summaryrefslogtreecommitdiffstats
path: root/comm/calendar
diff options
context:
space:
mode:
Diffstat (limited to 'comm/calendar')
-rw-r--r--comm/calendar/.prettierrc7
-rw-r--r--comm/calendar/base/calendar.js175
-rw-r--r--comm/calendar/base/content/calendar-base-view.js647
-rw-r--r--comm/calendar/base/content/calendar-chrome-startup.js438
-rw-r--r--comm/calendar/base/content/calendar-clipboard.js306
-rw-r--r--comm/calendar/base/content/calendar-command-controller.js869
-rw-r--r--comm/calendar/base/content/calendar-commands.inc.xhtml101
-rw-r--r--comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml949
-rw-r--r--comm/calendar/base/content/calendar-day-label.js128
-rw-r--r--comm/calendar/base/content/calendar-dnd-listener.js922
-rw-r--r--comm/calendar/base/content/calendar-editable-item.js464
-rw-r--r--comm/calendar/base/content/calendar-extract.js266
-rw-r--r--comm/calendar/base/content/calendar-invitation-display.js169
-rw-r--r--comm/calendar/base/content/calendar-invitations-manager.js385
-rw-r--r--comm/calendar/base/content/calendar-keys.inc.xhtml15
-rw-r--r--comm/calendar/base/content/calendar-management.js721
-rw-r--r--comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml105
-rw-r--r--comm/calendar/base/content/calendar-menus.js176
-rw-r--r--comm/calendar/base/content/calendar-migration.js323
-rw-r--r--comm/calendar/base/content/calendar-modes.js125
-rw-r--r--comm/calendar/base/content/calendar-month-view.js1242
-rw-r--r--comm/calendar/base/content/calendar-multiday-view.js3512
-rw-r--r--comm/calendar/base/content/calendar-print.js311
-rw-r--r--comm/calendar/base/content/calendar-status-bar.inc.xhtml75
-rw-r--r--comm/calendar/base/content/calendar-statusbar.js110
-rw-r--r--comm/calendar/base/content/calendar-tab-panels.inc.xhtml661
-rw-r--r--comm/calendar/base/content/calendar-tabs.js419
-rw-r--r--comm/calendar/base/content/calendar-task-tree-utils.js341
-rw-r--r--comm/calendar/base/content/calendar-task-tree-view.js495
-rw-r--r--comm/calendar/base/content/calendar-task-tree.js685
-rw-r--r--comm/calendar/base/content/calendar-task-view.js470
-rw-r--r--comm/calendar/base/content/calendar-today-pane.inc.xhtml179
-rw-r--r--comm/calendar/base/content/calendar-ui-utils.js596
-rw-r--r--comm/calendar/base/content/calendar-unifinder.js988
-rw-r--r--comm/calendar/base/content/calendar-view-menu.inc.xhtml195
-rw-r--r--comm/calendar/base/content/calendar-views-utils.js617
-rw-r--r--comm/calendar/base/content/calendar-views.js286
-rw-r--r--comm/calendar/base/content/dialogs/calendar-alarm-dialog.js484
-rw-r--r--comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml53
-rw-r--r--comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js43
-rw-r--r--comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml35
-rw-r--r--comm/calendar/base/content/dialogs/calendar-creation.js836
-rw-r--r--comm/calendar/base/content/dialogs/calendar-creation.xhtml259
-rw-r--r--comm/calendar/base/content/dialogs/calendar-dialog-utils.js662
-rw-r--r--comm/calendar/base/content/dialogs/calendar-error-prompt.js21
-rw-r--r--comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml59
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js1601
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml227
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js1237
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml1077
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js508
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml148
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js126
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml63
-rw-r--r--comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml584
-rw-r--r--comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js476
-rw-r--r--comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml137
-rw-r--r--comm/calendar/base/content/dialogs/calendar-identity-utils.js187
-rw-r--r--comm/calendar/base/content/dialogs/calendar-invitations-dialog.js310
-rw-r--r--comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml53
-rw-r--r--comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js52
-rw-r--r--comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml42
-rw-r--r--comm/calendar/base/content/dialogs/calendar-migration-dialog.js113
-rw-r--r--comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml49
-rw-r--r--comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js62
-rw-r--r--comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml61
-rw-r--r--comm/calendar/base/content/dialogs/calendar-properties-dialog.js251
-rw-r--r--comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml257
-rw-r--r--comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js58
-rw-r--r--comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml45
-rw-r--r--comm/calendar/base/content/dialogs/calendar-summary-dialog.js381
-rw-r--r--comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml232
-rw-r--r--comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js26
-rw-r--r--comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml46
-rw-r--r--comm/calendar/base/content/dialogs/chooseCalendarDialog.js89
-rw-r--r--comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml35
-rw-r--r--comm/calendar/base/content/dialogs/publishDialog.js68
-rw-r--r--comm/calendar/base/content/dialogs/publishDialog.xhtml51
-rw-r--r--comm/calendar/base/content/imip-bar-overlay.inc.xhtml296
-rw-r--r--comm/calendar/base/content/imip-bar.js429
-rw-r--r--comm/calendar/base/content/import-export.js330
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-editing.js849
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-iframe.js4302
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml1225
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml130
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-panel.js1143
-rw-r--r--comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml164
-rw-r--r--comm/calendar/base/content/item-editing/calendar-task-editing.js181
-rw-r--r--comm/calendar/base/content/preferences/alarms.inc.xhtml188
-rw-r--r--comm/calendar/base/content/preferences/alarms.js165
-rw-r--r--comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml55
-rw-r--r--comm/calendar/base/content/preferences/calendar-preferences.js28
-rw-r--r--comm/calendar/base/content/preferences/categories.inc.xhtml32
-rw-r--r--comm/calendar/base/content/preferences/categories.js297
-rw-r--r--comm/calendar/base/content/preferences/editCategory.js112
-rw-r--r--comm/calendar/base/content/preferences/editCategory.xhtml49
-rw-r--r--comm/calendar/base/content/preferences/general.inc.xhtml185
-rw-r--r--comm/calendar/base/content/preferences/general.js148
-rw-r--r--comm/calendar/base/content/preferences/notifications.js24
-rw-r--r--comm/calendar/base/content/preferences/views.inc.xhtml311
-rw-r--r--comm/calendar/base/content/preferences/views.js115
-rw-r--r--comm/calendar/base/content/printing-template.html285
-rw-r--r--comm/calendar/base/content/publish.js239
-rw-r--r--comm/calendar/base/content/sound.wavbin0 -> 66194 bytes
-rw-r--r--comm/calendar/base/content/today-pane-agenda.js668
-rw-r--r--comm/calendar/base/content/today-pane.js535
-rw-r--r--comm/calendar/base/content/widgets/calendar-alarm-widget.js402
-rw-r--r--comm/calendar/base/content/widgets/calendar-dnd-widgets.js192
-rw-r--r--comm/calendar/base/content/widgets/calendar-filter-tree-view.js371
-rw-r--r--comm/calendar/base/content/widgets/calendar-filter.js1365
-rw-r--r--comm/calendar/base/content/widgets/calendar-invitation-panel.js799
-rw-r--r--comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml96
-rw-r--r--comm/calendar/base/content/widgets/calendar-item-summary.js761
-rw-r--r--comm/calendar/base/content/widgets/calendar-minidate.js83
-rw-r--r--comm/calendar/base/content/widgets/calendar-minidate.xhtml17
-rw-r--r--comm/calendar/base/content/widgets/calendar-minimonth.js1055
-rw-r--r--comm/calendar/base/content/widgets/calendar-modebox.js244
-rw-r--r--comm/calendar/base/content/widgets/calendar-notifications-setting.js259
-rw-r--r--comm/calendar/base/content/widgets/datetimepickers.js1529
-rw-r--r--comm/calendar/base/content/widgets/mouseoverPreviews.js439
-rw-r--r--comm/calendar/base/jar.mn111
-rw-r--r--comm/calendar/base/modules/Ical.jsm9707
-rw-r--r--comm/calendar/base/modules/calCalendarDeactivator.jsm171
-rw-r--r--comm/calendar/base/modules/calExtract.jsm1417
-rw-r--r--comm/calendar/base/modules/calHashedArray.jsm258
-rw-r--r--comm/calendar/base/modules/calRecurrenceUtils.jsm553
-rw-r--r--comm/calendar/base/modules/calUtils.jsm578
-rw-r--r--comm/calendar/base/modules/moz.build36
-rw-r--r--comm/calendar/base/modules/utils/calACLUtils.jsm92
-rw-r--r--comm/calendar/base/modules/utils/calAlarmUtils.jsm161
-rw-r--r--comm/calendar/base/modules/utils/calAuthUtils.jsm564
-rw-r--r--comm/calendar/base/modules/utils/calCategoryUtils.jsm103
-rw-r--r--comm/calendar/base/modules/utils/calDataUtils.jsm313
-rw-r--r--comm/calendar/base/modules/utils/calDateTimeFormatter.jsm620
-rw-r--r--comm/calendar/base/modules/utils/calDateTimeUtils.jsm430
-rw-r--r--comm/calendar/base/modules/utils/calEmailUtils.jsm218
-rw-r--r--comm/calendar/base/modules/utils/calInvitationUtils.jsm875
-rw-r--r--comm/calendar/base/modules/utils/calItemUtils.jsm675
-rw-r--r--comm/calendar/base/modules/utils/calIteratorUtils.jsm279
-rw-r--r--comm/calendar/base/modules/utils/calItipUtils.jsm2181
-rw-r--r--comm/calendar/base/modules/utils/calL10NUtils.jsm162
-rw-r--r--comm/calendar/base/modules/utils/calPrintUtils.jsm616
-rw-r--r--comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm182
-rw-r--r--comm/calendar/base/modules/utils/calProviderUtils.jsm907
-rw-r--r--comm/calendar/base/modules/utils/calUnifinderUtils.jsm206
-rw-r--r--comm/calendar/base/modules/utils/calViewUtils.jsm521
-rw-r--r--comm/calendar/base/modules/utils/calWindowUtils.jsm182
-rw-r--r--comm/calendar/base/modules/utils/calXMLUtils.jsm188
-rw-r--r--comm/calendar/base/moz.build45
-rw-r--r--comm/calendar/base/public/calIAlarm.idl157
-rw-r--r--comm/calendar/base/public/calIAlarmService.idl116
-rw-r--r--comm/calendar/base/public/calIAttachment.idl57
-rw-r--r--comm/calendar/base/public/calIAttendee.idl80
-rw-r--r--comm/calendar/base/public/calICalendar.idl605
-rw-r--r--comm/calendar/base/public/calICalendarACLManager.idl86
-rw-r--r--comm/calendar/base/public/calICalendarManager.idl139
-rw-r--r--comm/calendar/base/public/calICalendarProvider.idl89
-rw-r--r--comm/calendar/base/public/calICalendarView.idl216
-rw-r--r--comm/calendar/base/public/calICalendarViewController.idl75
-rw-r--r--comm/calendar/base/public/calIChangeLog.idl151
-rw-r--r--comm/calendar/base/public/calIDateTime.idl228
-rw-r--r--comm/calendar/base/public/calIDeletedItems.idl26
-rw-r--r--comm/calendar/base/public/calIDuration.idl104
-rw-r--r--comm/calendar/base/public/calIErrors.idl117
-rw-r--r--comm/calendar/base/public/calIEvent.idl42
-rw-r--r--comm/calendar/base/public/calIFreeBusyProvider.idl109
-rw-r--r--comm/calendar/base/public/calIICSService.idl242
-rw-r--r--comm/calendar/base/public/calIIcsParser.idl81
-rw-r--r--comm/calendar/base/public/calIIcsSerializer.idl73
-rw-r--r--comm/calendar/base/public/calIImportExport.idl56
-rw-r--r--comm/calendar/base/public/calIItemBase.idl372
-rw-r--r--comm/calendar/base/public/calIItipItem.idl112
-rw-r--r--comm/calendar/base/public/calIItipTransport.idl48
-rw-r--r--comm/calendar/base/public/calIOperation.idl46
-rw-r--r--comm/calendar/base/public/calIPeriod.idl58
-rw-r--r--comm/calendar/base/public/calIRecurrenceDate.idl24
-rw-r--r--comm/calendar/base/public/calIRecurrenceInfo.idl184
-rw-r--r--comm/calendar/base/public/calIRecurrenceItem.idl60
-rw-r--r--comm/calendar/base/public/calIRecurrenceRule.idl53
-rw-r--r--comm/calendar/base/public/calIRelation.idl45
-rw-r--r--comm/calendar/base/public/calISchedulingSupport.idl47
-rw-r--r--comm/calendar/base/public/calIStartupService.idl30
-rw-r--r--comm/calendar/base/public/calIStatusObserver.idl60
-rw-r--r--comm/calendar/base/public/calITimezone.idl43
-rw-r--r--comm/calendar/base/public/calITimezoneDatabase.idl37
-rw-r--r--comm/calendar/base/public/calITimezoneService.idl50
-rw-r--r--comm/calendar/base/public/calITodo.idl72
-rw-r--r--comm/calendar/base/public/calIWeekInfoService.idl50
-rw-r--r--comm/calendar/base/public/moz.build56
-rw-r--r--comm/calendar/base/src/CalAlarm.jsm693
-rw-r--r--comm/calendar/base/src/CalAlarmMonitor.jsm233
-rw-r--r--comm/calendar/base/src/CalAlarmService.jsm827
-rw-r--r--comm/calendar/base/src/CalAttachment.jsm169
-rw-r--r--comm/calendar/base/src/CalAttendee.jsm212
-rw-r--r--comm/calendar/base/src/CalCalendarManager.jsm1076
-rw-r--r--comm/calendar/base/src/CalDateTime.jsm202
-rw-r--r--comm/calendar/base/src/CalDefaultACLManager.jsm97
-rw-r--r--comm/calendar/base/src/CalDeletedItems.jsm200
-rw-r--r--comm/calendar/base/src/CalDuration.jsm106
-rw-r--r--comm/calendar/base/src/CalEvent.jsm225
-rw-r--r--comm/calendar/base/src/CalFreeBusyService.jsm89
-rw-r--r--comm/calendar/base/src/CalICSService.jsm604
-rw-r--r--comm/calendar/base/src/CalIcsParser.jsm334
-rw-r--r--comm/calendar/base/src/CalIcsSerializer.jsm77
-rw-r--r--comm/calendar/base/src/CalItipItem.jsm212
-rw-r--r--comm/calendar/base/src/CalMetronome.jsm142
-rw-r--r--comm/calendar/base/src/CalMimeConverter.jsm69
-rw-r--r--comm/calendar/base/src/CalPeriod.jsm87
-rw-r--r--comm/calendar/base/src/CalProtocolHandler.jsm63
-rw-r--r--comm/calendar/base/src/CalReadableStreamFactory.jsm314
-rw-r--r--comm/calendar/base/src/CalRecurrenceDate.jsm122
-rw-r--r--comm/calendar/base/src/CalRecurrenceInfo.jsm847
-rw-r--r--comm/calendar/base/src/CalRecurrenceRule.jsm268
-rw-r--r--comm/calendar/base/src/CalRelation.jsm125
-rw-r--r--comm/calendar/base/src/CalStartupService.jsm124
-rw-r--r--comm/calendar/base/src/CalTimezone.jsm77
-rw-r--r--comm/calendar/base/src/CalTimezoneService.jsm228
-rw-r--r--comm/calendar/base/src/CalTodo.jsm264
-rw-r--r--comm/calendar/base/src/CalTransactionManager.jsm372
-rw-r--r--comm/calendar/base/src/CalWeekInfoService.jsm113
-rw-r--r--comm/calendar/base/src/TimezoneDatabase.cpp114
-rw-r--r--comm/calendar/base/src/TimezoneDatabase.h20
-rw-r--r--comm/calendar/base/src/calApplicationUtils.js47
-rw-r--r--comm/calendar/base/src/calCachedCalendar.js957
-rw-r--r--comm/calendar/base/src/calICSService-worker.js21
-rw-r--r--comm/calendar/base/src/calInternalInterfaces.idl29
-rw-r--r--comm/calendar/base/src/calItemBase.js1198
-rw-r--r--comm/calendar/base/src/components.conf208
-rw-r--r--comm/calendar/base/src/moz.build71
-rw-r--r--comm/calendar/base/themes/common/calendar-alarms.css83
-rw-r--r--comm/calendar/base/themes/common/calendar-attendees.css216
-rw-r--r--comm/calendar/base/themes/common/calendar-creation.css99
-rw-r--r--comm/calendar/base/themes/common/calendar-daypicker.css28
-rw-r--r--comm/calendar/base/themes/common/calendar-invitation-display.css13
-rw-r--r--comm/calendar/base/themes/common/calendar-item-summary.css77
-rw-r--r--comm/calendar/base/themes/common/calendar-itip-icons.svg122
-rw-r--r--comm/calendar/base/themes/common/calendar-occurrence-prompt.css67
-rw-r--r--comm/calendar/base/themes/common/calendar-occurrence.svg20
-rw-r--r--comm/calendar/base/themes/common/calendar-preferences.css76
-rw-r--r--comm/calendar/base/themes/common/calendar-print.css66
-rw-r--r--comm/calendar/base/themes/common/calendar-providerUninstall-dialog.css13
-rw-r--r--comm/calendar/base/themes/common/calendar-task-tree.css132
-rw-r--r--comm/calendar/base/themes/common/calendar-task-view.css236
-rw-r--r--comm/calendar/base/themes/common/calendar-toolbar.css21
-rw-r--r--comm/calendar/base/themes/common/calendar-unifinder.css40
-rw-r--r--comm/calendar/base/themes/common/calendar-views.css1232
-rw-r--r--comm/calendar/base/themes/common/calendar.css255
-rw-r--r--comm/calendar/base/themes/common/datetimepickers.css260
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css112
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-event-dialog-attendees.css264
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-event-dialog.css677
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-ics-file-dialog.css107
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css80
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-itip-identity-dialog.css11
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-properties-dialog.css89
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-summary-dialog.css47
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css142
-rw-r--r--comm/calendar/base/themes/common/dialogs/calendar-uri-redirect-dialog.css13
-rw-r--r--comm/calendar/base/themes/common/dialogs/chooseCalendarDialog.css19
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.pngbin0 -> 8696 bytes
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.pngbin0 -> 2150 bytes
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/chain-lock.svg6
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/chain-unlock.svg6
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/link-image-bottom.svg6
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/link-image-top.svg7
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/statusbar-priority-high.svg8
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/statusbar-priority-low.svg6
-rw-r--r--comm/calendar/base/themes/common/dialogs/images/statusbar-priority-normal.svg7
-rw-r--r--comm/calendar/base/themes/common/icons/alarm-no.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/alarm.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/complete.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/confidential.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/decline.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/edit.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/email.svg7
-rw-r--r--comm/calendar/base/themes/common/icons/event.svg10
-rw-r--r--comm/calendar/base/themes/common/icons/find.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/freebusy.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/icon32.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/imip-bar.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/locked.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/low-priority.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/newevent.svg10
-rw-r--r--comm/calendar/base/themes/common/icons/newtask.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/pane.svg8
-rw-r--r--comm/calendar/base/themes/common/icons/priority.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/private.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/recurrence-exception.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/recurrence.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/save-close.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/sort.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/status.svg10
-rw-r--r--comm/calendar/base/themes/common/icons/synchronize.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/task-tab.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/task.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/tentative.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/timezones.svg6
-rw-r--r--comm/calendar/base/themes/common/icons/today.svg9
-rw-r--r--comm/calendar/base/themes/common/icons/warn.svg6
-rw-r--r--comm/calendar/base/themes/common/images/attendee-icons.pngbin0 -> 5592 bytes
-rw-r--r--comm/calendar/base/themes/common/images/event-continue.svg6
-rw-r--r--comm/calendar/base/themes/common/images/event-end.svg6
-rw-r--r--comm/calendar/base/themes/common/images/event-grippy.pngbin0 -> 98 bytes
-rw-r--r--comm/calendar/base/themes/common/images/event-start.svg6
-rw-r--r--comm/calendar/base/themes/common/images/timezone_map.pngbin0 -> 14423 bytes
-rw-r--r--comm/calendar/base/themes/common/images/timezones.pngbin0 -> 101805 bytes
-rw-r--r--comm/calendar/base/themes/common/images/todayButton-arrow.svg6
-rw-r--r--comm/calendar/base/themes/common/images/todo-complete.svg6
-rw-r--r--comm/calendar/base/themes/common/images/todo.svg6
-rw-r--r--comm/calendar/base/themes/common/imip.css76
-rw-r--r--comm/calendar/base/themes/common/jar.inc.mn99
-rw-r--r--comm/calendar/base/themes/common/publishDialog.css53
-rw-r--r--comm/calendar/base/themes/common/today-pane.css433
-rw-r--r--comm/calendar/base/themes/common/view-cycler.svg6
-rw-r--r--comm/calendar/base/themes/common/widgets/calendar-invitation-panel.css135
-rw-r--r--comm/calendar/base/themes/common/widgets/calendar-minidate.css36
-rw-r--r--comm/calendar/base/themes/common/widgets/calendar-widgets.css388
-rw-r--r--comm/calendar/base/themes/common/widgets/images/drag-center.svg9
-rw-r--r--comm/calendar/base/themes/common/widgets/images/nav-arrow.svg6
-rw-r--r--comm/calendar/base/themes/common/widgets/images/nav-today.svg7
-rw-r--r--comm/calendar/base/themes/common/widgets/images/view-navigation.svg6
-rw-r--r--comm/calendar/base/themes/common/widgets/minimonth.css385
-rw-r--r--comm/calendar/base/themes/linux/calendar-daypicker.css18
-rw-r--r--comm/calendar/base/themes/linux/calendar-task-tree.css13
-rw-r--r--comm/calendar/base/themes/linux/calendar-task-view.css35
-rw-r--r--comm/calendar/base/themes/linux/calendar-unifinder.css33
-rw-r--r--comm/calendar/base/themes/linux/calendar-views.css9
-rw-r--r--comm/calendar/base/themes/linux/calendar.css46
-rw-r--r--comm/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css5
-rw-r--r--comm/calendar/base/themes/linux/dialogs/calendar-event-dialog.css43
-rw-r--r--comm/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css5
-rw-r--r--comm/calendar/base/themes/linux/imip.css5
-rw-r--r--comm/calendar/base/themes/linux/jar.mn19
-rw-r--r--comm/calendar/base/themes/linux/moz.build6
-rw-r--r--comm/calendar/base/themes/linux/today-pane.css29
-rw-r--r--comm/calendar/base/themes/linux/widgets/calendar-widgets.css22
-rw-r--r--comm/calendar/base/themes/moz.build11
-rw-r--r--comm/calendar/base/themes/osx/calendar-daypicker.css18
-rw-r--r--comm/calendar/base/themes/osx/calendar-task-tree.css9
-rw-r--r--comm/calendar/base/themes/osx/calendar-task-view.css41
-rw-r--r--comm/calendar/base/themes/osx/calendar-unifinder.css22
-rw-r--r--comm/calendar/base/themes/osx/calendar-views.css9
-rw-r--r--comm/calendar/base/themes/osx/calendar.css69
-rw-r--r--comm/calendar/base/themes/osx/dialogs/calendar-alarm-dialog.css18
-rw-r--r--comm/calendar/base/themes/osx/dialogs/calendar-event-dialog.css27
-rw-r--r--comm/calendar/base/themes/osx/dialogs/calendar-invitations-dialog.css5
-rw-r--r--comm/calendar/base/themes/osx/imip.css10
-rw-r--r--comm/calendar/base/themes/osx/jar.mn19
-rw-r--r--comm/calendar/base/themes/osx/moz.build6
-rw-r--r--comm/calendar/base/themes/osx/today-pane.css51
-rw-r--r--comm/calendar/base/themes/osx/widgets/calendar-widgets.css19
-rw-r--r--comm/calendar/base/themes/windows/calendar-daypicker.css18
-rw-r--r--comm/calendar/base/themes/windows/calendar-task-tree.css60
-rw-r--r--comm/calendar/base/themes/windows/calendar-task-view.css58
-rw-r--r--comm/calendar/base/themes/windows/calendar-unifinder.css26
-rw-r--r--comm/calendar/base/themes/windows/calendar-views.css16
-rw-r--r--comm/calendar/base/themes/windows/calendar.css52
-rw-r--r--comm/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css19
-rw-r--r--comm/calendar/base/themes/windows/dialogs/calendar-event-dialog.css42
-rw-r--r--comm/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css5
-rw-r--r--comm/calendar/base/themes/windows/imip.css5
-rw-r--r--comm/calendar/base/themes/windows/jar.mn19
-rw-r--r--comm/calendar/base/themes/windows/moz.build6
-rw-r--r--comm/calendar/base/themes/windows/today-pane.css64
-rw-r--r--comm/calendar/base/themes/windows/widgets/calendar-widgets.css29
-rw-r--r--comm/calendar/extract/CalExtractParser.jsm545
-rw-r--r--comm/calendar/extract/CalExtractParserService.jsm308
-rw-r--r--comm/calendar/extract/moz.build9
-rw-r--r--comm/calendar/import-export/CalHtmlExport.jsm116
-rw-r--r--comm/calendar/import-export/CalIcsImportExport.jsm55
-rw-r--r--comm/calendar/import-export/calHtmlExport.html71
-rw-r--r--comm/calendar/import-export/components.conf29
-rw-r--r--comm/calendar/import-export/jar.mn7
-rw-r--r--comm/calendar/import-export/moz.build18
-rw-r--r--comm/calendar/itip/CalItipEmailTransport.jsm439
-rw-r--r--comm/calendar/itip/CalItipMessageSender.jsm433
-rw-r--r--comm/calendar/itip/CalItipOutgoingMessage.jsm93
-rw-r--r--comm/calendar/itip/CalItipProtocolHandler.jsm118
-rw-r--r--comm/calendar/itip/components.conf39
-rw-r--r--comm/calendar/itip/moz.build18
-rw-r--r--comm/calendar/locales/Makefile.in6
-rw-r--r--comm/calendar/locales/all-locales66
-rw-r--r--comm/calendar/locales/en-US/README.txt3
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-context-menus.ftl11
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-delete-prompt.ftl43
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-editable-item.ftl42
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-event-dialog-reminder.ftl12
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-ics-file-dialog.ftl61
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-invitation-panel.ftl137
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-invitations-dialog.ftl12
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-itip-identity-dialog.ftl11
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-print.ftl20
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-recurrence-dialog.ftl11
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-summary-dialog.ftl21
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-uri-redirect-dialog.ftl15
-rw-r--r--comm/calendar/locales/en-US/calendar/calendar-widgets.ftl145
-rw-r--r--comm/calendar/locales/en-US/calendar/category-dialog.ftl8
-rw-r--r--comm/calendar/locales/en-US/calendar/preferences.ftl237
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-alarms.properties39
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog-attendees.properties15
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd418
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.properties541
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-extract.properties294
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.dtd13
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.properties10
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.dtd7
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.properties53
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar.dtd354
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendar.properties696
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendarCreation.dtd51
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/calendarCreation.properties6
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/categories.properties7
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/dateFormat.properties146
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/dialogs/calendar-event-dialog-reminder.dtd19
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/global.dtd21
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/menuOverlay.dtd46
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/migration.dtd9
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/migration.properties11
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/provider-uninstall.dtd12
-rw-r--r--comm/calendar/locales/en-US/chrome/calendar/timezones.properties490
-rw-r--r--comm/calendar/locales/en-US/chrome/lightning/lightning-toolbar.dtd25
-rw-r--r--comm/calendar/locales/en-US/chrome/lightning/lightning.dtd112
-rw-r--r--comm/calendar/locales/en-US/chrome/lightning/lightning.properties165
-rw-r--r--comm/calendar/locales/filter.py27
-rw-r--r--comm/calendar/locales/jar.mn38
-rw-r--r--comm/calendar/locales/l10n.ini12
-rw-r--r--comm/calendar/locales/l10n.toml30
-rw-r--r--comm/calendar/locales/moz.build6
-rw-r--r--comm/calendar/locales/shipped-locales38
-rw-r--r--comm/calendar/moz.build34
-rw-r--r--comm/calendar/providers/caldav/CalDavCalendar.jsm2464
-rw-r--r--comm/calendar/providers/caldav/CalDavProvider.jsm426
-rw-r--r--comm/calendar/providers/caldav/components.conf14
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequest.jsm1211
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm1091
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavSession.jsm573
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavUtils.jsm110
-rw-r--r--comm/calendar/providers/caldav/moz.build25
-rw-r--r--comm/calendar/providers/caldav/public/calICalDavCalendar.idl20
-rw-r--r--comm/calendar/providers/caldav/public/moz.build10
-rw-r--r--comm/calendar/providers/composite/CalCompositeCalendar.jsm426
-rw-r--r--comm/calendar/providers/composite/components.conf14
-rw-r--r--comm/calendar/providers/composite/moz.build12
-rw-r--r--comm/calendar/providers/ics/CalICSCalendar.sys.mjs1235
-rw-r--r--comm/calendar/providers/ics/CalICSProvider.jsm447
-rw-r--r--comm/calendar/providers/ics/components.conf14
-rw-r--r--comm/calendar/providers/ics/moz.build16
-rw-r--r--comm/calendar/providers/memory/CalMemoryCalendar.jsm538
-rw-r--r--comm/calendar/providers/memory/components.conf14
-rw-r--r--comm/calendar/providers/memory/moz.build12
-rw-r--r--comm/calendar/providers/moz.build12
-rw-r--r--comm/calendar/providers/storage/CalStorageCachedItemModel.jsm219
-rw-r--r--comm/calendar/providers/storage/CalStorageCalendar.jsm563
-rw-r--r--comm/calendar/providers/storage/CalStorageDatabase.jsm333
-rw-r--r--comm/calendar/providers/storage/CalStorageItemModel.jsm1374
-rw-r--r--comm/calendar/providers/storage/CalStorageMetaDataModel.jsm94
-rw-r--r--comm/calendar/providers/storage/CalStorageModelBase.jsm65
-rw-r--r--comm/calendar/providers/storage/CalStorageModelFactory.jsm52
-rw-r--r--comm/calendar/providers/storage/CalStorageOfflineModel.jsm54
-rw-r--r--comm/calendar/providers/storage/CalStorageStatements.jsm751
-rw-r--r--comm/calendar/providers/storage/calStorageHelpers.jsm121
-rw-r--r--comm/calendar/providers/storage/calStorageUpgrade.jsm1889
-rw-r--r--comm/calendar/providers/storage/components.conf14
-rw-r--r--comm/calendar/providers/storage/moz.build28
-rw-r--r--comm/calendar/test/.eslintrc.js74
-rw-r--r--comm/calendar/test/CalDAVServer.jsm627
-rw-r--r--comm/calendar/test/CalendarTestUtils.jsm1203
-rw-r--r--comm/calendar/test/CalendarUtils.jsm87
-rw-r--r--comm/calendar/test/ICSServer.jsm153
-rw-r--r--comm/calendar/test/ItemEditingHelpers.jsm681
-rw-r--r--comm/calendar/test/browser/browser.ini39
-rw-r--r--comm/calendar/test/browser/browser_basicFunctionality.js78
-rw-r--r--comm/calendar/test/browser/browser_calDAV_discovery.js241
-rw-r--r--comm/calendar/test/browser/browser_calDAV_oAuth.js201
-rw-r--r--comm/calendar/test/browser/browser_calendarList.js341
-rw-r--r--comm/calendar/test/browser/browser_calendarTelemetry.js119
-rw-r--r--comm/calendar/test/browser/browser_calendarUnifinder.js76
-rw-r--r--comm/calendar/test/browser/browser_dragEventItem.js414
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_dayView.js133
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js275
-rw-r--r--comm/calendar/test/browser/browser_eventDisplay_weekView.js151
-rw-r--r--comm/calendar/test/browser/browser_eventUndoRedo.js260
-rw-r--r--comm/calendar/test/browser/browser_import.js285
-rw-r--r--comm/calendar/test/browser/browser_localICS.js63
-rw-r--r--comm/calendar/test/browser/browser_tabs.js26
-rw-r--r--comm/calendar/test/browser/browser_taskDelete.js185
-rw-r--r--comm/calendar/test/browser/browser_taskDisplay.js274
-rw-r--r--comm/calendar/test/browser/browser_taskUndoRedo.js244
-rw-r--r--comm/calendar/test/browser/browser_todayPane.js820
-rw-r--r--comm/calendar/test/browser/browser_todayPane_dragAndDrop.js262
-rw-r--r--comm/calendar/test/browser/browser_todayPane_visibility.js167
-rw-r--r--comm/calendar/test/browser/contextMenu/browser.ini14
-rw-r--r--comm/calendar/test/browser/contextMenu/browser_edit.js187
-rw-r--r--comm/calendar/test/browser/data/attachment.pngbin0 -> 82 bytes
-rw-r--r--comm/calendar/test/browser/data/calendars.sjs126
-rw-r--r--comm/calendar/test/browser/data/dns.sjs56
-rw-r--r--comm/calendar/test/browser/data/event.ics10
-rw-r--r--comm/calendar/test/browser/data/import.ics24
-rw-r--r--comm/calendar/test/browser/data/principal.sjs39
-rw-r--r--comm/calendar/test/browser/eventDialog/browser.ini27
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_alarmDialog.js88
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attachMenu.js266
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js462
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js248
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js68
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js147
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js140
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialog.js399
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js154
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js223
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js160
-rw-r--r--comm/calendar/test/browser/eventDialog/browser_utf8.js56
-rw-r--r--comm/calendar/test/browser/eventDialog/data/guests.txt2
-rw-r--r--comm/calendar/test/browser/eventDialog/head.js97
-rw-r--r--comm/calendar/test/browser/head.js374
-rw-r--r--comm/calendar/test/browser/invitations/browser.ini31
-rw-r--r--comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js72
-rw-r--r--comm/calendar/test/browser/invitations/browser_icsAttachment.js71
-rw-r--r--comm/calendar/test/browser/invitations/browser_identityPrompt.js144
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBar.js199
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarCancel.js129
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarEmail.js168
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js137
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js262
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarExceptions.js288
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeat.js218
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js186
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js247
-rw-r--r--comm/calendar/test/browser/invitations/browser_imipBarUpdates.js223
-rw-r--r--comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js257
-rw-r--r--comm/calendar/test/browser/invitations/browser_unsupportedFreq.js107
-rw-r--r--comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/cancel-single-event.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/exception-major.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/exception-minor.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml384
-rw-r--r--comm/calendar/test/browser/invitations/data/message-containing-event.eml44
-rw-r--r--comm/calendar/test/browser/invitations/data/message-non-invite.eml115
-rw-r--r--comm/calendar/test/browser/invitations/data/outlook-test-invite.eml102
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-event.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-update-major.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/repeat-update-minor.eml49
-rw-r--r--comm/calendar/test/browser/invitations/data/single-event.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml167
-rw-r--r--comm/calendar/test/browser/invitations/data/update-major.eml78
-rw-r--r--comm/calendar/test/browser/invitations/data/update-minor.eml78
-rw-r--r--comm/calendar/test/browser/invitations/head.js942
-rw-r--r--comm/calendar/test/browser/preferences/browser.ini16
-rw-r--r--comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js176
-rw-r--r--comm/calendar/test/browser/preferences/browser_categoryColors.js90
-rw-r--r--comm/calendar/test/browser/preferences/head.js64
-rw-r--r--comm/calendar/test/browser/providers/browser.ini21
-rw-r--r--comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js64
-rw-r--r--comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js61
-rw-r--r--comm/calendar/test/browser/providers/browser_icsCalendar_cached.js73
-rw-r--r--comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js64
-rw-r--r--comm/calendar/test/browser/providers/browser_storageCalendar.js13
-rw-r--r--comm/calendar/test/browser/providers/head.js402
-rw-r--r--comm/calendar/test/browser/recurrence/browser.ini23
-rw-r--r--comm/calendar/test/browser/recurrence/browser_annual.js69
-rw-r--r--comm/calendar/test/browser/recurrence/browser_biweekly.js85
-rw-r--r--comm/calendar/test/browser/recurrence/browser_daily.js162
-rw-r--r--comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js112
-rw-r--r--comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js138
-rw-r--r--comm/calendar/test/browser/recurrence/browser_rotated.ini24
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyN.js268
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyUntil.js175
-rw-r--r--comm/calendar/test/browser/recurrence/browser_weeklyWithException.js264
-rw-r--r--comm/calendar/test/browser/recurrence/head.js26
-rw-r--r--comm/calendar/test/browser/timezones/browser.ini17
-rw-r--r--comm/calendar/test/browser/timezones/browser_minimonth.js215
-rw-r--r--comm/calendar/test/browser/timezones/browser_timezones.js867
-rw-r--r--comm/calendar/test/browser/views/browser.ini32
-rw-r--r--comm/calendar/test/browser/views/browser_dayView.js185
-rw-r--r--comm/calendar/test/browser/views/browser_monthView.js86
-rw-r--r--comm/calendar/test/browser/views/browser_multiweekView.js88
-rw-r--r--comm/calendar/test/browser/views/browser_propertyChanges.js248
-rw-r--r--comm/calendar/test/browser/views/browser_taskView.js148
-rw-r--r--comm/calendar/test/browser/views/browser_viewSwitch.js138
-rw-r--r--comm/calendar/test/browser/views/browser_weekView.js81
-rw-r--r--comm/calendar/test/browser/views/head.js13
-rw-r--r--comm/calendar/test/moz.build30
-rw-r--r--comm/calendar/test/unit/data/bug1790339.sql194
-rw-r--r--comm/calendar/test/unit/data/import.ics24
-rw-r--r--comm/calendar/test/unit/head.js337
-rw-r--r--comm/calendar/test/unit/providers/head.js152
-rw-r--r--comm/calendar/test/unit/providers/test_caldavCalendar_cached.js201
-rw-r--r--comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js96
-rw-r--r--comm/calendar/test/unit/providers/test_icsCalendar_cached.js53
-rw-r--r--comm/calendar/test/unit/providers/test_icsCalendar_uncached.js46
-rw-r--r--comm/calendar/test/unit/providers/test_storageCalendar.js17
-rw-r--r--comm/calendar/test/unit/providers/xpcshell.ini11
-rw-r--r--comm/calendar/test/unit/test_CalendarFileImporter.js46
-rw-r--r--comm/calendar/test/unit/test_alarm.js674
-rw-r--r--comm/calendar/test/unit/test_alarmservice.js606
-rw-r--r--comm/calendar/test/unit/test_alarmutils.js171
-rw-r--r--comm/calendar/test/unit/test_attachment.js112
-rw-r--r--comm/calendar/test/unit/test_attendee.js318
-rw-r--r--comm/calendar/test/unit/test_auth_utils.js100
-rw-r--r--comm/calendar/test/unit/test_bug1199942.js81
-rw-r--r--comm/calendar/test/unit/test_bug1204255.js146
-rw-r--r--comm/calendar/test/unit/test_bug1209399.js117
-rw-r--r--comm/calendar/test/unit/test_bug1790339.js71
-rw-r--r--comm/calendar/test/unit/test_bug272411.js15
-rw-r--r--comm/calendar/test/unit/test_bug343792.js66
-rw-r--r--comm/calendar/test/unit/test_bug350845.js43
-rw-r--r--comm/calendar/test/unit/test_bug356207.js47
-rw-r--r--comm/calendar/test/unit/test_bug485571.js99
-rw-r--r--comm/calendar/test/unit/test_bug486186.js21
-rw-r--r--comm/calendar/test/unit/test_bug494140.js57
-rw-r--r--comm/calendar/test/unit/test_bug523860.js15
-rw-r--r--comm/calendar/test/unit/test_bug653924.js20
-rw-r--r--comm/calendar/test/unit/test_bug668222.js28
-rw-r--r--comm/calendar/test/unit/test_bug759324.js74
-rw-r--r--comm/calendar/test/unit/test_calIteratorUtils.js38
-rw-r--r--comm/calendar/test/unit/test_calStorageHelpers.js23
-rw-r--r--comm/calendar/test/unit/test_caldav_requests.js970
-rw-r--r--comm/calendar/test/unit/test_calmgr.js411
-rw-r--r--comm/calendar/test/unit/test_calreadablestreamfactory.js195
-rw-r--r--comm/calendar/test/unit/test_data_bags.js151
-rw-r--r--comm/calendar/test/unit/test_datetime.js99
-rw-r--r--comm/calendar/test/unit/test_datetime_before_1970.js31
-rw-r--r--comm/calendar/test/unit/test_datetimeformatter.js604
-rw-r--r--comm/calendar/test/unit/test_deleted_items.js106
-rw-r--r--comm/calendar/test/unit/test_duration.js10
-rw-r--r--comm/calendar/test/unit/test_email_utils.js265
-rw-r--r--comm/calendar/test/unit/test_extract.js225
-rw-r--r--comm/calendar/test/unit/test_extract_parser.js160
-rw-r--r--comm/calendar/test/unit/test_extract_parser_parse.js1317
-rw-r--r--comm/calendar/test/unit/test_extract_parser_service.js96
-rw-r--r--comm/calendar/test/unit/test_extract_parser_tokenize.js367
-rw-r--r--comm/calendar/test/unit/test_filter.js406
-rw-r--r--comm/calendar/test/unit/test_filter_mixin.js1083
-rw-r--r--comm/calendar/test/unit/test_filter_tree_view.js451
-rw-r--r--comm/calendar/test/unit/test_freebusy.js88
-rw-r--r--comm/calendar/test/unit/test_freebusy_service.js201
-rw-r--r--comm/calendar/test/unit/test_hashedarray.js210
-rw-r--r--comm/calendar/test/unit/test_ics.js235
-rw-r--r--comm/calendar/test/unit/test_ics_parser.js220
-rw-r--r--comm/calendar/test/unit/test_ics_service.js289
-rw-r--r--comm/calendar/test/unit/test_imip.js47
-rw-r--r--comm/calendar/test/unit/test_invitationutils.js1654
-rw-r--r--comm/calendar/test/unit/test_items.js465
-rw-r--r--comm/calendar/test/unit/test_itip_message_sender.js358
-rw-r--r--comm/calendar/test/unit/test_itip_utils.js831
-rw-r--r--comm/calendar/test/unit/test_l10n_utils.js99
-rw-r--r--comm/calendar/test/unit/test_lenient_parsing.js41
-rw-r--r--comm/calendar/test/unit/test_providers.js426
-rw-r--r--comm/calendar/test/unit/test_recur.js1361
-rw-r--r--comm/calendar/test/unit/test_recurrence_utils.js371
-rw-r--r--comm/calendar/test/unit/test_relation.js133
-rw-r--r--comm/calendar/test/unit/test_rfc3339_parser.js188
-rw-r--r--comm/calendar/test/unit/test_startup_service.js46
-rw-r--r--comm/calendar/test/unit/test_storage.js85
-rw-r--r--comm/calendar/test/unit/test_storage_connection.js127
-rw-r--r--comm/calendar/test/unit/test_storage_get_items.js338
-rw-r--r--comm/calendar/test/unit/test_timezone.js89
-rw-r--r--comm/calendar/test/unit/test_timezone_changes.js93
-rw-r--r--comm/calendar/test/unit/test_timezone_definition.js32
-rw-r--r--comm/calendar/test/unit/test_transaction_manager.js431
-rw-r--r--comm/calendar/test/unit/test_unifinder_utils.js137
-rw-r--r--comm/calendar/test/unit/test_utils.js185
-rw-r--r--comm/calendar/test/unit/test_view_utils.js127
-rw-r--r--comm/calendar/test/unit/test_webcal.js44
-rw-r--r--comm/calendar/test/unit/test_weekinfo_service.js33
-rw-r--r--comm/calendar/test/unit/xpcshell.ini82
666 files changed, 156900 insertions, 0 deletions
diff --git a/comm/calendar/.prettierrc b/comm/calendar/.prettierrc
new file mode 100644
index 0000000000..0bce966680
--- /dev/null
+++ b/comm/calendar/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "arrowParens": "avoid",
+ "endOfLine": "lf",
+ "printWidth": 100,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/comm/calendar/base/calendar.js b/comm/calendar/base/calendar.js
new file mode 100644
index 0000000000..57afc317cc
--- /dev/null
+++ b/comm/calendar/base/calendar.js
@@ -0,0 +1,175 @@
+#filter dumbComments emptyLines substitution
+
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+// This file contains all of the default preference values for Calendar.
+
+// Turns on basic calendar logging.
+pref("calendar.debug.log", false);
+// Turns on verbose calendar logging.
+pref("calendar.debug.log.verbose", false);
+
+// general settings
+pref("calendar.date.format", 0);
+pref("calendar.event.defaultlength", 60);
+pref("calendar.task.defaultstart", "none");
+pref("calendar.task.defaultstartoffset", 0);
+pref("calendar.task.defaultstartoffsetunits", "minutes");
+pref("calendar.task.defaultdue", "none");
+pref("calendar.task.defaultdueoffset", 60);
+pref("calendar.task.defaultdueoffsetunits", "minutes");
+
+// default transparency (free-busy status) of standard and all-day events
+pref("calendar.events.defaultTransparency.allday.transparent", true);
+pref("calendar.events.defaultTransparency.standard.transparent", false);
+
+// Make "Edit" the default action for events.
+pref("calendar.events.defaultActionEdit", false);
+
+// Number of days in Today Pane agenda
+pref("calendar.agenda.days", 14);
+
+// alarm settings
+pref("calendar.alarms.show", true);
+pref("calendar.alarms.showmissed", true);
+pref("calendar.alarms.playsound", true);
+pref("calendar.alarms.soundType", 0);
+pref("calendar.alarms.soundURL", "chrome://calendar/content/sound.wav");
+pref("calendar.alarms.defaultsnoozelength", 5);
+pref("calendar.alarms.indicator.show", true);
+pref("calendar.alarms.indicator.totaltime", 3600);
+
+// default alarm settings for new event
+pref("calendar.alarms.onforevents", 0);
+pref("calendar.alarms.eventalarmlen", 15);
+pref("calendar.alarms.eventalarmunit", "minutes");
+
+// default alarm settings for new task
+pref("calendar.alarms.onfortodos", 0);
+pref("calendar.alarms.todoalarmlen", 15);
+pref("calendar.alarms.todoalarmunit", "minutes");
+
+pref("calendar.alarms.loglevel", "Warn");
+
+// The default timeouts to show notifications for calendar items. The value
+// should be in the form of "-PT1D,PT2M,END:-PT3M", which means to show
+// notifications at: 1 day before the start, 2 minutes after the start, 3
+// minutes before the end.
+pref("calendar.notifications.times", "");
+
+// open invitations autorefresh settings
+pref("calendar.invitations.autorefresh.enabled", true);
+pref("calendar.invitations.autorefresh.timeout", 3);
+
+// whether "notify" is checked by default when creating new events/todos with attendees
+pref("calendar.itip.notify", true);
+
+// whether "Separate invitation per attendee" is checked by default
+pref("calendar.itip.separateInvitationPerAttendee", false);
+
+// whether the organizer propagates replies of attendees to all attendees
+pref("calendar.itip.notify-replies", false);
+
+// whether email invitation updates are send out to all attendees if (only) adding a new attendee
+pref("calendar.itip.updateInvitationForNewAttendeesOnly", false);
+
+//whether changes in email invitation updates should be displayed
+pref("calendar.itip.displayInvitationChanges", true);
+
+//whether for delegated invitations a delegatee's replies will be send also to delegator(s)
+pref("calendar.itip.notifyDelegatorOnReply", true);
+
+// whether to prefix the subject field for email invitation invites or updates.
+pref("calendar.itip.useInvitationSubjectPrefixes", true);
+
+// whether separate invitation actions to more separate buttons or integrate into few buttons
+pref("calendar.itip.separateInvitationButtons", true);
+
+// Whether to show the imip bar.
+pref("calendar.itip.showImipBar", true);
+
+// Whether to always expand the iMIP details, instead of collapsing them.
+pref("calendar.itip.imipDetailsOpen", true);
+
+// Temporary pref for using the new invitation display instead of the old one.
+pref("calendar.itip.newInvitationDisplay", false);
+
+// whether CalDAV (experimental) scheduling is enabled or not.
+pref("calendar.caldav.sched.enabled", false);
+
+// 0=Sunday, 1=Monday, 2=Tuesday, etc. One day we might want to move this to
+// a locale specific file.
+pref("calendar.week.start", 0);
+pref("calendar.weeks.inview", 4);
+pref("calendar.previousweeks.inview", 0);
+
+// Show week number in minimonth and multiweek/month views
+pref("calendar.view-minimonth.showWeekNumber", true);
+
+// Default days off
+pref("calendar.week.d0sundaysoff", true);
+pref("calendar.week.d1mondaysoff", false);
+pref("calendar.week.d2tuesdaysoff", false);
+pref("calendar.week.d3wednesdaysoff", false);
+pref("calendar.week.d4thursdaysoff", false);
+pref("calendar.week.d5fridaysoff", false);
+pref("calendar.week.d6saturdaysoff", true);
+
+// start and end work hour for day and week views
+pref("calendar.view.daystarthour", 8);
+pref("calendar.view.dayendhour", 17);
+
+// number of visible hours for day and week views
+pref("calendar.view.visiblehours", 9);
+
+// If true, mouse scrolling via shift+wheel will be enabled
+pref("calendar.view.mousescroll", true);
+
+// Do not set this! If it's not there, then we guess the system timezone
+//pref("calendar.timezone.local", "");
+
+// Recent timezone list
+pref("calendar.timezone.recent", "[]");
+
+// categories settings
+// XXX One day we might want to move this to a locale specific file
+// and include a list of locale specific default categories
+pref("calendar.categories.names", "");
+
+// Disable use of worker threads. Restart needed.
+pref("calendar.threading.disabled", false);
+
+// The maximum time in microseconds that a cal.iterate.forEach event can take (soft limit).
+pref("calendar.threading.latency ", 250);
+
+// Enable support for multiple realms on one server with the payoff that you
+// will get multiple password dialogs (one for each calendar)
+pref("calendar.network.multirealm", false);
+
+// Disable hiding the label on todayPane button
+pref("calendar.view.showTodayPaneStatusLabel", true);
+
+// Maximum number of iterations allowed when searching for the next matching
+// occurrence of a repeating item in calFilter
+pref("calendar.filter.maxiterations", 50);
+
+// Edit events and tasks in a tab rather than a window.
+pref("calendar.item.editInTab", false);
+
+// Always use the currently selected calendar as target for paste operations
+pref("calendar.paste.intoSelectedCalendar", false);
+
+pref("calendar.baseview.loglevel", "Warn");
+
+// Enables the prompt when deleting from the item views or trees.
+pref("calendar.item.promptDelete", true);
+
+// Enables the new extract service.
+pref("calendar.extract.service.enabled", false);
+
+// Number of days to display in the invite attendees interface.
+pref("calendar.view.attendees.visibleDays", 16);
+// Only full days are displayed the invite attendees interface.
+pref("calendar.view.attendees.showOnlyWholeDays", false);
diff --git a/comm/calendar/base/content/calendar-base-view.js b/comm/calendar/base/content/calendar-base-view.js
new file mode 100644
index 0000000000..317770b984
--- /dev/null
+++ b/comm/calendar/base/content/calendar-base-view.js
@@ -0,0 +1,647 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global cal, calendarNavigationBar, CalendarFilteredViewMixin, calFilterProperties, currentView,
+ gCurrentMode, MozElements, MozXULElement, Services, toggleOrientation */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+ /**
+ * Calendar observer for calendar view elements. Used in CalendarBaseView class.
+ *
+ * @implements {calIObserver}
+ * @implements {calICompositeObserver}
+ * @implements {calIAlarmServiceObserver}
+ */
+ class CalendarViewObserver {
+ /**
+ * Constructor for CalendarViewObserver.
+ *
+ * @param {CalendarBaseView} calendarView - A calendar view.
+ */
+ constructor(calendarView) {
+ this.calView = calendarView.calICalendarView;
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["calIAlarmServiceObserver"]);
+
+ // calIAlarmServiceObserver
+
+ onAlarm(alarmItem) {
+ this.calView.flashAlarm(alarmItem, false);
+ }
+
+ onNotification(item) {}
+
+ onRemoveAlarmsByItem(item) {
+ // Stop the flashing for the item.
+ this.calView.flashAlarm(item, true);
+ }
+
+ onRemoveAlarmsByCalendar(calendar) {
+ // Stop the flashing for all items of this calendar.
+ for (const key in this.calView.mFlashingEvents) {
+ const item = this.calView.mFlashingEvents[key];
+ if (item.calendar.id == calendar.id) {
+ this.calView.flashAlarm(item, true);
+ }
+ }
+ }
+
+ onAlarmsLoaded(calendar) {}
+
+ // End calIAlarmServiceObserver
+ }
+
+ /**
+ * Abstract base class for calendar view elements (day, week, multiweek, month).
+ *
+ * @implements {calICalendarView}
+ * @abstract
+ */
+ class CalendarBaseView extends CalendarFilteredViewMixin(MozXULElement) {
+ /**
+ * Whether the view has been initialized.
+ *
+ * @type {boolean}
+ */
+ #isInitialized = false;
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ // For some unknown reason, `console.createInstance` isn't available when
+ // `ensureInitialized` runs.
+ this.mLog = console.createInstance({
+ prefix: `calendar.baseview (${this.constructor.name})`,
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "calendar.baseview.loglevel",
+ });
+
+ this.mSelectedItems = [];
+ }
+
+ ensureInitialized() {
+ if (this.#isInitialized) {
+ return;
+ }
+ this.#isInitialized = true;
+
+ this.calICalendarView = this.getCustomInterfaceCallback(Ci.calICalendarView);
+
+ this.addEventListener("move", event => {
+ this.moveView(event.detail);
+ });
+
+ this.addEventListener("keypress", event => {
+ switch (event.key) {
+ case "PageUp":
+ this.moveView(-1);
+ break;
+ case "PageDown":
+ this.moveView(1);
+ break;
+ }
+ });
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 150;
+
+ if (event.shiftKey && Services.prefs.getBoolPref("calendar.view.mousescroll", true)) {
+ let deltaView = 0;
+ if (event.deltaMode == event.DOM_DELTA_LINE) {
+ if (event.deltaY != 0) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ this.mPixelScrollDelta += event.deltaY;
+ if (this.mPixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.mPixelScrollDelta = 0;
+ } else if (this.mPixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.mPixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveView(deltaView);
+ }
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("MozRotateGesture", event => {
+ // Threshold for the minimum and maximum angle we should accept
+ // rotation for. 90 degrees minimum is most logical, but 45 degrees
+ // allows you to rotate with one hand.
+ const MIN_ROTATE_ANGLE = 45;
+ const MAX_ROTATE_ANGLE = 180;
+
+ const absval = Math.abs(event.delta);
+ if (this.supportsRotation && absval >= MIN_ROTATE_ANGLE && absval < MAX_ROTATE_ANGLE) {
+ toggleOrientation();
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("MozMagnifyGestureStart", event => {
+ this.mMagnifyAmount = 0;
+ });
+
+ this.addEventListener("MozMagnifyGestureUpdate", event => {
+ // Threshold as to how much magnification causes the zoom to happen.
+ const THRESHOLD = 30;
+
+ if (this.supportsZoom) {
+ this.mMagnifyAmount += event.delta;
+
+ if (this.mMagnifyAmount > THRESHOLD) {
+ this.zoomOut();
+ this.mMagnifyAmount = 0;
+ } else if (this.mMagnifyAmount < -THRESHOLD) {
+ this.zoomIn();
+ this.mMagnifyAmount = 0;
+ }
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("MozSwipeGesture", event => {
+ if (
+ (event.direction == SimpleGestureEvent.DIRECTION_UP && !this.rotated) ||
+ (event.direction == SimpleGestureEvent.DIRECTION_LEFT && this.rotated)
+ ) {
+ this.moveView(-1);
+ } else if (
+ (event.direction == SimpleGestureEvent.DIRECTION_DOWN && !this.rotated) ||
+ (event.direction == SimpleGestureEvent.DIRECTION_RIGHT && this.rotated)
+ ) {
+ this.moveView(1);
+ }
+ });
+
+ this.mRangeStartDate = null;
+ this.mRangeEndDate = null;
+
+ this.mWorkdaysOnly = false;
+
+ this.mController = null;
+
+ this.mStartDate = null;
+ this.mEndDate = null;
+
+ this.mTasksInView = false;
+ this.mShowCompleted = false;
+
+ this.mDisplayDaysOff = true;
+ this.mDaysOffArray = [0, 6];
+
+ this.mTimezone = null;
+ this.mFlashingEvents = {};
+
+ this.mDropShadowsLength = null;
+
+ this.mShadowOffset = null;
+ this.mDropShadows = null;
+
+ this.mMagnifyAmount = 0;
+ this.mPixelScrollDelta = 0;
+
+ this.mViewStart = null;
+ this.mViewEnd = null;
+
+ this.mToggleStatus = 0;
+
+ this.mToggleStatusFlag = {
+ WorkdaysOnly: 1,
+ TasksInView: 2,
+ ShowCompleted: 4,
+ };
+
+ this.mTimezoneObserver = {
+ observe: () => {
+ this.timezone = cal.dtz.defaultTimezone;
+ this.refreshView();
+
+ this.updateTimeIndicatorPosition();
+ },
+ };
+
+ this.mPrefObserver = {
+ calView: this.calICalendarView,
+
+ observe(subj, topic, pref) {
+ this.calView.handlePreference(subj, topic, pref);
+ },
+ };
+
+ this.mObserver = new CalendarViewObserver(this);
+
+ const isChecked = id => document.getElementById(id).getAttribute("checked") == "true";
+
+ this.workdaysOnly = isChecked("calendar_toggle_workdays_only_command");
+ this.tasksInView = isChecked("calendar_toggle_tasks_in_view_command");
+ this.rotated = isChecked("calendar_toggle_orientation_command");
+ this.showCompleted = isChecked("calendar_toggle_show_completed_in_view_command");
+
+ this.mTimezone = cal.dtz.defaultTimezone;
+ const alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(
+ Ci.calIAlarmService
+ );
+
+ alarmService.addObserver(this.mObserver);
+
+ this.setAttribute("type", this.type);
+
+ window.addEventListener("viewresize", event => {
+ if (gCurrentMode == "calendar" && this.isVisible()) {
+ this.onResize();
+ }
+ });
+
+ // Add a preference observer to monitor changes.
+ Services.prefs.addObserver("calendar.", this.mPrefObserver);
+ Services.obs.addObserver(this.mTimezoneObserver, "defaultTimezoneChanged");
+
+ this.updateDaysOffPrefs();
+ this.updateTimeIndicatorPosition();
+
+ // Remove observers on window unload.
+ window.addEventListener(
+ "unload",
+ () => {
+ alarmService.removeObserver(this.mObserver);
+
+ Services.prefs.removeObserver("calendar.", this.mPrefObserver);
+ Services.obs.removeObserver(this.mTimezoneObserver, "defaultTimezoneChanged");
+ },
+ { once: true }
+ );
+ }
+
+ /**
+ * Handle resizing by adjusting the view to the new size.
+ *
+ * @param {calICalendarView} [calViewElem] - A calendar view element.
+ */
+ onResize() {
+ // Child classes should provide the implementation.
+ throw new Error(this.constructor.name + ".onResize not implemented");
+ }
+
+ /**
+ * Whether the view has been initialized.
+ *
+ * @returns {boolean} - True if the view has been initialized, otherwise
+ * false.
+ */
+ get isInitialized() {
+ return this.#isInitialized;
+ }
+
+ get type() {
+ const typelist = this.id.split("-");
+ return typelist[0];
+ }
+
+ set rotated(rotated) {
+ this.setAttribute("orient", rotated ? "horizontal" : "vertical");
+ this.toggleAttribute("rotated", rotated);
+ }
+
+ get rotated() {
+ return this.getAttribute("orient") == "horizontal";
+ }
+
+ get supportsRotation() {
+ return false;
+ }
+
+ set displayDaysOff(displayDaysOff) {
+ this.mDisplayDaysOff = displayDaysOff;
+ }
+
+ get displayDaysOff() {
+ return this.mDisplayDaysOff;
+ }
+
+ set controller(controller) {
+ this.mController = controller;
+ }
+
+ get controller() {
+ return this.mController;
+ }
+
+ set daysOffArray(daysOffArray) {
+ this.mDaysOffArray = daysOffArray;
+ }
+
+ get daysOffArray() {
+ return this.mDaysOffArray;
+ }
+
+ set tasksInView(tasksInView) {
+ this.mTasksInView = tasksInView;
+ this.updateItemType();
+ }
+
+ get tasksInView() {
+ return this.mTasksInView;
+ }
+
+ set showCompleted(showCompleted) {
+ this.mShowCompleted = showCompleted;
+ this.updateItemType();
+ }
+
+ get showCompleted() {
+ return this.mShowCompleted;
+ }
+
+ set timezone(timezone) {
+ this.mTimezone = timezone;
+ }
+
+ get timezone() {
+ return this.mTimezone;
+ }
+
+ set workdaysOnly(workdaysOnly) {
+ this.mWorkdaysOnly = workdaysOnly;
+ }
+
+ get workdaysOnly() {
+ return this.mWorkdaysOnly;
+ }
+
+ get supportsWorkdaysOnly() {
+ return true;
+ }
+
+ get supportsZoom() {
+ return false;
+ }
+
+ get selectionObserver() {
+ return this.mSelectionObserver;
+ }
+
+ get startDay() {
+ return this.startDate;
+ }
+
+ get endDay() {
+ return this.endDate;
+ }
+
+ get supportDisjointDates() {
+ return false;
+ }
+
+ get hasDisjointDates() {
+ return false;
+ }
+
+ set rangeStartDate(startDate) {
+ this.mRangeStartDate = startDate;
+ }
+
+ get rangeStartDate() {
+ return this.mRangeStartDate;
+ }
+
+ set rangeEndDate(endDate) {
+ this.mRangeEndDate = endDate;
+ }
+
+ get rangeEndDate() {
+ return this.mRangeEndDate;
+ }
+
+ get observerID() {
+ return "base-view-observer";
+ }
+
+ // The end date that should be used for getItems and similar queries.
+ get queryEndDate() {
+ if (!this.endDate) {
+ return null;
+ }
+ const end = this.endDate.clone();
+ end.day += 1;
+ end.isDate = true;
+ return end;
+ }
+
+ /**
+ * Return a date object representing the current day.
+ *
+ * @returns {calIDateTime} A date object.
+ */
+ today() {
+ const date = cal.dtz.jsDateToDateTime(new Date()).getInTimezone(this.mTimezone);
+ date.isDate = true;
+ return date;
+ }
+
+ /**
+ * Return whether this view is currently active and visible in the UI.
+ *
+ * @returns {boolean}
+ */
+ isVisible() {
+ return this == currentView();
+ }
+
+ /**
+ * Set the view's item type based on the `tasksInView` and `showCompleted` properties.
+ */
+ updateItemType() {
+ if (!this.mTasksInView) {
+ this.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ return;
+ }
+
+ let type = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ type |= this.mShowCompleted
+ ? Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL
+ : Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ this.itemType = type;
+ }
+
+ // CalendarFilteredViewMixin implementation (clearItems and removeItemsFromCalendar
+ // are implemented in subclasses).
+
+ addItems(items) {
+ for (let item of items) {
+ this.doAddItem(item);
+ }
+ }
+
+ removeItems(items) {
+ for (let item of items) {
+ this.doRemoveItem(item);
+ }
+ }
+
+ // End of CalendarFilteredViewMixin implementation.
+
+ /**
+ * Create and fire an event.
+ *
+ * @param {string} eventName - Name of the event.
+ * @param {object} eventDetail - The details to add to the event.
+ */
+ fireEvent(eventName, eventDetail) {
+ this.dispatchEvent(
+ new CustomEvent(eventName, { bubbles: true, cancelable: false, detail: eventDetail })
+ );
+ }
+
+ /**
+ * A preference handler typically called by a preferences observer when a preference
+ * changes. Handles common preferences while other preferences are handled in subclasses.
+ *
+ * @param {object} subject - A subject, a prefs object.
+ * @param {string} topic - A topic.
+ * @param {string} preference - A preference that has changed.
+ */
+ handleCommonPreference(subject, topic, preference) {
+ switch (preference) {
+ case "calendar.week.d0sundaysoff":
+ case "calendar.week.d1mondaysoff":
+ case "calendar.week.d2tuesdaysoff":
+ case "calendar.week.d3wednesdaysoff":
+ case "calendar.week.d4thursdaysoff":
+ case "calendar.week.d5fridaysoff":
+ case "calendar.week.d6saturdaysoff":
+ this.updateDaysOffPrefs();
+ break;
+ case "calendar.alarms.indicator.show":
+ case "calendar.date.format":
+ case "calendar.view.showLocation":
+ // Break here to ensure the view is refreshed.
+ break;
+ default:
+ return;
+ }
+ this.refreshView();
+ }
+
+ /**
+ * Check preferences and update which days are days off.
+ */
+ updateDaysOffPrefs() {
+ const prefix = "calendar.week.";
+ const daysOffPrefs = [
+ [0, "d0sundaysoff", "true"],
+ [1, "d1mondaysoff", "false"],
+ [2, "d2tuesdaysoff", "false"],
+ [3, "d3wednesdaysoff", "false"],
+ [4, "d4thursdaysoff", "false"],
+ [5, "d5fridaysoff", "false"],
+ [6, "d6saturdaysoff", "true"],
+ ];
+ const filterDaysOff = ([number, name, defaultValue]) =>
+ Services.prefs.getBoolPref(prefix + name, defaultValue);
+
+ this.daysOffArray = daysOffPrefs.filter(filterDaysOff).map(pref => pref[0]);
+ }
+
+ /**
+ * Adjust the position of this view's indicator of the current time, if any.
+ */
+ updateTimeIndicatorPosition() {}
+
+ /**
+ * Refresh the view.
+ */
+ refreshView() {
+ if (!this.startDay || !this.endDay) {
+ // Don't refresh if we're not initialized.
+ return;
+ }
+ this.goToDay(this.selectedDay);
+ }
+
+ handlePreference(subject, topic, pref) {
+ // Do nothing by default.
+ }
+
+ flashAlarm(alarmItem, stop) {
+ // Do nothing by default.
+ }
+
+ // calICalendarView Methods
+
+ /**
+ * @note This is overridden in each of the built-in calendar views.
+ * It's only left here in case some extension is relying on it.
+ */
+ goToDay(date) {
+ this.showDate(date);
+ }
+
+ getRangeDescription() {
+ return cal.dtz.formatter.formatInterval(this.rangeStartDate, this.rangeEndDate);
+ }
+
+ removeDropShadows() {
+ this.querySelectorAll("[dropbox='true']").forEach(dbox => {
+ dbox.setAttribute("dropbox", "false");
+ });
+ }
+
+ setDateRange(startDate, endDate) {
+ calendarNavigationBar.setDateRange(startDate, endDate);
+ }
+
+ getSelectedItems() {
+ return this.mSelectedItems;
+ }
+
+ setSelectedItems(items) {
+ this.mSelectedItems = items.concat([]);
+ return this.mSelectedItems;
+ }
+
+ getDateList() {
+ const start = this.startDate.clone();
+ const dateList = [];
+ while (start.compare(this.endDate) <= 0) {
+ dateList.push(start);
+ start.day++;
+ }
+ return dateList;
+ }
+
+ zoomIn(level) {}
+
+ zoomOut(level) {}
+
+ zoomReset() {}
+
+ // End calICalendarView Methods
+ }
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ CalendarBaseView.prototype,
+ "weekStartOffset",
+ "calendar.week.start",
+ 0
+ );
+
+ MozXULElement.implementCustomInterface(CalendarBaseView, [Ci.calICalendarView]);
+
+ MozElements.CalendarBaseView = CalendarBaseView;
+}
diff --git a/comm/calendar/base/content/calendar-chrome-startup.js b/comm/calendar/base/content/calendar-chrome-startup.js
new file mode 100644
index 0000000000..8a902923f9
--- /dev/null
+++ b/comm/calendar/base/content/calendar-chrome-startup.js
@@ -0,0 +1,438 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported calendarOnToolbarsPopupShowing, customizeMailToolbarForTabType,
+ * initViewCalendarPaneMenu, loadCalendarComponent,
+ */
+
+/* globals loadCalendarManager, injectCalendarCommandController, getViewBox,
+ observeViewDaySelect, getViewBox, calendarController, calendarUpdateNewItemsCommand,
+ TodayPane, setUpInvitationsManager, changeMode,
+ prepareCalendarUnifinder, taskViewOnLoad, taskEdit, tearDownInvitationsManager,
+ unloadCalendarManager, removeCalendarCommandController, finishCalendarUnifinder,
+ PanelUI, changeMenuForTask, setupDeleteMenuitem, getMinimonth, currentView,
+ refreshEventTree, gCurrentMode, InitMessageMenu, onViewToolbarsPopupShowing,
+ onCommandCustomize, CustomizeMailToolbar */
+
+var { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { calendarDeactivator } = ChromeUtils.import(
+ "resource:///modules/calendar/calCalendarDeactivator.jsm"
+);
+
+ChromeUtils.defineModuleGetter(this, "CalMetronome", "resource:///modules/CalMetronome.jsm");
+
+/**
+ * Does calendar initialization steps for a given chrome window. Called at
+ * startup as the application window is loaded, before tabs are restored.
+ */
+async function loadCalendarComponent() {
+ if (loadCalendarComponent.hasBeenCalled) {
+ cal.ERROR("loadCalendarComponent was called more than once for a single window");
+ return;
+ }
+ loadCalendarComponent.hasBeenCalled = true;
+
+ if (cal.manager.wrappedJSObject.mCache) {
+ cal.ASSERT(
+ [...Services.wm.getEnumerator("mail:3pane")].length > 1,
+ "Calendar manager initialised calendars before loadCalendarComponent ran on the first " +
+ "3pane window. This should not happen."
+ );
+ }
+
+ await uninstallLightningAddon();
+
+ // load locale specific default values for preferences
+ setLocaleDefaultPreferences();
+
+ // Move around toolbarbuttons and whatever is needed in the UI.
+ migrateCalendarUI();
+
+ // Load the Calendar Manager
+ await loadCalendarManager();
+
+ CalMetronome.on("day", doMidnightUpdate);
+ CalMetronome.on("minute", updateTimeIndicatorPosition);
+
+ // Set up the command controller from calendar-command-controller.js
+ injectCalendarCommandController();
+
+ // Set up calendar deactivation for this window.
+ calendarDeactivator.registerWindow(window);
+
+ // Set up item and day selection listeners
+ getViewBox().addEventListener("dayselect", observeViewDaySelect);
+ getViewBox().addEventListener("itemselect", calendarController.onSelectionChanged, true);
+
+ // Start alarm service
+ Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService).startup();
+ document.getElementById("calsidebar_splitter").addEventListener("command", () => {
+ window.dispatchEvent(new CustomEvent("viewresize"));
+ });
+ document.getElementById("calendar-view-splitter").addEventListener("command", () => {
+ window.dispatchEvent(new CustomEvent("viewresize"));
+ });
+ window.addEventListener("resize", event => {
+ if (event.target == window) {
+ window.dispatchEvent(new CustomEvent("viewresize"));
+ }
+ });
+
+ // Set calendar color CSS on this window
+ cal.view.colorTracker.registerWindow(window);
+
+ /* Ensure the new items commands state can be setup properly even when no
+ * calendar support refreshes (i.e. the "onLoad" notification) or when none
+ * are active. In specific cases such as for file-based ICS calendars can
+ * happen, the initial "onLoad" will already have been triggered at this
+ * point (see bug 714431 comment 29). We thus unconditionally invoke
+ * calendarUpdateNewItemsCommand until somebody writes code that enables the
+ * checking of the calendar readiness (getProperty("ready") ?).
+ */
+ calendarUpdateNewItemsCommand();
+
+ // Prepare the Today Pane, and if it is ready, display it.
+ await TodayPane.onLoad();
+
+ // Add an unload function to the window so we don't leak any listeners.
+ window.addEventListener("unload", unloadCalendarComponent);
+
+ setUpInvitationsManager();
+
+ let filter = document.getElementById("task-tree-filtergroup");
+ filter.value = filter.value || "all";
+
+ // Set up mode-switching menu items and mode[v]box elements for the initial mode.
+ // At this point no tabs have been restored, so the only reason we wouldn't be
+ // in "mail" mode is if a content tab has opened to display the account set-up.
+ let tabmail = document.getElementById("tabmail");
+ if (tabmail.currentTabInfo.mode.name == "contentTab") {
+ changeMode("special");
+ } else {
+ changeMode("mail");
+ }
+
+ updateTodayPaneButton();
+
+ prepareCalendarUnifinder();
+
+ taskViewOnLoad();
+ taskEdit.onLoad();
+
+ document.getElementById("calSidebar").style.width = `${document
+ .getElementById("calSidebar")
+ .getAttribute("width")}px`;
+
+ Services.obs.notifyObservers(window, "calendar-startup-done");
+}
+
+/**
+ * Does unload steps for a given calendar chrome window.
+ */
+function unloadCalendarComponent() {
+ tearDownInvitationsManager();
+
+ // Unload the calendar manager
+ unloadCalendarManager();
+
+ // Remove the command controller
+ removeCalendarCommandController();
+
+ finishCalendarUnifinder();
+
+ taskEdit.onUnload();
+
+ CalMetronome.off("minute", updateTimeIndicatorPosition);
+ CalMetronome.off("day", doMidnightUpdate);
+}
+
+/**
+ * Uninstall the Lightning calendar addon, now that calendar is in Thunderbird.
+ */
+async function uninstallLightningAddon() {
+ try {
+ let addon = await AddonManager.getAddonByID("{e2fda1a4-762b-4020-b5ad-a41df1933103}");
+ if (addon) {
+ await addon.uninstall();
+ }
+ } catch (err) {
+ console.error("Error while attempting to uninstall Lightning addon:", err);
+ }
+}
+/**
+ * Migrate calendar UI. This function is called at each startup and can be used
+ * to change UI items that require js code intervention
+ */
+function migrateCalendarUI() {
+ const UI_VERSION = 3;
+ let currentUIVersion = Services.prefs.getIntPref("calendar.ui.version", 0);
+ if (currentUIVersion >= UI_VERSION) {
+ return;
+ }
+
+ try {
+ if (currentUIVersion < 2) {
+ // If the user has customized the event/task window dialog toolbar,
+ // we copy that custom set of toolbar items to the event/task tab
+ // toolbar and add the app menu button and a spring for alignment.
+ let xulStore = Services.xulStore;
+ let uri = "chrome://calendar/content/calendar-event-dialog.xhtml";
+
+ if (xulStore.hasValue(uri, "event-toolbar", "currentset")) {
+ let windowSet = xulStore.getValue(uri, "event-toolbar", "currentset");
+ let items = "";
+ if (!windowSet.includes("spring")) {
+ items = "spring";
+ }
+ let previousSet = windowSet == "__empty" ? "" : windowSet + ",";
+ let tabSet = previousSet + items;
+ let tabBar = document.getElementById("event-tab-toolbar");
+
+ tabBar.currentSet = tabSet;
+ // For some reason we also have to do the following,
+ // presumably because the toolbar has already been
+ // loaded into the DOM so the toolbar's currentset
+ // attribute does not yet match the new currentSet.
+ tabBar.setAttribute("currentset", tabSet);
+ }
+ }
+ if (currentUIVersion < 3) {
+ // Rename toolbar button id "button-save" to
+ // "button-saveandclose" in customized toolbars
+ let xulStore = Services.xulStore;
+ let windowUri = "chrome://calendar/content/calendar-event-dialog.xhtml";
+ let tabUri = "chrome://messenger/content/messenger.xhtml";
+
+ if (xulStore.hasValue(windowUri, "event-toolbar", "currentset")) {
+ let windowSet = xulStore.getValue(windowUri, "event-toolbar", "currentset");
+ let newSet = windowSet.replace("button-save", "button-saveandclose");
+ xulStore.setValue(windowUri, "event-toolbar", "currentset", newSet);
+ }
+ if (xulStore.hasValue(tabUri, "event-tab-toolbar", "currentset")) {
+ let tabSet = xulStore.getValue(tabUri, "event-tab-toolbar", "currentset");
+ let newSet = tabSet.replace("button-save", "button-saveandclose");
+ xulStore.setValue(tabUri, "event-tab-toolbar", "currentset", newSet);
+
+ let tabBar = document.getElementById("event-tab-toolbar");
+ tabBar.currentSet = newSet;
+ tabBar.setAttribute("currentset", newSet);
+ }
+ }
+ Services.prefs.setIntPref("calendar.ui.version", UI_VERSION);
+ } catch (e) {
+ cal.ERROR("Error upgrading UI from " + currentUIVersion + " to " + UI_VERSION + ": " + e);
+ }
+}
+
+function setLocaleDefaultPreferences() {
+ function setDefaultLocaleValue(aName) {
+ // Shift encoded days from 1=Monday ... 7=Sunday to 0=Sunday ... 6=Saturday
+ let startDefault = calendarInfo.firstDayOfWeek % 7;
+
+ if (aName == "calendar.categories.names" && defaultBranch.getStringPref(aName) == "") {
+ cal.category.setupDefaultCategories();
+ } else if (aName == "calendar.week.start" && defaultBranch.getIntPref(aName) != startDefault) {
+ defaultBranch.setIntPref(aName, startDefault);
+ } else if (aName.startsWith("calendar.week.d")) {
+ let dayNumber = parseInt(aName[15], 10);
+ if (dayNumber == 0) {
+ dayNumber = 7;
+ }
+ defaultBranch.setBoolPref(aName, calendarInfo.weekend.includes(dayNumber));
+ }
+ }
+
+ cal.LOG("Start loading of locale dependent preference default values...");
+
+ let defaultBranch = Services.prefs.getDefaultBranch("");
+ let calendarInfo = cal.l10n.calendarInfo();
+
+ let prefDefaults = [
+ "calendar.week.start",
+ "calendar.week.d0sundaysoff",
+ "calendar.week.d1mondaysoff",
+ "calendar.week.d2tuesdaysoff",
+ "calendar.week.d3wednesdaysoff",
+ "calendar.week.d4thursdaysoff",
+ "calendar.week.d5fridaysoff",
+ "calendar.week.d6saturdaysoff",
+ "calendar.categories.names",
+ ];
+ for (let prefDefault of prefDefaults) {
+ setDefaultLocaleValue(prefDefault);
+ }
+
+ cal.LOG("Loading of locale sensitive preference default values completed.");
+}
+
+/**
+ * Called at midnight to tell us to redraw date-specific widgets.
+ */
+function doMidnightUpdate() {
+ try {
+ getMinimonth().refreshDisplay();
+
+ // Refresh the current view and just allow the refresh for the others
+ // views when will be displayed.
+ let currView = currentView();
+ currView.goToDay();
+ let views = ["day-view", "week-view", "multiweek-view", "month-view"];
+ for (let view of views) {
+ if (view != currView.id) {
+ document.getElementById(view).mToggleStatus = -1;
+ }
+ }
+
+ if (!TodayPane.showsToday()) {
+ TodayPane.setDay(cal.dtz.now());
+ }
+
+ // Update the unifinder.
+ refreshEventTree();
+
+ // Update today's date on todaypane button.
+ updateTodayPaneButtonDate();
+ } catch (exc) {
+ cal.ASSERT(false, exc);
+ }
+}
+
+/**
+ * Update the position of the current view's indicator of the current time, if
+ * any.
+ */
+function updateTimeIndicatorPosition() {
+ const view = currentView();
+ if (!view?.isInitialized) {
+ // Ensure that we don't attempt to update a view that isn't ready. Calendar
+ // chrome is always loaded at startup, but the view isn't initialized until
+ // the user switches to the calendar tab.
+ return;
+ }
+
+ view.updateTimeIndicatorPosition();
+}
+
+/**
+ * Updates button structure to enable images on both sides of the label.
+ */
+function updateTodayPaneButton() {
+ let todaypane = document.getElementById("calendar-status-todaypane-button");
+
+ let iconStack = document.createXULElement("stack");
+ iconStack.setAttribute("pack", "center");
+ iconStack.setAttribute("align", "end");
+
+ let iconBegin = document.createElement("img");
+ iconBegin.setAttribute("alt", "");
+ iconBegin.setAttribute("src", "chrome://messenger/skin/icons/new/calendar-empty.svg");
+ iconBegin.classList.add("toolbarbutton-icon-begin");
+
+ let iconLabel = document.createXULElement("label");
+ iconLabel.classList.add("toolbarbutton-day-text");
+
+ let dayNumber = cal.l10n.getDateFmtString(`day.${cal.dtz.now().day}.number`);
+ iconLabel.textContent = dayNumber;
+
+ iconStack.appendChild(iconBegin);
+ iconStack.appendChild(iconLabel);
+
+ let iconEnd = document.createElement("img");
+ iconEnd.setAttribute("alt", "");
+ iconEnd.setAttribute("src", "chrome://messenger/skin/icons/new/nav-up-sm.svg");
+ iconEnd.classList.add("toolbarbutton-icon-end");
+
+ let oldImage = todaypane.querySelector(".toolbarbutton-icon");
+ todaypane.replaceChild(iconStack, oldImage);
+ todaypane.appendChild(iconEnd);
+
+ let calSidebar = document.getElementById("calSidebar");
+ todaypane.setAttribute("checked", !calSidebar.collapsed);
+}
+
+/**
+ * Updates the date number in the calendar icon of the todaypane button.
+ */
+function updateTodayPaneButtonDate() {
+ let todaypane = document.getElementById("calendar-status-todaypane-button");
+
+ let dayNumber = cal.l10n.getDateFmtString(`day.${cal.dtz.now().day}.number`);
+ todaypane.querySelector(".toolbarbutton-day-text").textContent = dayNumber;
+}
+
+/**
+ * Get the toolbox id for the current tab type.
+ *
+ * @returns {string} A toolbox id.
+ */
+function getToolboxIdForCurrentTabType() {
+ // A mapping from calendar tab types to toolbox ids.
+ const calendarToolboxIds = {
+ calendar: null,
+ tasks: null,
+ calendarEvent: "event-toolbox",
+ calendarTask: "event-toolbox",
+ };
+ let tabmail = document.getElementById("tabmail");
+ if (!tabmail) {
+ return "mail-toolbox"; // Standalone message window.
+ }
+ let tabType = tabmail.currentTabInfo.mode.type;
+
+ return calendarToolboxIds[tabType] || null;
+}
+
+/**
+ * Modify the contents of the "Toolbars" context menu for the current
+ * tab type. Menu items are inserted before (appear above) aInsertPoint.
+ *
+ * @param {MouseEvent} aEvent - The popupshowing event
+ * @param {nsIDOMXULElement} aInsertPoint - (optional) menuitem node
+ */
+function calendarOnToolbarsPopupShowing(aEvent, aInsertPoint) {
+ if (onViewToolbarsPopupShowing.length < 3) {
+ // SeaMonkey
+ onViewToolbarsPopupShowing(aEvent);
+ return;
+ }
+
+ let toolboxes = ["navigation-toolbox"];
+ let toolboxId = getToolboxIdForCurrentTabType();
+
+ if (toolboxId) {
+ toolboxes.push(toolboxId);
+ }
+
+ onViewToolbarsPopupShowing(aEvent, toolboxes, aInsertPoint);
+}
+
+/**
+ * Open the customize dialog for the toolbar for the current tab type.
+ */
+function customizeMailToolbarForTabType() {
+ let toolboxId = getToolboxIdForCurrentTabType();
+ if (!toolboxId) {
+ return;
+ }
+ if (toolboxId == "event-toolbox") {
+ onCommandCustomize();
+ } else {
+ CustomizeMailToolbar(toolboxId, "CustomizeMailToolbar");
+ }
+}
+
+/**
+ * Initialize the calendar sidebar menu state.
+ */
+function initViewCalendarPaneMenu() {
+ let calSidebar = document.getElementById("calSidebar");
+
+ document.getElementById("calViewCalendarPane").setAttribute("checked", !calSidebar.collapsed);
+
+ if (document.getElementById("appmenu_calViewCalendarPane")) {
+ document.getElementById("appmenu_calViewCalendarPane").checked = !calSidebar.collapsed;
+ }
+}
diff --git a/comm/calendar/base/content/calendar-clipboard.js b/comm/calendar/base/content/calendar-clipboard.js
new file mode 100644
index 0000000000..d3a755d167
--- /dev/null
+++ b/comm/calendar/base/content/calendar-clipboard.js
@@ -0,0 +1,306 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals getSelectedCalendar, getSelectedItems, promptOccurrenceModification,
+ calendarViewController, currentView, startBatchTransaction, doTransaction,
+ endBatchTransaction */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/* exported cutToClipboard, pasteFromClipboard */
+
+/**
+ * Test if a writable calendar is selected, and if the clipboard has items that
+ * can be pasted into Calendar. The data must be of type "text/calendar" or
+ * "text/plain".
+ *
+ * @returns If true, pasting is currently possible.
+ */
+function canPaste() {
+ if (Services.prefs.getBoolPref("calendar.paste.intoSelectedCalendar", false)) {
+ let selectedCal = getSelectedCalendar();
+ if (
+ !selectedCal ||
+ !cal.acl.isCalendarWritable(selectedCal) ||
+ !cal.acl.userCanAddItemsToCalendar(selectedCal)
+ ) {
+ return false;
+ }
+ } else {
+ let calendars = cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar);
+ if (!calendars.length) {
+ return false;
+ }
+ }
+
+ const flavors = ["text/calendar", "text/plain"];
+ return Services.clipboard.hasDataMatchingFlavors(flavors, Ci.nsIClipboard.kGlobalClipboard);
+}
+
+/**
+ * Copy the ics data of the current view's selected events to the clipboard and
+ * deletes the events on success
+ *
+ * @param aCalendarItemArray (optional) an array of items to cut. If not
+ * passed, the current view's selected items will
+ * be used.
+ */
+function cutToClipboard(aCalendarItemArray = null) {
+ copyToClipboard(aCalendarItemArray, true);
+}
+
+/**
+ * Copy the ics data of the items in calendarItemArray to the clipboard. Fills
+ * both text/unicode and text/calendar mime types.
+ *
+ * @param aCalendarItemArray (optional) an array of items to copy. If not
+ * passed, the current view's selected items will
+ * be used.
+ * @param aCutMode (optional) set to true, if this is a cut operation
+ */
+function copyToClipboard(aCalendarItemArray = null, aCutMode = false) {
+ let calendarItemArray = aCalendarItemArray || getSelectedItems();
+ if (!calendarItemArray.length) {
+ cal.LOG("[calendar-clipboard] No items selected.");
+ return;
+ }
+ if (aCutMode) {
+ let items = calendarItemArray.filter(
+ aItem =>
+ cal.acl.userCanModifyItem(aItem) ||
+ (aItem.calendar && cal.acl.userCanDeleteItemsFromCalendar(aItem.calendar))
+ );
+ if (items.length < calendarItemArray.length) {
+ cal.LOG("[calendar-clipboard] No privilege to delete some or all selected items.");
+ return;
+ }
+ calendarItemArray = items;
+ }
+ let [targetItems, , response] = promptOccurrenceModification(
+ calendarItemArray,
+ true,
+ aCutMode ? "cut" : "copy"
+ );
+ if (!response) {
+ // The user canceled the dialog, bail out
+ return;
+ }
+
+ let icsSerializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ icsSerializer.addItems(targetItems);
+ let icsString = icsSerializer.serializeToString();
+
+ let clipboard = Services.clipboard;
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+
+ if (trans && clipboard) {
+ // Register supported data flavors
+ trans.init(null);
+ trans.addDataFlavor("text/calendar");
+ trans.addDataFlavor("text/plain");
+
+ // Create the data objects
+ let icsWrapper = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ icsWrapper.data = icsString;
+
+ // Add data objects to transferable
+ // Both Outlook 2000 client and Lotus Organizer use text/unicode
+ // when pasting iCalendar data.
+ trans.setTransferData("text/calendar", icsWrapper);
+ trans.setTransferData("text/plain", icsWrapper);
+
+ clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+ if (aCutMode) {
+ // check for MODIFICATION_PARENT
+ let useParent = response == 3;
+ calendarViewController.deleteOccurrences(targetItems, useParent, true);
+ }
+ }
+}
+
+/**
+ * Reads ics data from the clipboard, parses it into items and inserts the items
+ * into the currently selected calendar.
+ */
+function pasteFromClipboard() {
+ if (!canPaste()) {
+ return;
+ }
+
+ let clipboard = Services.clipboard;
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+
+ if (!trans || !clipboard) {
+ return;
+ }
+
+ // Register the wanted data flavors (highest fidelity first!)
+ trans.init(null);
+ trans.addDataFlavor("text/calendar");
+ trans.addDataFlavor("text/plain");
+
+ // Get transferable from clipboard
+ clipboard.getData(trans, Ci.nsIClipboard.kGlobalClipboard);
+
+ // Ask transferable for the best flavor.
+ let flavor = {};
+ let data = {};
+ trans.getAnyTransferData(flavor, data);
+ data = data.value.QueryInterface(Ci.nsISupportsString).data;
+ switch (flavor.value) {
+ case "text/calendar":
+ case "text/plain": {
+ let icsParser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ try {
+ icsParser.parseString(data);
+ } catch (e) {
+ // Ignore parser errors from the clipboard data, if it fails
+ // there will just be 0 items.
+ }
+
+ let items = icsParser.getItems();
+ if (items.length == 0) {
+ return;
+ }
+
+ // If there are multiple items on the clipboard, the earliest
+ // should be set to the selected day and the rest adjusted.
+ let earliestDate = null;
+ for (let item of items) {
+ let date = null;
+ if (item.startDate) {
+ date = item.startDate.clone();
+ } else if (item.entryDate) {
+ date = item.entryDate.clone();
+ } else if (item.dueDate) {
+ date = item.dueDate.clone();
+ }
+
+ if (!date) {
+ continue;
+ }
+
+ if (!earliestDate || date.compare(earliestDate) < 0) {
+ earliestDate = date;
+ }
+ }
+ let firstDate = currentView().selectedDay;
+
+ let offset = null;
+ if (earliestDate) {
+ // Timezones and DT/DST time may differ between the earliest item
+ // and the selected day. Determine the offset between the
+ // earliestDate in local time and the selected day in whole days.
+ earliestDate = earliestDate.getInTimezone(cal.dtz.defaultTimezone);
+ earliestDate.isDate = true;
+ offset = firstDate.subtractDate(earliestDate);
+ let deltaDST = firstDate.timezoneOffset - earliestDate.timezoneOffset;
+ offset.inSeconds += deltaDST;
+ }
+
+ // we only will need to ask whether to send notifications, if there
+ // are attendees at all
+ let withAttendees = items.filter(aItem => aItem.getAttendees().length > 0);
+
+ let notify = Ci.calIItipItem.USER;
+ let destCal = null;
+ if (Services.prefs.getBoolPref("calendar.paste.intoSelectedCalendar", false)) {
+ destCal = getSelectedCalendar();
+ } else {
+ let pasteText = "paste";
+ if (withAttendees.length) {
+ if (withAttendees.every(item => item.isEvent())) {
+ pasteText += "Event";
+ } else if (withAttendees.every(item => item.isTodo())) {
+ pasteText += "Task";
+ } else {
+ pasteText += "Item";
+ }
+ if (withAttendees.length > 1) {
+ pasteText += "s";
+ }
+ }
+ let validPasteText = pasteText != "paste" && !pasteText.endsWith("Item");
+ pasteText += items.length == withAttendees.length ? "Only" : "Also";
+
+ let calendars = cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar)
+ .filter(aCal => {
+ let status = aCal.getProperty("currentStatus");
+ return Components.isSuccessCode(status);
+ });
+ if (calendars.length > 1) {
+ let args = {};
+ args.calendars = calendars;
+ args.promptText = cal.l10n.getCalString("pastePrompt");
+
+ if (validPasteText) {
+ pasteText = cal.l10n.getCalString(pasteText);
+ let note = cal.l10n.getCalString("pasteNotifyAbout", [pasteText]);
+ args.promptNotify = note;
+
+ args.labelExtra1 = cal.l10n.getCalString("pasteDontNotifyLabel");
+ args.onExtra1 = aCal => {
+ destCal = aCal;
+ notify = Ci.calIItipItem.NONE;
+ };
+ args.labelOk = cal.l10n.getCalString("pasteAndNotifyLabel");
+ args.onOk = aCal => {
+ destCal = aCal;
+ notify = Ci.calIItipItem.AUTO;
+ };
+ } else {
+ args.onOk = aCal => {
+ destCal = aCal;
+ };
+ }
+
+ window.openDialog(
+ "chrome://calendar/content/chooseCalendarDialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ } else if (calendars.length == 1) {
+ destCal = calendars[0];
+ }
+ }
+ if (!destCal) {
+ return;
+ }
+
+ startBatchTransaction();
+ for (let item of items) {
+ // TODO: replace the UUID only it it already exists in the
+ // calendar to avoid to break invitation scenarios where remote
+ // parties rely on the UUID.
+ let newItem = item.clone();
+ // Set new UID to allow multiple paste actions of the same
+ // clipboard content.
+ newItem.id = cal.getUUID();
+ if (offset) {
+ cal.item.shiftOffset(newItem, offset);
+ }
+
+ let extResp = { responseMode: Ci.calIItipItem.NONE };
+ if (item.getAttendees().length > 0) {
+ extResp.responseMode = notify;
+ }
+
+ doTransaction("add", newItem, destCal, null, null, extResp);
+ }
+ endBatchTransaction();
+ break;
+ }
+ default:
+ break;
+ }
+}
diff --git a/comm/calendar/base/content/calendar-command-controller.js b/comm/calendar/base/content/calendar-command-controller.js
new file mode 100644
index 0000000000..605b2e9a58
--- /dev/null
+++ b/comm/calendar/base/content/calendar-command-controller.js
@@ -0,0 +1,869 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals goUpdateCommand, currentView, TodayPane, createEventWithDialog,
+ getSelectedCalendar, editSelectedEvents, viewSelectedEvents,
+ modifyTaskFromContext, deleteSelectedEvents, setupAttendanceMenu,
+ createTodoWithDialog, deleteToDoCommand, promptDeleteCalendar,
+ toImport, loadEventsFromFile, exportEntireCalendar, saveEventsToFile,
+ publishEntireCalendar, publishCalendarData, toggleUnifinder, toggleOrientation
+ toggleWorkdaysOnly, switchCalendarView, getTaskTree, selectAllEvents,
+ gCurrentMode, getSelectedTasks, canPaste, goSetMenuValue, canUndo, canRedo,
+ cutToClipboard, copyToClipboard, pasteFromClipboard, undo, redo,
+ PrintUtils */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var CalendarDeleteCommandEnabled = false;
+var CalendarNewEventsCommandEnabled = false;
+var CalendarNewTasksCommandEnabled = false;
+
+/**
+ * Command controller to execute calendar specific commands
+ *
+ * @see nsICommandController
+ */
+var calendarController = {
+ commands: new Set([
+ // Common commands
+ "calendar_new_event_command",
+ "calendar_new_event_context_command",
+ "calendar_new_event_todaypane_command",
+ "calendar_modify_event_command",
+ "calendar_view_event_command",
+ "calendar_delete_event_command",
+
+ "calendar_modify_focused_item_command",
+ "calendar_delete_focused_item_command",
+
+ "calendar_new_todo_command",
+ "calendar_new_todo_context_command",
+ "calendar_new_todo_todaypane_command",
+ "calendar_toggle_tasks_in_view_command",
+ "calendar_modify_todo_command",
+ "calendar_modify_todo_todaypane_command",
+ "calendar_delete_todo_command",
+
+ "calendar_new_calendar_command",
+ "calendar_edit_calendar_command",
+ "calendar_delete_calendar_command",
+
+ "calendar_import_command",
+ "calendar_export_command",
+ "calendar_export_selection_command",
+
+ "calendar_publish_selected_calendar_command",
+ "calendar_publish_calendar_command",
+ "calendar_publish_selected_events_command",
+
+ "calendar_view_next_command",
+ "calendar_view_prev_command",
+
+ "calendar_toggle_orientation_command",
+ "calendar_toggle_workdays_only_command",
+
+ "calendar_day-view_command",
+ "calendar_week-view_command",
+ "calendar_multiweek-view_command",
+ "calendar_month-view_command",
+
+ "calendar_task_filter_command",
+ "calendar_reload_remote_calendars",
+ "calendar_show_unifinder_command",
+ "calendar_toggle_completed_command",
+ "calendar_percentComplete-0_command",
+ "calendar_percentComplete-25_command",
+ "calendar_percentComplete-50_command",
+ "calendar_percentComplete-75_command",
+ "calendar_percentComplete-100_command",
+ "calendar_priority-0_command",
+ "calendar_priority-9_command",
+ "calendar_priority-5_command",
+ "calendar_priority-1_command",
+ "calendar_general-priority_command",
+ "calendar_general-progress_command",
+ "calendar_general-postpone_command",
+ "calendar_postpone-1hour_command",
+ "calendar_postpone-1day_command",
+ "calendar_postpone-1week_command",
+ "calendar_task_category_command",
+
+ "calendar_attendance_command",
+
+ // for events/tasks in a tab
+ "cmd_save",
+ "cmd_accept",
+
+ // Pseudo commands
+ "calendar_in_foreground",
+ "calendar_in_background",
+ "calendar_mode_calendar",
+ "calendar_mode_task",
+
+ "cmd_selectAll",
+ ]),
+
+ updateCommands() {
+ this.commands.forEach(goUpdateCommand);
+ },
+
+ supportsCommand(aCommand) {
+ if (this.commands.has(aCommand)) {
+ return true;
+ }
+ return false;
+ },
+
+ /* eslint-disable complexity */
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "calendar_new_event_command":
+ case "calendar_new_event_context_command":
+ case "calendar_new_event_todaypane_command":
+ return CalendarNewEventsCommandEnabled;
+ case "calendar_modify_focused_item_command":
+ return this.item_selected && canEditSelectedItems();
+ case "calendar_modify_event_command":
+ return this.item_selected && canEditSelectedItems();
+ case "calendar_view_event_command":
+ return this.item_selected;
+ case "calendar_delete_focused_item_command":
+ return CalendarDeleteCommandEnabled && this.selected_items_writable;
+ case "calendar_delete_event_command":
+ return CalendarDeleteCommandEnabled && this.selected_items_writable;
+ case "calendar_new_todo_command":
+ case "calendar_new_todo_context_command":
+ case "calendar_new_todo_todaypane_command":
+ case "calendar_toggle_tasks_in_view_command":
+ return CalendarNewTasksCommandEnabled;
+ case "calendar_modify_todo_command":
+ case "calendar_modify_todo_todaypane_command":
+ return this.todo_items_selected;
+ // This code is temporarily commented out due to
+ // bug 469684 Unifinder-todo: raising of the context menu fires blur-event
+ // this.todo_tasktree_focused;
+ case "calendar_edit_calendar_command":
+ return this.isCalendarInForeground();
+ case "calendar_task_filter_command":
+ return true;
+ case "calendar_delete_todo_command":
+ if (!CalendarDeleteCommandEnabled) {
+ return false;
+ }
+ // falls through otherwise
+ case "calendar_toggle_completed_command":
+ case "calendar_percentComplete-0_command":
+ case "calendar_percentComplete-25_command":
+ case "calendar_percentComplete-50_command":
+ case "calendar_percentComplete-75_command":
+ case "calendar_percentComplete-100_command":
+ case "calendar_priority-0_command":
+ case "calendar_priority-9_command":
+ case "calendar_priority-5_command":
+ case "calendar_priority-1_command":
+ case "calendar_task_category_command":
+ case "calendar_general-progress_command":
+ case "calendar_general-priority_command":
+ case "calendar_general-postpone_command":
+ case "calendar_postpone-1hour_command":
+ case "calendar_postpone-1day_command":
+ case "calendar_postpone-1week_command":
+ return (
+ ((this.isCalendarInForeground() || this.todo_tasktree_focused) &&
+ this.writable &&
+ this.todo_items_selected &&
+ this.todo_items_writable) ||
+ document.getElementById("tabmail").currentTabInfo.mode.type == "calendarTask"
+ );
+ case "calendar_delete_calendar_command":
+ return this.isCalendarInForeground() && !this.last_calendar;
+ case "calendar_import_command":
+ return this.writable;
+ case "calendar_export_selection_command":
+ return this.item_selected;
+ case "calendar_toggle_orientation_command":
+ return this.isInMode("calendar") && currentView().supportsRotation;
+ case "calendar_toggle_workdays_only_command":
+ return this.isInMode("calendar") && currentView().supportsWorkdaysOnly;
+ case "calendar_publish_selected_events_command":
+ return this.item_selected;
+
+ case "calendar_reload_remote_calendars":
+ return this.has_enabled_reloadable_calendars && !this.offline;
+ case "calendar_attendance_command": {
+ let attendSel = false;
+ if (this.todo_tasktree_focused) {
+ attendSel =
+ this.writable &&
+ this.todo_items_invitation &&
+ this.todo_items_selected &&
+ this.todo_items_writable;
+ } else {
+ attendSel =
+ this.item_selected && this.selected_events_invitation && this.selected_items_writable;
+ }
+
+ // Small hack, we want to hide instead of disable.
+ document.getElementById("calendar_attendance_command").setAttribute("hidden", !attendSel);
+ return attendSel;
+ }
+
+ // The following commands all just need the calendar in foreground,
+ // make sure you take care when changing things here.
+ case "calendar_view_next_command":
+ case "calendar_view_prev_command":
+ case "calendar_in_foreground":
+ return this.isCalendarInForeground();
+ case "calendar_in_background":
+ return !this.isCalendarInForeground();
+
+ // The following commands need calendar mode, be careful when
+ // changing things.
+ case "calendar_day-view_command":
+ case "calendar_week-view_command":
+ case "calendar_multiweek-view_command":
+ case "calendar_month-view_command":
+ case "calendar_show_unifinder_command":
+ case "calendar_mode_calendar":
+ return this.isInMode("calendar");
+
+ case "calendar_mode_task":
+ return this.isInMode("task");
+
+ case "cmd_selectAll":
+ return this.todo_tasktree_focused || this.isInMode("calendar");
+
+ // for events/tasks in a tab
+ case "cmd_save":
+ // falls through
+ case "cmd_accept": {
+ let tabType = document.getElementById("tabmail").currentTabInfo.mode.type;
+ return tabType == "calendarTask" || tabType == "calendarEvent";
+ }
+
+ default:
+ if (this.commands.has(aCommand)) {
+ // All other commands we support should be enabled by default
+ return true;
+ }
+ }
+ return false;
+ },
+ /* eslint-enable complexity */
+
+ doCommand(aCommand) {
+ switch (aCommand) {
+ // Common Commands
+ case "calendar_new_event_command":
+ createEventWithDialog(
+ getSelectedCalendar(),
+ cal.dtz.getDefaultStartDate(currentView().selectedDay)
+ );
+ break;
+ case "calendar_new_event_context_command": {
+ let newStart = currentView().selectedDateTime;
+ if (!newStart) {
+ newStart = cal.dtz.getDefaultStartDate(currentView().selectedDay);
+ }
+ createEventWithDialog(getSelectedCalendar(), newStart, null, null, null, newStart.isDate);
+ break;
+ }
+ case "calendar_new_event_todaypane_command":
+ createEventWithDialog(getSelectedCalendar(), cal.dtz.getDefaultStartDate(TodayPane.start));
+ break;
+ case "calendar_modify_event_command":
+ editSelectedEvents();
+ break;
+ case "calendar_view_event_command":
+ viewSelectedEvents();
+ break;
+ case "calendar_modify_focused_item_command": {
+ let focusedElement = document.commandDispatcher.focusedElement;
+ if (focusedElement == TodayPane.agenda) {
+ TodayPane.agenda.editSelectedItem();
+ } else if (focusedElement && focusedElement.className == "calendar-task-tree") {
+ modifyTaskFromContext();
+ } else if (this.isInMode("calendar")) {
+ editSelectedEvents();
+ }
+ break;
+ }
+ case "calendar_delete_event_command":
+ deleteSelectedEvents();
+ break;
+ case "calendar_delete_focused_item_command": {
+ let focusedElement = document.commandDispatcher.focusedElement;
+ if (focusedElement == TodayPane.agenda) {
+ TodayPane.agenda.deleteSelectedItem(false);
+ } else if (focusedElement && focusedElement.className == "calendar-task-tree") {
+ deleteToDoCommand(false);
+ } else if (this.isInMode("calendar")) {
+ deleteSelectedEvents();
+ }
+ break;
+ }
+ case "calendar_new_todo_command":
+ createTodoWithDialog(
+ getSelectedCalendar(),
+ null,
+ null,
+ null,
+ cal.dtz.getDefaultStartDate(currentView().selectedDay)
+ );
+ break;
+ case "calendar_new_todo_context_command": {
+ let initialDate = currentView().selectedDateTime;
+ if (!initialDate || initialDate.isDate) {
+ initialDate = cal.dtz.getDefaultStartDate(currentView().selectedDay);
+ }
+ createTodoWithDialog(getSelectedCalendar(), null, null, null, initialDate);
+ break;
+ }
+ case "calendar_new_todo_todaypane_command":
+ createTodoWithDialog(
+ getSelectedCalendar(),
+ null,
+ null,
+ null,
+ cal.dtz.getDefaultStartDate(TodayPane.start)
+ );
+ break;
+ case "calendar_delete_todo_command":
+ deleteToDoCommand();
+ break;
+ case "calendar_modify_todo_command":
+ modifyTaskFromContext(cal.dtz.getDefaultStartDate(currentView().selectedDay));
+ break;
+ case "calendar_modify_todo_todaypane_command":
+ modifyTaskFromContext(cal.dtz.getDefaultStartDate(TodayPane.start));
+ break;
+
+ case "calendar_new_calendar_command":
+ cal.window.openCalendarWizard(window);
+ break;
+ case "calendar_edit_calendar_command":
+ cal.window.openCalendarProperties(window, { calendar: getSelectedCalendar() });
+ break;
+ case "calendar_delete_calendar_command":
+ promptDeleteCalendar(getSelectedCalendar());
+ break;
+
+ case "calendar_import_command":
+ if (Services.prefs.getBoolPref("mail.import.in_new_tab")) {
+ toImport("calendar");
+ } else {
+ loadEventsFromFile();
+ }
+ break;
+ case "calendar_export_command":
+ exportEntireCalendar();
+ break;
+ case "calendar_export_selection_command":
+ saveEventsToFile(currentView().getSelectedItems());
+ break;
+
+ case "calendar_publish_selected_calendar_command":
+ publishEntireCalendar(getSelectedCalendar());
+ break;
+ case "calendar_publish_calendar_command":
+ publishEntireCalendar();
+ break;
+ case "calendar_publish_selected_events_command":
+ publishCalendarData();
+ break;
+
+ case "calendar_reload_remote_calendars":
+ cal.view.getCompositeCalendar(window).refresh();
+ break;
+ case "calendar_show_unifinder_command":
+ toggleUnifinder();
+ break;
+ case "calendar_view_next_command":
+ currentView().moveView(1);
+ break;
+ case "calendar_view_prev_command":
+ currentView().moveView(-1);
+ break;
+ case "calendar_toggle_orientation_command":
+ toggleOrientation();
+ break;
+ case "calendar_toggle_workdays_only_command":
+ toggleWorkdaysOnly();
+ break;
+
+ case "calendar_day-view_command":
+ switchCalendarView("day", true);
+ break;
+ case "calendar_week-view_command":
+ switchCalendarView("week", true);
+ break;
+ case "calendar_multiweek-view_command":
+ switchCalendarView("multiweek", true);
+ break;
+ case "calendar_month-view_command":
+ switchCalendarView("month", true);
+ break;
+ case "calendar_attendance_command":
+ // This command is actually handled inline, since it takes a value
+ break;
+
+ case "cmd_selectAll":
+ if (this.todo_tasktree_focused) {
+ getTaskTree().selectAll();
+ } else if (this.isInMode("calendar")) {
+ selectAllEvents();
+ }
+ break;
+ }
+ },
+
+ onEvent(aEvent) {},
+
+ isCalendarInForeground() {
+ return gCurrentMode && gCurrentMode != "mail";
+ },
+
+ isInMode(mode) {
+ switch (mode) {
+ case "mail":
+ return !this.isCalendarInForeground();
+ case "calendar":
+ return gCurrentMode && gCurrentMode == "calendar";
+ case "task":
+ return gCurrentMode && gCurrentMode == "task";
+ }
+ return false;
+ },
+
+ onSelectionChanged(aEvent) {
+ let selectedItems = aEvent.detail;
+
+ calendarUpdateDeleteCommand(selectedItems);
+ calendarController.item_selected = selectedItems && selectedItems.length > 0;
+
+ let selLength = selectedItems === undefined ? 0 : selectedItems.length;
+ let selected_events_readonly = 0;
+ let selected_events_requires_network = 0;
+ let selected_events_invitation = 0;
+
+ if (selLength > 0) {
+ for (let item of selectedItems) {
+ if (item.calendar.readOnly) {
+ selected_events_readonly++;
+ }
+ if (
+ item.calendar.getProperty("requiresNetwork") &&
+ !item.calendar.getProperty("cache.enabled") &&
+ !item.calendar.getProperty("cache.always")
+ ) {
+ selected_events_requires_network++;
+ }
+
+ if (cal.itip.isInvitation(item)) {
+ selected_events_invitation++;
+ } else if (item.organizer) {
+ // If we are the organizer and there are attendees, then
+ // this is likely also an invitation.
+ let calOrgId = item.calendar.getProperty("organizerId");
+ if (item.organizer.id == calOrgId && item.getAttendees().length) {
+ selected_events_invitation++;
+ }
+ }
+ }
+ }
+
+ calendarController.selected_events_readonly = selected_events_readonly == selLength;
+
+ calendarController.selected_events_requires_network =
+ selected_events_requires_network == selLength;
+ calendarController.selected_events_invitation = selected_events_invitation == selLength;
+
+ calendarController.updateCommands();
+ calendarController2.updateCommands();
+ document.commandDispatcher.updateCommands("mail-toolbar");
+ },
+
+ /**
+ * Condition Helpers
+ */
+
+ // These attributes will be set up manually.
+ item_selected: false,
+ selected_events_readonly: false,
+ selected_events_requires_network: false,
+ selected_events_invitation: false,
+
+ /**
+ * Returns a boolean indicating if its possible to write items to any
+ * calendar.
+ */
+ get writable() {
+ return cal.manager.getCalendars().some(cal.acl.isCalendarWritable);
+ },
+
+ /**
+ * Returns a boolean indicating if the application is currently in offline
+ * mode.
+ */
+ get offline() {
+ return Services.io.offline;
+ },
+
+ /**
+ * Returns a boolean indicating whether there is at least one enabled
+ * calendar that can be reloaded. Note: ICS calendars can have a network URL
+ * or a file URL, but both are reloadable.
+ */
+ get has_enabled_reloadable_calendars() {
+ return cal.manager
+ .getCalendars()
+ .some(
+ calendar =>
+ !calendar.getProperty("disabled") &&
+ (calendar.type == "ics" || calendar.getProperty("requiresNetwork") !== false)
+ );
+ },
+
+ /**
+ * Returns a boolean indicating that there is only one calendar left.
+ */
+ get last_calendar() {
+ return cal.manager.calendarCount < 2;
+ },
+
+ /**
+ * Returns a boolean indicating that at least one of the items selected
+ * in the current view has a writable calendar.
+ */
+ get selected_items_writable() {
+ return (
+ this.writable &&
+ this.item_selected &&
+ !this.selected_events_readonly &&
+ (!this.offline || !this.selected_events_requires_network)
+ );
+ },
+
+ /**
+ * Returns a boolean indicating that tasks are selected.
+ */
+ get todo_items_selected() {
+ let selectedTasks = getSelectedTasks();
+ return selectedTasks.length > 0;
+ },
+
+ get todo_items_invitation() {
+ let selectedTasks = getSelectedTasks();
+ let selected_tasks_invitation = 0;
+
+ for (let item of selectedTasks) {
+ if (cal.itip.isInvitation(item)) {
+ selected_tasks_invitation++;
+ } else if (item.organizer) {
+ // If we are the organizer and there are attendees, then
+ // this is likely also an invitation.
+ let calOrgId = item.calendar.getProperty("organizerId");
+ if (item.organizer.id == calOrgId && item.getAttendees().length) {
+ selected_tasks_invitation++;
+ }
+ }
+ }
+
+ return selectedTasks.length == selected_tasks_invitation;
+ },
+
+ /**
+ * Returns a boolean indicating that at least one task in the selection is
+ * on a calendar that is writable.
+ */
+ get todo_items_writable() {
+ let selectedTasks = getSelectedTasks();
+ for (let task of selectedTasks) {
+ if (cal.acl.isCalendarWritable(task.calendar)) {
+ return true;
+ }
+ }
+ return false;
+ },
+};
+
+/**
+ * XXX This is a temporary hack so we can release 1.0b2. This will soon be
+ * superseded by a new command controller architecture.
+ */
+var calendarController2 = {
+ commands: new Set([
+ "cmd_cut",
+ "cmd_copy",
+ "cmd_paste",
+ "cmd_undo",
+ "cmd_redo",
+ "cmd_print",
+ "button_print",
+ "button_delete",
+ "cmd_delete",
+ "cmd_properties",
+ "cmd_goForward",
+ "cmd_goBack",
+ "cmd_fullZoomReduce",
+ "cmd_fullZoomEnlarge",
+ "cmd_fullZoomReset",
+ "cmd_showQuickFilterBar",
+ ]),
+
+ // These functions can use the same from the calendar controller for now.
+ updateCommands: calendarController.updateCommands,
+ supportsCommand: calendarController.supportsCommand,
+ onEvent: calendarController.onEvent,
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ // Thunderbird Commands
+ case "cmd_cut":
+ return calendarController.selected_items_writable;
+ case "cmd_copy":
+ return calendarController.item_selected;
+ case "cmd_paste":
+ return canPaste();
+ case "cmd_undo":
+ goSetMenuValue(aCommand, "valueDefault");
+ return canUndo();
+ case "cmd_redo":
+ goSetMenuValue(aCommand, "valueDefault");
+ return canRedo();
+ case "button_delete":
+ case "cmd_delete":
+ return calendarController.isCommandEnabled("calendar_delete_focused_item_command");
+ case "cmd_fullZoomReduce":
+ case "cmd_fullZoomEnlarge":
+ case "cmd_fullZoomReset":
+ return calendarController.isInMode("calendar") && currentView().supportsZoom;
+ case "cmd_properties":
+ return false;
+ case "cmd_showQuickFilterBar":
+ return calendarController.isInMode("task");
+ default:
+ return true;
+ }
+ },
+
+ doCommand(aCommand) {
+ if (!this.isCommandEnabled(aCommand)) {
+ // doCommand is triggered for cmd_cut even if the command is disabled
+ // so we bail out here
+ return;
+ }
+ switch (aCommand) {
+ case "cmd_cut":
+ cutToClipboard();
+ break;
+ case "cmd_copy":
+ copyToClipboard();
+ break;
+ case "cmd_paste":
+ pasteFromClipboard();
+ break;
+ case "cmd_undo":
+ undo();
+ break;
+ case "cmd_redo":
+ redo();
+ break;
+ case "button_print":
+ case "cmd_print":
+ printCalendar();
+ break;
+ // Thunderbird commands
+ case "cmd_goForward":
+ currentView().moveView(1);
+ break;
+ case "cmd_goBack":
+ currentView().moveView(-1);
+ break;
+ case "cmd_fullZoomReduce":
+ currentView().zoomIn();
+ break;
+ case "cmd_fullZoomEnlarge":
+ currentView().zoomOut();
+ break;
+ case "cmd_fullZoomReset":
+ currentView().zoomReset();
+ break;
+ case "cmd_showQuickFilterBar":
+ document.getElementById("task-text-filter-field").select();
+ break;
+
+ case "button_delete":
+ case "cmd_delete":
+ calendarController.doCommand("calendar_delete_focused_item_command");
+ break;
+ }
+ },
+};
+
+/**
+ * Inserts the command controller into the document. Make sure that it is
+ * inserted before the conflicting Thunderbird command controller.
+ */
+function injectCalendarCommandController() {
+ // This is the third-highest priority controller. It's preceded by
+ // DefaultController and tabmail.tabController, and followed by
+ // calendarController, then whatever Gecko adds.
+ top.controllers.insertControllerAt(2, calendarController);
+ document.commandDispatcher.updateCommands("calendar_commands");
+}
+
+/**
+ * Remove the calendar command controller from the document.
+ */
+function removeCalendarCommandController() {
+ top.controllers.removeController(calendarController);
+}
+
+/**
+ * Handler function to set up the item context menu, depending on the given
+ * items. Changes the delete menuitem to fit the passed items.
+ *
+ * @param {DOMEvent} aEvent The DOM popupshowing event that is
+ * triggered by opening the context menu
+ * @param {Array.<calIItemBase>} aItems An array of items (usually the selected
+ * items) to adapt the context menu for
+ * @returns {boolean} True, to show the popup menu.
+ */
+function setupContextItemType(aEvent, aItems) {
+ function adaptModificationMenuItem(aMenuItemId, aItemType) {
+ let menuItem = document.getElementById(aMenuItemId);
+ if (menuItem) {
+ menuItem.setAttribute("label", cal.l10n.getCalString(`delete${aItemType}Label`));
+ menuItem.setAttribute("accesskey", cal.l10n.getCalString(`delete${aItemType}Accesskey`));
+ }
+ }
+ if (aItems.some(item => item.isEvent()) && aItems.some(item => item.isTodo())) {
+ aEvent.target.setAttribute("type", "mixed");
+ adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item");
+ } else if (aItems.length && aItems[0].isEvent()) {
+ aEvent.target.setAttribute("type", "event");
+ adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Event");
+ } else if (aItems.length && aItems[0].isTodo()) {
+ aEvent.target.setAttribute("type", "todo");
+ adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Task");
+ } else {
+ aEvent.target.removeAttribute("type");
+ adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item");
+ }
+
+ let menu = document.getElementById("calendar-item-context-menu-attendance-menu");
+ setupAttendanceMenu(menu, aItems);
+ return true;
+}
+
+/**
+ * Tests whether the items currently selected can be edited in the event dialog.
+ * Invitations are not considered editable here.
+ */
+function canEditSelectedItems() {
+ let items = currentView().getSelectedItems();
+ return items.every(item => {
+ let calendar = item.calendar;
+ return (
+ cal.acl.isCalendarWritable(calendar) &&
+ cal.acl.userCanModifyItem(item) &&
+ calendar.supportsScheduling &&
+ !calendar.getSchedulingSupport().isInvitation(item)
+ );
+ });
+}
+
+/**
+ * Returns the selected items, based on which mode we are currently in and what task tree is focused.
+ */
+function getSelectedItems() {
+ if (calendarController.todo_tasktree_focused) {
+ return getSelectedTasks();
+ }
+
+ return currentView().getSelectedItems();
+}
+
+/**
+ * Deletes the selected items, based on which mode we are currently in and what task tree is focused
+ */
+function deleteSelectedItems() {
+ if (calendarController.todo_tasktree_focused) {
+ deleteToDoCommand();
+ } else if (calendarController.isInMode("calendar")) {
+ deleteSelectedEvents();
+ }
+}
+
+/**
+ * Checks if any calendar allows new events and tasks to be added, otherwise
+ * disables the creation buttons.
+ */
+function calendarUpdateNewItemsCommand() {
+ // Re-calculate command status.
+ let calendars = cal.manager
+ .getCalendars()
+ .filter(cal.acl.isCalendarWritable)
+ .filter(cal.acl.userCanAddItemsToCalendar);
+
+ CalendarNewEventsCommandEnabled = calendars.some(cal.item.isEventCalendar);
+ CalendarNewTasksCommandEnabled = calendars.some(cal.item.isTaskCalendar);
+
+ [
+ "calendar_new_event_command",
+ "calendar_new_event_context_command",
+ "calendar_new_event_todaypane_command",
+ "calendar_new_todo_command",
+ "calendar_new_todo_context_command",
+ "calendar_new_todo_todaypane_command",
+ "calendar_toggle_tasks_in_view_command",
+ ].forEach(goUpdateCommand);
+
+ document.getElementById("sidePanelNewEvent").disabled = !CalendarNewEventsCommandEnabled;
+ document.getElementById("sidePanelNewTask").disabled = !CalendarNewTasksCommandEnabled;
+}
+
+function calendarUpdateDeleteCommand(selectedItems) {
+ let oldValue = CalendarDeleteCommandEnabled;
+ CalendarDeleteCommandEnabled = selectedItems.length > 0;
+
+ /* we must disable "delete" when at least one item cannot be deleted */
+ for (let item of selectedItems) {
+ if (!cal.acl.userCanDeleteItemsFromCalendar(item.calendar)) {
+ CalendarDeleteCommandEnabled = false;
+ break;
+ }
+ }
+
+ if (CalendarDeleteCommandEnabled != oldValue) {
+ [
+ "calendar_delete_event_command",
+ "calendar_delete_todo_command",
+ "calendar_delete_focused_item_command",
+ "button_delete",
+ "cmd_delete",
+ ].forEach(goUpdateCommand);
+ }
+}
+
+/**
+ * Loads the printing template into a hidden browser then starts the printing
+ * process for that browser.
+ */
+async function printCalendar() {
+ // Ensure the printing of this file will be detected by calPrintUtils.jsm.
+ cal.print.ensureInitialized();
+
+ await PrintUtils.loadPrintBrowser("chrome://calendar/content/printing-template.html");
+ PrintUtils.startPrintWindow(PrintUtils.printBrowser.browsingContext, {});
+}
+/**
+ * Toggle the visibility of the calendars list.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+function toggleVisibilityCalendarsList(event) {
+ document.getElementById("calendar-list-inner-pane").togglePane(event);
+}
diff --git a/comm/calendar/base/content/calendar-commands.inc.xhtml b/comm/calendar/base/content/calendar-commands.inc.xhtml
new file mode 100644
index 0000000000..78bce756ca
--- /dev/null
+++ b/comm/calendar/base/content/calendar-commands.inc.xhtml
@@ -0,0 +1,101 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<commandset id="calendar_commands"
+ commandupdater="true"
+ events="calendar_commands"
+ oncommandupdate="calendarController.updateCommands()">
+ <command id="agenda_delete_event_command" oncommand="TodayPane.agenda.deleteSelectedItem();"/>
+ <command id="agenda_edit_event_command" oncommand="TodayPane.agenda.editSelectedItem();"/>
+ <command id="switch2calendar"
+ oncommand="document.getElementById('tabmail').openTab('calendar')"/>
+ <command id="switch2task"
+ oncommand="document.getElementById('tabmail').openTab('tasks')"/>
+ <command id="new_calendar_tab"
+ oncommand="document.getElementById('tabmail').openTab('calendar')"/>
+ <command id="new_task_tab"
+ oncommand="document.getElementById('tabmail').openTab('tasks')"/>
+ <command id="calendar_go_to_today_command"
+ observes="calendar_mode_calendar"
+ oncommand="document.getElementById('tabmail').openTab('calendar'); goToDate(cal.dtz.now())"/>
+ <command id="calendar_new_calendar_command" oncommand="goDoCommand('calendar_new_calendar_command')"/>
+ <command id="calendar_delete_calendar_command" oncommand="goDoCommand('calendar_delete_calendar_command')"/>
+ <command id="calendar_edit_calendar_command" oncommand="goDoCommand('calendar_edit_calendar_command')"/>
+
+ <command id="calendar_new_event_command" oncommand="goDoCommand('calendar_new_event_command')"/>
+ <command id="calendar_new_event_context_command" oncommand="goDoCommand('calendar_new_event_context_command')"/>
+ <command id="calendar_new_event_todaypane_command" oncommand="goDoCommand('calendar_new_event_todaypane_command')"/>
+ <command id="calendar_modify_event_command" oncommand="goDoCommand('calendar_modify_event_command')"/>
+ <command id="calendar_view_event_command" oncommand="goDoCommand('calendar_view_event_command')"/>
+ <command id="calendar_delete_event_command" oncommand="goDoCommand('calendar_delete_event_command')"/>
+
+ <command id="calendar_new_todo_command" oncommand="goDoCommand('calendar_new_todo_command')"/>
+ <command id="calendar_new_todo_context_command" oncommand="goDoCommand('calendar_new_todo_context_command')"/>
+ <command id="calendar_new_todo_todaypane_command" oncommand="goDoCommand('calendar_new_todo_todaypane_command')"/>
+ <command id="calendar_modify_todo_command" oncommand="goDoCommand('calendar_modify_todo_command')"/>
+ <command id="calendar_modify_todo_todaypane_command" oncommand="goDoCommand('calendar_modify_todo_todaypane_command')"/>
+ <command id="calendar_delete_todo_command" oncommand="goDoCommand('calendar_delete_todo_command')"/>
+
+ <command id="calendar_modify_focused_item_command" oncommand="goDoCommand('calendar_modify_focused_item_command')"/>
+ <command id="calendar_delete_focused_item_command" oncommand="goDoCommand('calendar_delete_focused_item_command')"/>
+
+ <command id="calendar_import_command" oncommand="goDoCommand('calendar_import_command')"/>
+ <command id="calendar_export_command" oncommand="goDoCommand('calendar_export_command')"/>
+ <command id="calendar_export_selection_command" oncommand="goDoCommand('calendar_export_selection_command')"/>
+
+ <command id="calendar_publish_selected_calendar_command" oncommand="goDoCommand('calendar_publish_selected_calendar_command')"/>
+ <command id="calendar_publish_calendar_command" oncommand="goDoCommand('calendar_publish_calendar_command')"/>
+ <command id="calendar_publish_selected_events_command" oncommand="goDoCommand('calendar_publish_selected_events_command')"/>
+
+ <command id="calendar_reload_remote_calendars" oncommand="goDoCommand('calendar_reload_remote_calendars')"/>
+
+ <command id="calendar_show_unifinder_command" oncommand="goDoCommand('calendar_show_unifinder_command')"/>
+ <!-- The dash instead of the underscore is intended. the 'xxx-view' part should be the id of the view in the deck -->
+ <command id="calendar_day-view_command" oncommand="goDoCommand('calendar_day-view_command')"/>
+ <command id="calendar_week-view_command" oncommand="goDoCommand('calendar_week-view_command')"/>
+ <command id="calendar_multiweek-view_command" oncommand="goDoCommand('calendar_multiweek-view_command')"/>
+ <command id="calendar_month-view_command" oncommand="goDoCommand('calendar_month-view_command')"/>
+ <command id="calendar_task_category_command"/>
+ <command id="calendar_toggle_completed_command" oncommand="toggleCompleted(event)"/>
+ <command id="calendar_percentComplete-0_command" oncommand="contextChangeTaskProgress(0)"/>
+ <command id="calendar_percentComplete-25_command" oncommand="contextChangeTaskProgress(25)"/>
+ <command id="calendar_percentComplete-50_command" oncommand="contextChangeTaskProgress(50)"/>
+ <command id="calendar_percentComplete-75_command" oncommand="contextChangeTaskProgress(75)"/>
+ <command id="calendar_percentComplete-100_command" oncommand="contextChangeTaskProgress(100)"/>
+ <command id="calendar_priority-0_command" oncommand="contextChangeTaskPriority(0)"/>
+ <command id="calendar_priority-9_command" oncommand="contextChangeTaskPriority(9)"/>
+ <command id="calendar_priority-5_command" oncommand="contextChangeTaskPriority(5)"/>
+ <command id="calendar_priority-1_command" oncommand="contextChangeTaskPriority(1)"/>
+ <command id="calendar_general-priority_command" oncommand="goDoCommand('calendar_general-priority_command')"/>
+ <command id="calendar_general-progress_command" oncommand="goDoCommand('calendar_general-progress_command')"/>
+ <command id="calendar_general-postpone_command"/>
+ <command id="calendar_postpone-1hour_command" oncommand="contextPostponeTask('PT1H')"/>
+ <command id="calendar_postpone-1day_command" oncommand="contextPostponeTask('P1D')"/>
+ <command id="calendar_postpone-1week_command" oncommand="contextPostponeTask('P1W')"/>
+ <command id="calendar_toggle_orientation_command" persist="checked" oncommand="goDoCommand('calendar_toggle_orientation_command')"/>
+ <command id="calendar_toggle_workdays_only_command" persist="checked" oncommand="goDoCommand('calendar_toggle_workdays_only_command')"/>
+ <command id="calendar_toggle_tasks_in_view_command" persist="checked" oncommand="toggleTasksInView()"/>
+ <command id="calendar_toggle_show_completed_in_view_command" persist="checked" oncommand="toggleShowCompletedInView()"/>
+ <command id="calendar_toggle_calendarsidebar_command" oncommand="togglePaneSplitter('calsidebar_splitter')"/>
+ <command id="calendar_toggle_minimonthpane_command" oncommand="document.getElementById('minimonth-pane').togglePane(event)"/>
+ <command id="calendar_toggle_calendarlist_command" oncommand="document.getElementById('calendar-list-pane').togglePane(event)"/>
+ <command id="calendar_task_filter_command" oncommand="taskViewUpdate(event.target.getAttribute('value'))"/>
+ <command id="calendar_toggle_filter_command" oncommand="document.getElementById('task-filter-pane').togglePane(event)"/>
+ <command id="calendar_view_next_command" oncommand="goDoCommand('calendar_view_next_command')"/>
+ <command id="calendar_view_today_command" oncommand="currentView().moveView()"/>
+ <command id="calendar_view_prev_command" oncommand="goDoCommand('calendar_view_prev_command')"/>
+
+ <!-- this is a pseudo-command that is disabled when in calendar mode -->
+ <command id="calendar_in_foreground"/>
+ <!-- this is a pseudo-command that is disabled when not in calendar mode -->
+ <command id="calendar_in_background"/>
+
+ <!-- These commands are enabled when in calendar or task mode, respectively -->
+ <command id="calendar_mode_calendar"/>
+ <command id="calendar_mode_task"/>
+
+ <command id="calendar_attendance_command"/>
+
+ <command id="calendar_toggle_todaypane_command" oncommand="TodayPane.toggleVisibility(event)"/>
+</commandset>
diff --git a/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml b/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml
new file mode 100644
index 0000000000..f408dcca84
--- /dev/null
+++ b/comm/calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml
@@ -0,0 +1,949 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<popupset id="calendar-popupset">
+ <!-- Tooltips -->
+ <tooltip id="eventTreeTooltip"
+ onpopupshowing="return showToolTip(this, unifinderTreeView.getItemFromEvent(event))"
+ noautohide="true"/>
+
+ <tooltip id="taskTreeTooltip"
+ onpopupshowing="return showToolTip(this, getTaskTree().getTaskFromEvent(event))"
+ noautohide="true"/>
+
+ <tooltip id="itemTooltip"
+ noautohide="true"/>
+
+ <menupopup id="agenda-menupopup">
+ <menuitem label="&calendar.context.modifyorviewitem.label;"
+ accesskey="&calendar.context.modifyorviewitem.accesskey;"
+ command="agenda_edit_event_command"/>
+ <menu id="agenda-context-menu-convert-menu"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.calendar;">
+ <menupopup id="agenda-context-menu-convert-menupopup">
+ <menuitem id="agenda-context-menu-convert-message-menuitem"
+ label="&calendar.context.convertmenu.message.label;"
+ accesskey="&calendar.context.convertmenu.message.accesskey;"
+ oncommand="calendarMailButtonDNDObserver.onDropItems([TodayPane.agenda.selectedItem])"/>
+ <menuitem id="agenda-context-menu-convert-task-menuitem"
+ class="event-only"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"
+ oncommand="calendarTaskButtonDNDObserver.onDropItems([TodayPane.agenda.selectedItem])"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="calendar-today-pane-menuseparator-before-delete"/>
+ <menuitem label="&calendar.context.deleteevent.label;"
+ accesskey="&calendar.context.deleteevent.accesskey;"
+ key="calendar-delete-item-key"
+ command="agenda_delete_event_command"/>
+ <menu id="calendar-today-pane-menu-attendance-menu"
+ label="&calendar.context.attendance.menu.label;"
+ accesskey="&calendar.context.attendance.menu.accesskey;"
+ oncommand="setContextPartstat(event.target, [TodayPane.agenda.selectedItem])"
+ observes="calendar_attendance_command">
+ <menupopup id="agenda-context-menu-attendance-menupopup">
+ <label id="agenda-context-attendance-thisoccurrence-label"
+ class="calendar-context-heading-label"
+ scope="this-occurrence"
+ value="&calendar.context.attendance.occurrence.label;"/>
+ <menu id="agenda-context-menu-attendance-accepted-menu"
+ label="&calendar.context.attendance.occ.accepted.label;"
+ accesskey="&calendar.context.attendance.occ.accepted.accesskey;"
+ value="ACCEPTED"
+ name="agenda-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="agenda-context-menu-occurrence-accepted-menupopup">
+ <menuitem id="agenda-context-menu-attend-accept-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-accept-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="agenda-context-menu-attendance-tentative-menu"
+ label="&calendar.context.attendance.occ.tentative.label;"
+ accesskey="&calendar.context.attendance.occ.tentative.accesskey;"
+ value="TENTATIVE"
+ name="agenda-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="agenda-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="agenda-context-menu-attend-tentative-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-tentative-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="agenda-context-menu-attendance-declined-menu"
+ label="&calendar.context.attendance.occ.declined.label;"
+ accesskey="&calendar.context.attendance.occ.declined.accesskey;"
+ value="DECLINED"
+ name="agenda-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="agenda-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="agenda-context-menu-attend-declined-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-declined-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="agenda-context-menu-attendance-delegated-menu"
+ label="&calendar.context.attendance.occ.delegated.label;"
+ name="agenda-context-attendance"
+ scope="this-occurrence"
+ value="DELEGATED"/>
+ <menuitem id="agenda-context-menu-attendance-needsaction-menu"
+ label="&calendar.context.attendance.occ.needsaction.label;"
+ name="agenda-context-attendance"
+ scope="this-occurrence"
+ value="NEEDS-ACTION"/>
+ <label id="agenda-context-attendance-alloccurrence-label"
+ class="calendar-context-heading-label"
+ scope="all-occurrences"
+ value="&calendar.context.attendance.all2.label;"/>
+ <menu id="agenda-context-menu-attendance-accepted-all-menu"
+ label="&calendar.context.attendance.all.accepted.label;"
+ accesskey="&calendar.context.attendance.all.accepted.accesskey;"
+ value="ACCEPTED"
+ name="agenda-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="agenda-context-menu-alloccurrences-accept-menupopup">
+ <menuitem id="agenda-context-menu-attend-accept-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-accept-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="agenda-context-menu-attendance-tentative-all-menu"
+ label="&calendar.context.attendance.all.tentative.label;"
+ accesskey="&calendar.context.attendance.all.tentative.accesskey;"
+ value="TENTATIVE"
+ name="agenda-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="agenda-context-menu-alloccurrences-tentative-menupopup">
+ <menuitem id="agenda-context-menu-attend-tentative-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-tentative-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="agenda-context-menu-attendance-decline-all-menu"
+ label="&calendar.context.attendance.all.declined.label;"
+ accesskey="&calendar.context.attendance.all.declined.accesskey;"
+ value="DECLINED"
+ name="agenda-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="agenda-context-menu-alloccurrences-decline-menupopup">
+ <menuitem id="agenda-context-menu-attend-declined-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="agenda-context-menu-attend-declined-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="agenda-context-menu-attendance-delegated-all-menu"
+ label="&calendar.context.attendance.all.delegated.label;"
+ name="agenda-context-attendance-delegated-all"
+ scope="all-occurrences"
+ value="DELEGATED"/>
+ <menuitem id="agenda-context-menu-attendance-needsaction-all-menu"
+ label="&calendar.context.attendance.all.needsaction.label;"
+ name="agenda-context-attendance-needaction-all"
+ scope="all-occurrences"
+ value="NEEDS-ACTION"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+
+ <!-- CALENDAR LIST CONTEXT MENU -->
+ <menupopup id="list-calendars-context-menu">
+ <menuitem id="list-calendars-context-togglevisible"
+ class="needs-calendar"
+ accesskeyshow="&calendar.context.showcalendar.accesskey;"
+ accesskeyhide="&calendar.context.hidecalendar.accesskey;"
+ oncommand="toggleCalendarVisible(document.getElementById('list-calendars-context-menu').contextCalendar);"/>
+ <menuitem id="list-calendars-context-showonly"
+ class="needs-calendar"
+ accesskey="&calendar.context.showonly.accesskey;"
+ oncommand="showOnlyCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/>
+ <menuitem id="list-calendars-context-showall"
+ label="&calendar.context.showall.label;"
+ accesskey="&calendar.context.showall.accesskey;"
+ oncommand="showAllCalendars();"/>
+ <menuseparator id="list-calendars-context-showops-menuseparator"/>
+ <menuitem id="list-calendars-context-new"
+ label="&calendar.context.newserver.label;"
+ accesskey="&calendar.context.newserver.accesskey;"
+ command="calendar_new_calendar_command"/>
+ <menuitem id="list-calendars-context-delete"
+ class="needs-calendar"
+ labeldelete="&calendar.context.deleteserver2.label;"
+ labelremove="&calendar.context.removeserver.label;"
+ labelunsubscribe="&calendar.context.unsubscribeserver.label;"
+ accesskeydelete="&calendar.context.deleteserver2.accesskey;"
+ accesskeyremove="&calendar.context.removeserver.accesskey;"
+ accesskeyunsubscribe="&calendar.context.unsubscribeserver.accesskey;"
+ command="calendar_delete_calendar_command"/>
+ <menuseparator id="list-calendars-context-itemops-menuseparator"
+ class="needs-calendar"/>
+ <menuitem id="list-calendars-context-export"
+ class="needs-calendar"
+ label="&calendar.context.export.label;"
+ accesskey="&calendar.context.export.accesskey;"
+ oncommand="exportEntireCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/>
+ <menuitem id="list-calendars-context-publish"
+ class="needs-calendar"
+ label="&calendar.context.publish.label;"
+ accesskey="&calendar.context.publish.accesskey;"
+ command="calendar_publish_selected_calendar_command"/>
+ <menuseparator id="list-calendars-context-export-menuseparator"
+ class="needs-calendar"/>
+ <menuitem id="list-calendar-context-reload"
+ class="needs-calendar"
+ data-l10n-id="list-calendar-context-reload-menuitem"
+ oncommand="document.getElementById('list-calendars-context-menu').contextCalendar.refresh();"/>
+ <menuseparator id="list-calendars-context-reload-menuseparator"
+ class="needs-calendar"/>
+ <menuitem id="list-calendars-context-edit"
+ class="needs-calendar"
+ label="&calendar.context.properties.label;"
+ accesskey="&calendar.context.properties.accesskey;"
+ command="calendar_edit_calendar_command"/>
+ </menupopup>
+
+ <!-- CALENDAR ITEM CONTEXT MENU -->
+ <menupopup id="calendar-item-context-menu"
+ onpopupshowing="return setupContextItemType(event, currentView().getSelectedItems());">
+ <menuitem id="calendar-item-context-menu-view-menuitem"
+ label="&calendar.context.modifyorviewitem.label;"
+ accesskey="&calendar.context.modifyorviewitem.accesskey;"
+ command="calendar_view_event_command"/>
+ <menuitem id="calendar-item-context-menu-modify-menuitem"
+ data-l10n-id="calendar-item-context-menu-modify-menuitem"
+ command="calendar_modify_event_command"
+ disabled="true" />
+ <menuitem id="calendar-item-context-menu-newevent-menutitem"
+ label="&calendar.context.newevent.label;"
+ accesskey="&calendar.context.newevent.accesskey;"
+ key="calendar-new-event-key"
+ command="calendar_new_event_context_command"/>
+ <menuitem id="calendar-item-context-menu-newtodo-menuitem"
+ label="&calendar.context.newtodo.label;"
+ accesskey="&calendar.context.newtodo.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_context_command"/>
+ <menuseparator id="calendar-item-context-menuseparator-adddeletemodify"/>
+ <menuitem id="calendar-item-context-menu-cut-menuitem"
+ label="&calendar.context.cutevent.label;"
+ accesskey="&calendar.context.cutevent.accesskey;"
+ key="key_cut"
+ command="cmd_cut"/>
+ <menuitem id="calendar-item-context-menu-copy-menuitem"
+ label="&calendar.context.copyevent.label;"
+ accesskey="&calendar.context.copyevent.accesskey;"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuitem id="calendar-item-context-menu-paste-menuitem"
+ label="&calendar.context.pasteevent.label;"
+ accesskey="&calendar.context.pasteevent.accesskey;"
+ key="key_paste"
+ command="cmd_paste"/>
+ <menuseparator id="calendar-item-context-separator-cutcopypaste"/>
+ <menu id="calendar-item-context-menu-convert-menu"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.calendar;">
+ <menupopup id="calendar-item-context-menu-convert-menupopup">
+ <menuitem id="calendar-view-context-menu-convert-message-menuitem"
+ label="&calendar.context.convertmenu.message.label;"
+ accesskey="&calendar.context.convertmenu.message.accesskey;"
+ oncommand="calendarMailButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/>
+ <menuitem id="calendar-item-context-menu-convert-event-menuitem"
+ class="todo-only"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"
+ oncommand="calendarCalendarButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/>
+ <menuitem id="calendar-item-context-menu-convert-task-menuitem"
+ class="event-only"
+ label="&calendar.context.convertmenu.task.label;"
+ accesskey="&calendar.context.convertmenu.task.accesskey;"
+ oncommand="calendarTaskButtonDNDObserver.onDropItems(currentView().getSelectedItems())"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="calendar-menuseparator-before-delete"/>
+ <!-- the label and accesskey of the following menuitem is set during runtime,
+ and depends on whether the item is a task or an event -->
+ <menuitem id="calendar-item-context-menu-delete-menuitem"
+ key="calendar-delete-item-key"
+ command="calendar_delete_event_command"/>
+ <menu id="calendar-item-context-menu-attendance-menu"
+ label="&calendar.context.attendance.menu.label;"
+ accesskey="&calendar.context.attendance.menu.accesskey;"
+ oncommand="setContextPartstat(event.target, currentView().getSelectedItems())"
+ observes="calendar_attendance_command">
+ <menupopup id="calendar-item-context-menu-attendance-menupopup">
+ <label id="calendar-item-context-attendance-thisoccurrence-label"
+ class="calendar-context-heading-label"
+ scope="this-occurrence"
+ value="&calendar.context.attendance.occurrence.label;"/>
+ <menu id="calendar-item-context-menu-attendance-accepted-menu"
+ label="&calendar.context.attendance.occ.accepted.label;"
+ accesskey="&calendar.context.attendance.occ.accepted.accesskey;"
+ value="ACCEPTED"
+ name="calendar-item-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="calendar-item-context-menu-occurrence-accepted-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-accept-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-accept-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="calendar-item-context-menu-attendance-tentative-menu"
+ label="&calendar.context.attendance.occ.tentative.label;"
+ accesskey="&calendar.context.attendance.occ.tentative.accesskey;"
+ value="TENTATIVE"
+ name="calendar-item-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="calendar-item-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-tentative-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-tentative-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="calendar-item-context-menu-attendance-declined-menu"
+ label="&calendar.context.attendance.occ.declined.label;"
+ accesskey="&calendar.context.attendance.occ.declined.accesskey;"
+ value="DECLINED"
+ name="calendar-item-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="calendar-item-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-declined-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-declined-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="calendar-item-context-menu-attendance-delegated-menu"
+ label="&calendar.context.attendance.occ.delegated.label;"
+ name="calendar-item-context-attendance"
+ scope="this-occurrence"
+ value="DELEGATED"/>
+ <menuitem id="calendar-item-context-menu-attendance-needsaction-menu"
+ label="&calendar.context.attendance.occ.needsaction.label;"
+ name="calendar-item-context-attendance"
+ scope="this-occurrence"
+ value="NEEDS-ACTION"/>
+ <label id="calendar-item-context-attendance-alloccurrence-label"
+ class="calendar-context-heading-label"
+ scope="all-occurrences"
+ value="&calendar.context.attendance.all2.label;"/>
+ <menu id="calendar-item-context-menu-attendance-accepted-all-menu"
+ label="&calendar.context.attendance.all.accepted.label;"
+ accesskey="&calendar.context.attendance.all.accepted.accesskey;"
+ value="ACCEPTED"
+ name="calendar-item-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="calendar-item-context-menu-alloccurrences-accept-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-accept-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-accept-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="calendar-item-context-menu-attendance-tentative-all-menu"
+ label="&calendar.context.attendance.all.tentative.label;"
+ accesskey="&calendar.context.attendance.all.tentative.accesskey;"
+ value="TENTATIVE"
+ name="calendar-item-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="calendar-item-context-menu-alloccurrences-tentative-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-tentative-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-tentative-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="calendar-item-context-menu-attendance-decline-all-menu"
+ label="&calendar.context.attendance.all.declined.label;"
+ accesskey="&calendar.context.attendance.all.declined.accesskey;"
+ value="DECLINED"
+ name="calendar-item-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="calendar-item-context-menu-alloccurrences-decline-menupopup">
+ <menuitem id="calendar-item-context-menu-attend-declined-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="calendar-item-context-menu-attend-declined-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="calendar-item-context-menu-attendance-delegated-all-menu"
+ label="&calendar.context.attendance.all.delegated.label;"
+ name="calendar-item-context-attendance-delegated-all"
+ scope="all-occurrences"
+ value="DELEGATED"/>
+ <menuitem id="calendar-item-context-menu-attendance-needsaction-all-menu"
+ label="&calendar.context.attendance.all.needsaction.label;"
+ name="calendar-item-context-attendance-needaction-all"
+ scope="all-occurrences"
+ value="NEEDS-ACTION"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+
+ <!-- CALENDAR VIEW CONTEXT MENU -->
+ <menupopup id="calendar-view-context-menu">
+ <menuitem id="calendar-view-context-menu-newevent"
+ label="&calendar.context.newevent.label;"
+ command="calendar_new_event_context_command"
+ accesskey="&calendar.context.newevent.accesskey;"
+ key="calendar-new-event-key"/>
+ <menuitem id="calendar-view-context-menu-newtodo"
+ label="&calendar.context.newtodo.label;"
+ command="calendar_new_todo_context_command"
+ accesskey="&calendar.context.newtodo.accesskey;"
+ key="calendar-new-todo-key"/>
+ <!-- These labels are set dynamically, based on the current view -->
+ <menuitem id="calendar-view-context-menu-previous"
+ command="calendar_view_prev_command"/>
+ <menuitem id="calendar-view-context-menu-next"
+ command="calendar_view_next_command"/>
+ <menuseparator id="calendar-item-context-separator-cutcopypaste"/>
+ <!-- Cut and copy doesn't make sense in the views, but only showing paste
+ makes it look like something is missing. Disable by default. -->
+ <menuitem id="calendar-view-context-menu-cut-menuitem"
+ label="&calendar.context.cutevent.label;"
+ accesskey="&calendar.context.cutevent.accesskey;"
+ key="key_cut"
+ disabled="true"/>
+ <menuitem id="calendar-view-context-menu-copy-menuitem"
+ label="&calendar.context.copyevent.label;"
+ accesskey="&calendar.context.copyevent.accesskey;"
+ key="key_copy"
+ disabled="true"/>
+ <menuitem id="calendar-view-context-menu-paste-menuitem"
+ label="&calendar.context.pasteevent.label;"
+ accesskey="&calendar.context.pasteevent.accesskey;"
+ key="key_paste"
+ command="cmd_paste"/>
+ </menupopup>
+
+ <!-- TASK ITEM CONTEXT MENU -->
+ <menupopup id="taskitem-context-menu"
+ onpopupshowing="changeContextMenuForTask(event);"
+ onpopuphiding="handleTaskContextMenuStateChange(event);">
+ <menuitem id="task-context-menu-modify"
+ label="&calendar.context.modifyorviewtask.label;"
+ accesskey="&calendar.context.modifyorviewtask.accesskey;"
+ command="calendar_modify_todo_command"/>
+ <menuitem id="task-context-menu-modify-todaypane"
+ label="&calendar.context.modifyorviewtask.label;"
+ accesskey="&calendar.context.modifyorviewtask.accesskey;"
+ command="calendar_modify_todo_todaypane_command"/>
+ <menuitem id="task-context-menu-new"
+ label="&calendar.context.newtodo.label;"
+ accesskey="&calendar.context.newtodo.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_command"/>
+ <menuitem id="task-context-menu-new-todaypane"
+ label="&calendar.context.newtodo.label;"
+ accesskey="&calendar.context.newtodo.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_todaypane_command"/>
+ <menuseparator id="task-context-menuseparator-cutcopypaste"/>
+ <menuitem id="task-context-menu-cut-menuitem"
+ label="&calendar.context.cutevent.label;"
+ accesskey="&calendar.context.cutevent.accesskey;"
+ key="key_cut"
+ command="cmd_cut"/>
+ <menuitem id="task-context-menu-copy-menuitem"
+ label="&calendar.context.copyevent.label;"
+ accesskey="&calendar.context.copyevent.accesskey;"
+ key="key_copy"
+ command="cmd_copy"/>
+ <menuitem id="task-context-menu-paste-menuitem"
+ label="&calendar.context.pasteevent.label;"
+ accesskey="&calendar.context.pasteevent.accesskey;"
+ key="key_paste"
+ command="cmd_paste"/>
+ <menuseparator id="calendar-menuseparator-beforemarkcompleted"/>
+ <menuitem id="calendar-context-markcompleted"
+ type="checkbox"
+ autocheck="false"
+ label="&calendar.context.markcompleted.label;"
+ accesskey="&calendar.context.markcompleted.accesskey;"
+ command="calendar_toggle_completed_command"/>
+ <menu id="task-context-menu-progress"
+ label="&calendar.context.progress.label;"
+ accesskey="&calendar.context.progress.accesskey;"
+ command="calendar_general-progress_command">
+ <menupopup is="calendar-task-progress-menupopup"/>
+ </menu>
+ <menu id="task-context-menu-priority"
+ label="&calendar.context.priority.label;"
+ accesskey="&calendar.context.priority.accesskey;"
+ command="calendar_general-priority_command">
+ <menupopup is="calendar-task-priority-menupopup"/>
+ </menu>
+ <menu id="task-context-menu-postpone"
+ label="&calendar.context.postpone.label;"
+ accesskey="&calendar.context.postpone.accesskey;"
+ command="calendar_general-postpone_command">
+ <menupopup id="task-context-postpone-menupopup">
+ <menuitem id="task-context-postpone-1hour"
+ label="&calendar.context.postpone.1hour.label;"
+ accesskey="&calendar.context.postpone.1hour.accesskey;"
+ command="calendar_postpone-1hour_command"/>
+ <menuitem id="task-context-postpone-1day"
+ label="&calendar.context.postpone.1day.label;"
+ accesskey="&calendar.context.postpone.1day.accesskey;"
+ command="calendar_postpone-1day_command"/>
+ <menuitem id="task-context-postpone-1week"
+ label="&calendar.context.postpone.1week.label;"
+ accesskey="&calendar.context.postpone.1week.accesskey;"
+ command="calendar_postpone-1week_command"/>
+ </menupopup>
+ </menu>
+ <menu id="calendar-context-calendar-menu"
+ label="&calendar.calendar.label;"
+ accesskey="&calendar.calendar.accesskey;">
+ <menupopup id="calendar-context-calendar-menupopup"
+ onpopupshowing="addCalendarNames(event);"/>
+ </menu>
+ <menuseparator id="task-context-menu-separator-conversion"/>
+ <menu id="task-context-menu-convert"
+ label="&calendar.context.convertmenu.label;"
+ accesskey="&calendar.context.convertmenu.accesskey.calendar;">
+ <menupopup id="task-context-convert-menupopup">
+ <menuitem id="calendar-context-converttomessage"
+ label="&calendar.context.convertmenu.message.label;"
+ accesskey="&calendar.context.convertmenu.message.accesskey;"
+ oncommand="tasksToMail()"/>
+ <menuitem id="calendar-context-converttoevent"
+ label="&calendar.context.convertmenu.event.label;"
+ accesskey="&calendar.context.convertmenu.event.accesskey;"
+ oncommand="tasksToEvents()"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="task-context-menu-delete"
+ label="&calendar.context.deletetask.label;"
+ accesskey="&calendar.context.deletetask.accesskey;"
+ command="calendar_delete_todo_command"/>
+ <menu id="task-context-menu-attendance-menu"
+ label="&calendar.context.attendance.menu.label;"
+ accesskey="&calendar.context.attendance.menu.accesskey;"
+ oncommand="setContextPartstat(event.target, getSelectedTasks())"
+ observes="calendar_attendance_command">
+ <menupopup id="task-context-menu-attendance-menupopup">
+ <label id="task-context-attendance-thisoccurrence-label"
+ class="calendar-context-heading-label"
+ scope="this-occurrence"
+ value="&calendar.context.attendance.occurrence.label;"/>
+ <menu id="task-context-menu-attendance-accepted-menu"
+ label="&calendar.context.attendance.occ.accepted.label;"
+ accesskey="&calendar.context.attendance.occ.accepted.accesskey;"
+ value="ACCEPTED"
+ name="task-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="task-context-menu-occurrence-accepted-menupopup">
+ <menuitem id="task-context-menu-attend-accept-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-accept-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-tentative-menu"
+ label="&calendar.context.attendance.occ.tentative.label;"
+ accesskey="&calendar.context.attendance.occ.tentative.accesskey;"
+ value="TENTATIVE"
+ name="task-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="task-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="task-context-menu-attend-tentative-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-tentative-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-declined-menu"
+ label="&calendar.context.attendance.occ.declined.label;"
+ accesskey="&calendar.context.attendance.occ.declined.accesskey;"
+ value="DECLINED"
+ name="task-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="task-context-menu-occurrence-tentative-menupopup">
+ <menuitem id="task-context-menu-attend-declined-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-declined-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-inprogress-menu"
+ label="&calendar.context.attendance.occ.inprogress.label;"
+ accesskey="&calendar.context.attendance.occ.inprogress.accesskey;"
+ value="IN-PROGRESS"
+ name="task-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="task-context-menu-occurrence-inprogress-menupopup">
+ <menuitem id="task-context-menu-attend-inprogress-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="IN-PROGRESS"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-inprogress-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="IN-PROGRESS"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-completed-menu"
+ label="&calendar.context.attendance.occ.completed.label;"
+ accesskey="&calendar.context.attendance.occ.completed.accesskey;"
+ value="COMPLETED"
+ name="task-context-attendance"
+ scope="this-occurrence">
+ <menupopup id="task-context-menu-occurrence-completed-menupopup">
+ <menuitem id="task-context-menu-attend-completed-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="COMPLETED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-completed-dontsend-menuitem"
+ scope="this-occurrence"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="COMPLETED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="task-context-menu-attendance-delegated-menu"
+ label="&calendar.context.attendance.occ.delegated.label;"
+ name="task-context-attendance"
+ scope="this-occurrence"
+ value="DELEGATED"/>
+ <menuitem id="task-context-menu-attendance-needsaction-menu"
+ label="&calendar.context.attendance.occ.needsaction.label;"
+ name="task-context-attendance"
+ scope="this-occurrence"
+ value="NEEDS-ACTION"/>
+ <label id="task-context-attendance-alloccurrence-label"
+ class="calendar-context-heading-label"
+ scope="all-occurrences"
+ value="&calendar.context.attendance.all2.label;"/>
+ <menu id="task-context-menu-attendance-accepted-all-menu"
+ label="&calendar.context.attendance.all.accepted.label;"
+ accesskey="&calendar.context.attendance.all.accepted.accesskey;"
+ value="ACCEPTED"
+ name="task-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="task-context-menu-alloccurrences-accept-menupopup">
+ <menuitem id="task-context-menu-attend-accept-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-accept-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="ACCEPTED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-tentative-all-menu"
+ label="&calendar.context.attendance.all.tentative.label;"
+ accesskey="&calendar.context.attendance.all.tentative.accesskey;"
+ value="TENTATIVE"
+ name="task-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="task-context-menu-alloccurrences-tentative-menupopup">
+ <menuitem id="task-context-menu-attend-tentative-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-tentative-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="TENTATIVE"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-decline-all-menu"
+ label="&calendar.context.attendance.all.declined.label;"
+ accesskey="&calendar.context.attendance.all.declined.accesskey;"
+ value="DECLINED"
+ name="task-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="task-context-menu-alloccurrences-decline-menupopup">
+ <menuitem id="task-context-menu-attend-declined-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="DECLINED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-declined-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="DECLINED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-inprogress-all-menu"
+ label="&calendar.context.attendance.all.inprogress.label;"
+ accesskey="&calendar.context.attendance.all.inprogress.accesskey;"
+ value="IN-PROGRESS"
+ name="task-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="task-context-menu-alloccurrences-inprogress-menupopup">
+ <menuitem id="task-context-menu-attend-inprogress-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="IN-PROGRESS"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-inprogress-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="IN-PROGRESS"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menu id="task-context-menu-attendance-completed-all-menu"
+ label="&calendar.context.attendance.all.completed.label;"
+ accesskey="&calendar.context.attendance.all.completed.accesskey;"
+ value="COMPLETED"
+ name="task-context-attendance-all"
+ scope="all-occurrences">
+ <menupopup id="task-context-menu-alloccurrences-completed-menupopup">
+ <menuitem id="task-context-menu-attend-completed-all-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.send.label;"
+ accesskey="&calendar.context.attendance.send.accesskey;"
+ respvalue="COMPLETED"
+ respmode="AUTO"/>
+ <menuitem id="task-context-menu-attend-completed-all-dontsend-menuitem"
+ scope="all-occurrences"
+ label="&calendar.context.attendance.dontsend.label;"
+ accesskey="&calendar.context.attendance.dontsend.accesskey;"
+ respvalue="COMPLETED"
+ respmode="NONE"/>
+ </menupopup>
+ </menu>
+ <menuitem id="task-context-menu-attendance-delegated-all-menu"
+ label="&calendar.context.attendance.all.delegated.label;"
+ name="task-context-attendance-delegated-all"
+ scope="all-occurrences"
+ value="DELEGATED"/>
+ <menuitem id="task-context-menu-attendance-needsaction-all-menu"
+ label="&calendar.context.attendance.all.delegated.label;"
+ name="task-context-attendance-needaction-all"
+ scope="all-occurrences"
+ value="NEEDS-ACTION"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="task-context-menu-separator-filter"/>
+ <menu id="task-context-menu-filter-todaypane"
+ label="&calendar.tasks.view.filtertasks.label;"
+ accesskey="&calendar.tasks.view.filtertasks.accesskey;">
+ <menupopup id="task-context-menu-filter-todaypane-popup"
+ oncommand="TodayPane.updateCalendarToDoUnifinder(event.target.getAttribute('value'))">
+ <menuitem id="task-context-menu-filter-todaypane-current"
+ name="filtergrouptodaypane"
+ value="throughcurrent"
+ type="radio"
+ label="&calendar.task.filter.current.label;"
+ accesskey="&calendar.task.filter.current.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-today"
+ name="filtergrouptodaypane"
+ value="throughtoday"
+ type="radio"
+ label="&calendar.task.filter.today.label;"
+ accesskey="&calendar.task.filter.today.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-next7days"
+ name="filtergrouptodaypane"
+ value="throughsevendays"
+ type="radio"
+ label="&calendar.task.filter.next7days.label;"
+ accesskey="&calendar.task.filter.next7days.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-notstarted"
+ name="filtergrouptodaypane"
+ value="notstarted"
+ type="radio"
+ label="&calendar.task.filter.notstarted.label;"
+ accesskey="&calendar.task.filter.notstarted.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-overdue"
+ name="filtergrouptodaypane"
+ value="overdue"
+ type="radio"
+ label="&calendar.task.filter.overdue.label;"
+ accesskey="&calendar.task.filter.overdue.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-completed"
+ name="filtergrouptodaypane"
+ type="radio"
+ value="completed"
+ label="&calendar.task.filter.completed.label;"
+ accesskey="&calendar.task.filter.completed.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-open"
+ name="filtergrouptodaypane"
+ type="radio"
+ value="open"
+ label="&calendar.task.filter.open.label;"
+ accesskey="&calendar.task.filter.open.accesskey;"/>
+ <menuitem id="task-context-menu-filter-todaypane-all"
+ name="filtergrouptodaypane"
+ value="all"
+ type="radio"
+ label="&calendar.task.filter.all.label;"
+ accesskey="&calendar.task.filter.all.accesskey;"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+
+ <!-- TASKVIEW LINK CONTEXT MENU -->
+ <menupopup id="taskview-link-context-menu">
+ <menuitem id="taskview-link-context-menu-copy"
+ label="&calendar.copylink.label;"
+ accesskey="&calendar.copylink.accesskey;"
+ oncommand="taskViewCopyLink(this.parentNode.triggerNode)"/>
+ </menupopup>
+
+ <!-- CALENDAR EVENT DIALOG (IN TAB) TOOLBAR CONTEXT MENU -->
+ <menupopup id="event-dialog-toolbar-context-menu"
+ onpopupshowing="calendarOnToolbarsPopupShowing(event);">
+ <menuseparator id="customizeEventToolbarMenuSeparator"/>
+ <menuitem id="CustomizeDialogToolbar"
+ label="&event.menu.view.toolbars.customize.label;"
+ command="cmd_customize"/>
+ </menupopup>
+</popupset>
diff --git a/comm/calendar/base/content/calendar-day-label.js b/comm/calendar/base/content/calendar-day-label.js
new file mode 100644
index 0000000000..e0d205bc5d
--- /dev/null
+++ b/comm/calendar/base/content/calendar-day-label.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozXULElement, getSummarizedStyleValues */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ class MozCalendarDayLabel extends MozXULElement {
+ static get observedAttributes() {
+ return ["selected", "relation"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.textContent = "";
+ this.setAttribute("flex", "1");
+ this.setAttribute("pack", "center");
+
+ this.longWeekdayName = document.createXULElement("label");
+ this.longWeekdayName.classList.add("calendar-day-label-name");
+
+ this.shortWeekdayName = document.createXULElement("label");
+ this.shortWeekdayName.classList.add("calendar-day-label-name");
+ this.shortWeekdayName.setAttribute("hidden", "true");
+
+ this.appendChild(this.longWeekdayName);
+ this.appendChild(this.shortWeekdayName);
+
+ this.mWeekday = -1;
+
+ this.longWeekdayPixels = 0;
+
+ this.mDate = null;
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.longWeekdayName || !this.shortWeekdayName) {
+ return;
+ }
+
+ if (this.hasAttribute("selected")) {
+ this.longWeekdayName.setAttribute("selected", this.getAttribute("selected"));
+ this.shortWeekdayName.setAttribute("selected", this.getAttribute("selected"));
+ } else {
+ this.longWeekdayName.removeAttribute("selected");
+ this.shortWeekdayName.removeAttribute("selected");
+ }
+
+ if (this.hasAttribute("relation")) {
+ this.longWeekdayName.setAttribute("relation", this.getAttribute("relation"));
+ this.shortWeekdayName.setAttribute("relation", this.getAttribute("relation"));
+ } else {
+ this.longWeekdayName.removeAttribute("relation");
+ this.shortWeekdayName.removeAttribute("relation");
+ }
+ }
+
+ set weekDay(val) {
+ this.mWeekday = val % 7;
+ this.longWeekdayName.value = cal.dtz.formatter.dayName(val);
+ this.shortWeekdayName.value = cal.dtz.formatter.shortDayName(val);
+ }
+
+ get weekDay() {
+ return this.mWeekday;
+ }
+
+ set date(val) {
+ this.mDate = val;
+ let dateFormatter = cal.dtz.formatter;
+ let label = cal.l10n.getCalString("dayHeaderLabel", [
+ dateFormatter.shortDayName(val.weekday),
+ dateFormatter.formatDateWithoutYear(val),
+ ]);
+ this.shortWeekdayName.setAttribute("value", label);
+ label = cal.l10n.getCalString("dayHeaderLabel", [
+ dateFormatter.dayName(val.weekday),
+ dateFormatter.formatDateWithoutYear(val),
+ ]);
+ this.longWeekdayName.setAttribute("value", label);
+ }
+
+ get date() {
+ return this.mDate;
+ }
+
+ set shortWeekNames(val) {
+ // cache before change, in case we are switching to short
+ this.getLongWeekdayPixels();
+ this.longWeekdayName.hidden = val;
+ this.shortWeekdayName.hidden = !val;
+ }
+
+ getLongWeekdayPixels() {
+ // Only do this if the long weekdays are visible and we haven't already cached.
+ let longNameWidth = this.longWeekdayName.getBoundingClientRect().width;
+
+ if (longNameWidth == 0) {
+ // weekdaypixels have not yet been laid out
+ return 0;
+ }
+
+ this.longWeekdayPixels =
+ longNameWidth +
+ getSummarizedStyleValues(this.longWeekdayName, ["margin-left", "margin-right"]);
+ this.longWeekdayPixels += getSummarizedStyleValues(this, [
+ "border-left-width",
+ "padding-left",
+ "padding-right",
+ ]);
+
+ return this.longWeekdayPixels;
+ }
+ }
+
+ customElements.define("calendar-day-label", MozCalendarDayLabel);
+}
diff --git a/comm/calendar/base/content/calendar-dnd-listener.js b/comm/calendar/base/content/calendar-dnd-listener.js
new file mode 100644
index 0000000000..994d305c95
--- /dev/null
+++ b/comm/calendar/base/content/calendar-dnd-listener.js
@@ -0,0 +1,922 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals getSelectedCalendar, MODE_RDONLY, startBatchTransaction, doTransaction,
+ endBatchTransaction, createEventWithDialog, createTodoWithDialog */
+
+/* exported invokeEventDragSession,
+ * calendarMailButtonDNDObserver, calendarCalendarButtonDNDObserver,
+ * calendarTaskButtonDNDObserver
+ */
+
+var calendarViewDNDObserver;
+var calendarMailButtonDNDObserver;
+var calendarCalendarButtonDNDObserver;
+var calendarTaskButtonDNDObserver;
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+ var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+ var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+ XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+ });
+
+ var itemConversion = {
+ /**
+ * Converts an email message to a calendar item.
+ *
+ * @param {calIItemBase} item - The target calIItemBase.
+ * @param {nsIMsgDBHdr} message - The nsIMsgDBHdr to convert from.
+ */
+ async calendarItemFromMessage(item, message) {
+ let folder = message.folder;
+ let msgUri = folder.getUriForMsg(message);
+
+ item.calendar = getSelectedCalendar();
+ item.title = message.mime2DecodedSubject;
+ item.setProperty("URL", `mid:${message.messageId}`);
+
+ cal.dtz.setDefaultStartEndHour(item);
+ cal.alarms.setDefaultValues(item);
+
+ let content = "";
+ await new Promise((resolve, reject) => {
+ let streamListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ onDataAvailable(request, inputStream, offset, count) {
+ let text = folder.getMsgTextFromStream(
+ inputStream,
+ message.charset,
+ count, // bytesToRead
+ 32768, // maxOutputLen
+ false, // compressQuotes
+ true, // stripHTMLTags
+ {} // out contentType
+ );
+ // If we ever got text, we're good. Ignore further chunks.
+ content ||= text;
+ },
+ onStartRequest(request) {},
+ onStopRequest(request, statusCode) {
+ if (!Components.isSuccessCode(statusCode)) {
+ reject(new Error(statusCode));
+ }
+ resolve();
+ },
+ };
+ MailServices.messageServiceFromURI(msgUri).streamMessage(
+ msgUri,
+ streamListener,
+ null,
+ null,
+ false,
+ "",
+ false
+ );
+ });
+ item.setProperty("DESCRIPTION", content);
+ },
+
+ /**
+ * Copy base item properties from aItem to aTarget. This includes properties
+ * like title, location, description, priority, transparency, attendees,
+ * categories, calendar, recurrence and possibly more.
+ *
+ * @param {object} aItem - The item to copy from.
+ * @param {object} aTarget - The item to copy to.
+ */
+ copyItemBase(aItem, aTarget) {
+ const copyProps = ["SUMMARY", "LOCATION", "DESCRIPTION", "URL", "CLASS", "PRIORITY"];
+
+ for (let prop of copyProps) {
+ aTarget.setProperty(prop, aItem.getProperty(prop));
+ }
+
+ // Attendees
+ let attendees = aItem.getAttendees();
+ for (let attendee of attendees) {
+ aTarget.addAttendee(attendee.clone());
+ }
+
+ // Categories
+ let categories = aItem.getCategories();
+ aTarget.setCategories(categories);
+
+ // Organizer
+ aTarget.organizer = aItem.organizer ? aItem.organizer.clone() : null;
+
+ // Calendar
+ aTarget.calendar = getSelectedCalendar();
+
+ // Recurrence
+ if (aItem.recurrenceInfo) {
+ aTarget.recurrenceInfo = aItem.recurrenceInfo.clone();
+ aTarget.recurrenceInfo.item = aTarget;
+ }
+ },
+
+ /**
+ * Creates a task from the passed event. This function copies the base item
+ * and a few event specific properties (dates, alarms, ...).
+ *
+ * @param {object} aEvent - The event to copy from.
+ * @returns {object} The resulting task.
+ */
+ taskFromEvent(aEvent) {
+ let item = new CalTodo();
+
+ this.copyItemBase(aEvent, item);
+
+ // Dates and alarms
+ if (!aEvent.startDate.isDate && !aEvent.endDate.isDate) {
+ // Dates
+ item.entryDate = aEvent.startDate.clone();
+ item.dueDate = aEvent.endDate.clone();
+
+ // Alarms
+ for (let alarm of aEvent.getAlarms()) {
+ item.addAlarm(alarm.clone());
+ }
+ item.alarmLastAck = aEvent.alarmLastAck ? aEvent.alarmLastAck.clone() : null;
+ }
+
+ // Map Status values
+ let statusMap = {
+ TENTATIVE: "NEEDS-ACTION",
+ CONFIRMED: "IN-PROCESS",
+ CANCELLED: "CANCELLED",
+ };
+ if (aEvent.getProperty("STATUS") in statusMap) {
+ item.setProperty("STATUS", statusMap[aEvent.getProperty("STATUS")]);
+ }
+ return item;
+ },
+
+ /**
+ * Creates an event from the passed task. This function copies the base item
+ * and a few task specific properties (dates, alarms, ...). If the task has
+ * no due date, the default event length is used.
+ *
+ * @param {object} aTask - The task to copy from.
+ * @returns {object} The resulting event.
+ */
+ eventFromTask(aTask) {
+ let item = new CalEvent();
+
+ this.copyItemBase(aTask, item);
+
+ // Dates and alarms
+ item.startDate = aTask.entryDate;
+ if (!item.startDate) {
+ if (aTask.dueDate) {
+ item.startDate = aTask.dueDate.clone();
+ item.startDate.minute -= Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ } else {
+ item.startDate = cal.dtz.getDefaultStartDate();
+ }
+ }
+
+ item.endDate = aTask.dueDate;
+ if (!item.endDate) {
+ // Make the event be the default event length if no due date was
+ // specified.
+ item.endDate = item.startDate.clone();
+ item.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ }
+
+ // Alarms
+ for (let alarm of aTask.getAlarms()) {
+ item.addAlarm(alarm.clone());
+ }
+ item.alarmLastAck = aTask.alarmLastAck ? aTask.alarmLastAck.clone() : null;
+
+ // Map Status values
+ let statusMap = {
+ "NEEDS-ACTION": "TENTATIVE",
+ COMPLETED: "CONFIRMED",
+ "IN-PROCESS": "CONFIRMED",
+ CANCELLED: "CANCELLED",
+ };
+ if (aTask.getProperty("STATUS") in statusMap) {
+ item.setProperty("STATUS", statusMap[aTask.getProperty("STATUS")]);
+ }
+ return item;
+ },
+ };
+
+ /**
+ * CalDNDTransferHandler provides a base class for handling drag and drop data
+ * transfers based on detected mime types. Actual processing of the dropped
+ * data is left up to CalDNDListener however children of this class mostly
+ * do some preprocessing first.
+ *
+ * The main methods here are the handleDataTransferItem() and handleString()
+ * methods that initiate transfer from a DataTransferItem or string
+ * respectively. Whether the data is passed as a DataTransferItem or string
+ * mostly depends on whether dropped from an external application or
+ * internally.
+ *
+ * @abstract
+ */
+ class CalDNDTransferHandler {
+ /**
+ * List of mime types this class handles (Overridden by child class).
+ *
+ * @type {string[]}
+ */
+ mimeTypes = [];
+
+ /**
+ * @param {CalDNDListener} listener - The listener that received the
+ * original drop event. Most CalDNDTransferHandlers will invoke a method on
+ * this class once data has been processed.
+ */
+ constructor(listener) {
+ this.listener = listener;
+ }
+
+ /**
+ * Returns true if the handler is able to process any of the given mime types.
+ *
+ * @param {string|string[]} mime - The mime type to handle.
+ *
+ * @returns {boolean}
+ */
+ willTransfer(mime) {
+ return Array.isArray(mime)
+ ? this.mimeTypes.find(type => mime.includes(type))
+ : this.mimeTypes.includes(mime);
+ }
+
+ /**
+ * Selects the most appropriate type from a list to use with mozGetDataAt().
+ *
+ * @param {string[]} types
+ *
+ * @returns {string?}
+ */
+ getMozType(types) {
+ return types.find(type => this.mimeTypes.includes(type));
+ }
+
+ /**
+ * Overridden by child classes that handle DataTransferItems. By default, no
+ * processing is done.
+ *
+ * @param {DataTransferItem} item
+ */
+ async handleDataTransferItem(item) {}
+
+ /**
+ * Overridden by child classes that handle string data. By default, no
+ * processing is done.
+ *
+ * @param {string} data
+ */
+ async handleString() {}
+ }
+
+ /**
+ * CalDNDMozMessageTransferHandler handles messages dropped from the
+ * message pane.
+ */
+ class CalDNDMozMessageTransferHandler extends CalDNDTransferHandler {
+ mimeTypes = ["text/x-moz-message"];
+
+ /**
+ * Treats the provided data as a message uri. Invokes the listener's
+ * onMessageDrop() method with the corresponding message header.
+ *
+ * @param {string} data
+ */
+ async handleString(data) {
+ let messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger);
+ this.listener.onDropMessage(messenger.msgHdrFromURI(data));
+ }
+ }
+
+ /**
+ * CalDNDAddressTransferHandler handles address book data internally dropped.
+ */
+ class CalDNDAddressTransferHandler extends CalDNDTransferHandler {
+ mimeTypes = ["text/x-moz-address"];
+
+ /**
+ * Invokes the listener's onDropAddress() method.
+ *
+ * @param {string} data
+ */
+ async handleString(data) {
+ this.listener.onDropAddress(data);
+ }
+ }
+
+ /**
+ * CalDNDDefaultTransferHandler serves as a "catch all" and should be included
+ * last in the list of handlers.
+ */
+ class CalDNDDefaultTransferHandler extends CalDNDTransferHandler {
+ willTransfer() {
+ return true;
+ }
+
+ /**
+ * If the dropped item is a file, it is treated as an event attachment,
+ * otherwise it is ignored.
+ *
+ * @param {DataTransferItem} item
+ */
+ async handleDataTransferItem(item) {
+ if (item.kind == "file") {
+ let path = item.getAsFile().mozFullPath;
+ if (path) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(path);
+
+ let uri = Services.io.newFileURI(file);
+ this.listener.onDropURL(uri);
+ }
+ }
+ }
+ }
+
+ /**
+ * CalDNDDirectTransferHandler provides a base class for CalDNDTransferHandlers
+ * that directly extract the contents of a DataTransferItem for processing.
+ *
+ * @abstract
+ */
+ class CalDNDDirectTransferHandler extends CalDNDTransferHandler {
+ /**
+ * Extracts the raw string data from a DataTransferItem before passing to
+ * handleString().
+ *
+ * @param {DataTransferItem} item
+ */
+ async handleDataTransferItem(item) {
+ if (item.kind == "string") {
+ let txt = await new Promise(resolve => item.getAsString(resolve));
+ await this.handleString(txt);
+ } else if (item.kind == "file") {
+ let txt = await item.getAsFile().text();
+ await this.handleString(txt);
+ }
+ }
+ }
+
+ /**
+ * CalDNDICSTransferHandler handles internal or external data in ICS format.
+ */
+ class CalDNDICSTransferHandler extends CalDNDDirectTransferHandler {
+ mimeTypes = ["text/calendar", "application/x-extension-ics"];
+
+ /**
+ * Parses the provided data as an ICS string before invoking the listener's
+ * onDropItems() method.
+ *
+ * @param {string} data
+ */
+ async handleString(data) {
+ if (AppConstants.platform == "macosx") {
+ // Mac likes to convert all \r to \n, we need to reverse this.
+ data = data.replace(/\n\n/g, "\r\n");
+ }
+
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(data);
+ this.listener.onDropItems(parser.getItems().concat(parser.getParentlessItems()));
+ }
+ }
+
+ /**
+ * CalDNDURLTransferHandler handles urls (dropped internally or externally).
+ */
+ class CalDNDURLTransferHandler extends CalDNDDirectTransferHandler {
+ mimeTypes = ["text/uri-list", "text/x-moz-url"];
+
+ _icsFilename = /filename=.*\.ics/;
+
+ /**
+ * Treats the provided data as a url. If we determine it is a url to an
+ * ICS file, we delegate to the "text/calendar" handler. The listener's
+ * onDropURL method is invoked otherwise.
+ *
+ * @param {string} data
+ */
+ async handleString(data) {
+ data = data.split("\n")[0];
+ if (!data) {
+ return;
+ }
+
+ let uri = Services.io.newURI(data);
+
+ // Below we attempt to detect ics files dropped from the message pane's
+ // attachment list. These will appear as uris rather than file blobs so we
+ // check the "filename" query parameter for a .ics extension.
+ if (this._icsFilename.test(uri.query)) {
+ let url = uri.mutate().setUsername("").setUserPass("").finalize().spec;
+
+ let resp = await fetch(new Request(url, { method: "GET" }));
+ let txt = await resp.text();
+ await this.listener.getHandler("text/calendar").handleString(txt);
+ } else {
+ this.listener.onDropURL(uri);
+ }
+ }
+ }
+
+ /**
+ * CalDNDPlainTextTransferHandler handles text/plain transfers coming mainly
+ * from internally dropped text.
+ */
+ class CalDNDPlainTextTransferHandler extends CalDNDDirectTransferHandler {
+ mimeTypes = ["text/plain"];
+
+ _keyWords = ["VEVENT", "VTODO", "VCALENDAR"];
+
+ _isICS(data) {
+ return this._keyWords.some(kwrd => data.includes(kwrd));
+ }
+
+ /**
+ * Treats the data provided as an uri to an .ics file and attempts to parse
+ * its contents. If we detect calendar data however, we delegate to the
+ * "text/calendar" handler.
+ *
+ * @param {string} data
+ */
+ async handleString(data) {
+ if (this._isICS(data)) {
+ this.listener.getHandler("text/calendar").handleString(data);
+ return;
+ }
+
+ let droppedUrl = data.split("\n")[0];
+ if (!droppedUrl) {
+ return;
+ }
+
+ let url = Services.io.newURI(droppedUrl);
+
+ let localFileInstance = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ localFileInstance.initWithPath(url.pathQueryRef);
+
+ let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ inputStream.init(localFileInstance, MODE_RDONLY, parseInt("0444", 8), {});
+
+ try {
+ let importer = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter);
+ let items = importer.importFromStream(inputStream);
+ this.onDropItems(items);
+ } finally {
+ inputStream.close();
+ }
+ }
+ }
+
+ /**
+ * This is the base class for calendar drag and drop listeners.
+ */
+ class CalDNDListener {
+ /**
+ * Limits the number of items to process from a drop operation. In the
+ * future, this could be removed in favour of better UI for bulk operations.
+ *
+ * @type {number}
+ */
+ maxItemsTransferred = 8;
+
+ /**
+ * A list of CalDNDTransferHandlers for all of the supported mime types.
+ * The order of this list is important as it dictates which types will be
+ * selected first.
+ *
+ * @type {CalDNDTransferHandler[]}
+ */
+ mimeHandlers = [
+ new CalDNDICSTransferHandler(this),
+ new CalDNDMozMessageTransferHandler(this),
+ new CalDNDAddressTransferHandler(this),
+ new CalDNDURLTransferHandler(this),
+ new CalDNDPlainTextTransferHandler(this),
+ new CalDNDDefaultTransferHandler(this),
+ ];
+
+ /**
+ * Provides the most suitable handler for the type or one of the types of a
+ * list.
+ *
+ * @param {string|string[]} mime
+ *
+ * @returns {CalDNDTransferHandler}
+ */
+ getHandler(mime) {
+ return this.mimeHandlers.find(handler => handler.willTransfer(mime));
+ }
+
+ /**
+ * Prevents the browser's default behaviour when an item is dragged over the
+ * drop target.
+ *
+ * @param {Event} event
+ */
+ onDragOver(event) {
+ event.preventDefault();
+ }
+
+ /**
+ * Handles calendar event items.
+ *
+ * @param {calIItemBase[]} items
+ */
+ onDropItems() {}
+
+ /**
+ * Handles mail messages.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ */
+ onDropMessage() {}
+
+ /**
+ * Handles address book data.
+ */
+ onDropAddress() {}
+
+ /**
+ * Handles the drop event. The items property of DataTransfer can be
+ * interpreted differently depending on whether the drop is coming from an
+ * internal or external source (really its up to whatever is sending the
+ * data to decide what the transfer entails).
+ *
+ * Mozilla seems to treat it as alternative formats for the data being
+ * sent while external/other applications may only have one data transfer
+ * item per single thing dropped. The item's interface seems to have
+ * more accurate mime types than the ones of mozTypesAt() so working with
+ * those are preferable however not always possible.
+ *
+ * This method tries to determine which of the APIs is more appropriate for
+ * processing the drop. It does that by checking for a source node or a
+ * difference between length of DataTransfer.items and DataTransfer
+ * .mozItemCount.
+ *
+ * Note: While testing, it was noticed that dragging text from an external
+ * application shows up erroneously as a file in DataTransfer.items. This is
+ * dealt with too.
+ *
+ * @param {Event} event
+ */
+ async onDrop(event) {
+ let { dataTransfer } = event;
+
+ // No mozSourceNode means it's an external drop, however if the drop is
+ // coming from Firefox then we can expect the same behaviour as done
+ // internally. Generally there may be more DataTransferItems than
+ // mozItemCount indicates.
+ let isInternal =
+ dataTransfer.mozSourceNode || dataTransfer.items.length != dataTransfer.mozItemCount;
+
+ // For the strange case of copied text having the "file" kind, the files
+ // property will have a length of zero.
+ let actualFiles = Array.from(dataTransfer.items).filter(i => i.kind == "file").length;
+ let isExternalText = actualFiles != dataTransfer.files.length;
+
+ if (isInternal || isExternalText) {
+ await this.onInternalDrop(dataTransfer);
+ } else {
+ await this.onExternalDrop(dataTransfer);
+ }
+ }
+
+ /**
+ * This method is intended for use when the drop event originates internally.
+ *
+ * @param {DataTransfer} dataTransfer
+ */
+ async onInternalDrop(dataTransfer) {
+ for (let i = 0; i < dataTransfer.mozItemCount; i++) {
+ if (i == this.maxItemsTransferred) {
+ break;
+ }
+
+ let types = Array.from(dataTransfer.mozTypesAt(i));
+ let handler = this.getHandler(types);
+ let data = dataTransfer.mozGetDataAt(handler.getMozType(types), i);
+
+ if (typeof data == "string") {
+ await handler.handleString(data);
+ }
+ }
+ }
+
+ /**
+ * This method is intended for use when the drop event originates externally.
+ *
+ * @param {DataTransfer} dataTransfer
+ */
+ async onExternalDrop(dataTransfer) {
+ let i = 0;
+ for (let item of dataTransfer.items) {
+ if (i == this.maxItemsTransferred) {
+ break;
+ }
+
+ let handler = this.getHandler(item.type);
+ await handler.handleDataTransferItem(item, i, dataTransfer);
+ i++;
+ }
+ }
+ }
+
+ /**
+ * Drag'n'drop handler for the calendar views.
+ */
+ class CalViewDNDObserver extends CalDNDListener {
+ wrappedJSObject = this;
+
+ /**
+ * Gets called in case we're dropping an array of items on one of the
+ * calendar views. In this case we just try to add these items to the
+ * currently selected calendar.
+ *
+ * @param {calIItemBase[]} items
+ */
+ onDropItems(items) {
+ let destCal = getSelectedCalendar();
+ startBatchTransaction();
+ // we fall back explicitly to the popup to ask whether to send a
+ // notification to participants if required
+ let extResp = { responseMode: Ci.calIItipItem.USER };
+ try {
+ for (let item of items) {
+ doTransaction("add", item, destCal, null, null, extResp);
+ }
+ } finally {
+ endBatchTransaction();
+ }
+ }
+ }
+
+ /**
+ * Drag'n'drop handler for the 'mail mode'-button. This handler is derived
+ * from the base handler and just implements specific actions.
+ */
+ class CalMailButtonDNDObserver extends CalDNDListener {
+ wrappedJSObject = this;
+
+ /**
+ * Gets called in case we're dropping an array of items on the
+ * 'mail mode'-button.
+ *
+ * @param {calIItemBase[]} items
+ */
+ onDropItems(items) {
+ if (items && items.length > 0) {
+ let item = items[0];
+ let identity = item.calendar.getProperty("imip.identity");
+ let parties = item.getAttendees();
+ if (item.organizer) {
+ parties.push(item.organizer);
+ }
+ if (identity) {
+ // if no identity is defined, the composer will fall back to
+ // whatever seems suitable - in this case we don't try to remove
+ // the sender from the recipient list
+ identity = identity.QueryInterface(Ci.nsIMsgIdentity);
+ parties = parties.filter(aParty => {
+ return identity.email != cal.email.getAttendeeEmail(aParty, false);
+ });
+ }
+ let recipients = cal.email.createRecipientList(parties);
+ cal.email.sendTo(recipients, item.title, item.getProperty("DESCRIPTION"), identity);
+ }
+ }
+ }
+
+ /**
+ * Drag'n'drop handler for the 'open calendar tab'-button. This handler is
+ * derived from the base handler and just implements specific actions.
+ */
+ class CalCalendarButtonObserver extends CalDNDListener {
+ wrappedJSObject = this;
+
+ /**
+ * Gets called in case we're dropping an array of items
+ * on the 'open calendar tab'-button.
+ *
+ * @param {calIItemBase[]} items
+ */
+ onDropItems(items) {
+ for (let item of items) {
+ let newItem = item;
+ if (item.isTodo()) {
+ newItem = itemConversion.eventFromTask(item);
+ }
+ createEventWithDialog(null, null, null, null, newItem);
+ }
+ }
+
+ /**
+ * Gets called in case we're dropping a message on the 'open calendar tab'-
+ * button. In this case we create a new event from the mail. We open the
+ * default event dialog and just use the subject of the message as the event
+ * title.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ */
+ async onDropMessage(msgHdr) {
+ let newItem = new CalEvent();
+ await itemConversion.calendarItemFromMessage(newItem, msgHdr);
+ createEventWithDialog(null, null, null, null, newItem);
+ }
+
+ /**
+ * Gets called in case we're dropping a uri on the 'open calendar tab'-
+ * button.
+ *
+ * @param {nsIURI} uri
+ */
+ onDropURL(uri) {
+ let newItem = new CalEvent();
+ newItem.calendar = getSelectedCalendar();
+ cal.dtz.setDefaultStartEndHour(newItem);
+ cal.alarms.setDefaultValues(newItem);
+ let attachment = new CalAttachment();
+ attachment.uri = uri;
+ newItem.addAttachment(attachment);
+ createEventWithDialog(null, null, null, null, newItem);
+ }
+
+ /**
+ * Gets called in case we're dropping addresses on the 'open calendar tab'
+ * -button.
+ *
+ * @param {string} addresses
+ */
+ onDropAddress(addresses) {
+ let parsedInput = MailServices.headerParser.makeFromDisplayAddress(addresses);
+ let attendee = new CalAttendee();
+ attendee.id = "";
+ attendee.rsvp = "TRUE";
+ attendee.role = "REQ-PARTICIPANT";
+ attendee.participationStatus = "NEEDS-ACTION";
+ let attendees = parsedInput
+ .filter(address => address.name.length > 0)
+ .map((address, index) => {
+ // Convert address to attendee.
+ if (index > 0) {
+ attendee = attendee.clone();
+ }
+ attendee.id = cal.email.prependMailTo(address.email);
+ let commonName = null;
+ if (address.name.length > 0) {
+ // We remove any double quotes within CN due to bug 1209399.
+ let name = address.name.replace(/(?:(?:[\\]")|(?:"))/g, "");
+ if (address.email != name) {
+ commonName = name;
+ }
+ }
+ attendee.commonName = commonName;
+ return attendee;
+ });
+ let newItem = new CalEvent();
+ newItem.calendar = getSelectedCalendar();
+ cal.dtz.setDefaultStartEndHour(newItem);
+ cal.alarms.setDefaultValues(newItem);
+ for (let attendee of attendees) {
+ newItem.addAttendee(attendee);
+ }
+ createEventWithDialog(null, null, null, null, newItem);
+ }
+ }
+
+ /**
+ * Drag'n'drop handler for the 'open tasks tab'-button. This handler is
+ * derived from the base handler and just implements specific actions.
+ */
+ class CalTaskButtonObserver extends CalDNDListener {
+ wrappedJSObject = this;
+
+ /**
+ * Gets called in case we're dropping an array of items on the
+ * 'open tasks tab'-button.
+ *
+ * @param {object} items - An array of items to handle.
+ */
+ onDropItems(items) {
+ for (let item of items) {
+ let newItem = item;
+ if (item.isEvent()) {
+ newItem = itemConversion.taskFromEvent(item);
+ }
+ createTodoWithDialog(null, null, null, newItem);
+ }
+ }
+
+ /**
+ * Gets called in case we're dropping a message on the 'open tasks tab'
+ * -button.
+ *
+ * @param {nsIMsgHdr} msgHdr
+ */
+ async onDropMessage(msgHdr) {
+ let todo = new CalTodo();
+ await itemConversion.calendarItemFromMessage(todo, msgHdr);
+ createTodoWithDialog(null, null, null, todo);
+ }
+
+ /**
+ * Gets called in case we're dropping a uri on the 'open tasks tab'-button.
+ *
+ * @param {nsIURI} uri
+ */
+ onDropURL(uri) {
+ let todo = new CalTodo();
+ todo.calendar = getSelectedCalendar();
+ cal.dtz.setDefaultStartEndHour(todo);
+ cal.alarms.setDefaultValues(todo);
+ let attachment = new CalAttachment();
+ attachment.uri = uri;
+ todo.addAttachment(attachment);
+ createTodoWithDialog(null, null, null, todo);
+ }
+ }
+
+ calendarViewDNDObserver = new CalViewDNDObserver();
+ calendarMailButtonDNDObserver = new CalMailButtonDNDObserver();
+ calendarCalendarButtonDNDObserver = new CalCalendarButtonObserver();
+ calendarTaskButtonDNDObserver = new CalTaskButtonObserver();
+}
+
+/**
+ * Invoke a drag session for the passed item. The passed box will be used as a
+ * source.
+ *
+ * @param {object} aItem - The item to drag.
+ * @param {object} aXULBox - The XUL box to invoke the drag session from.
+ */
+function invokeEventDragSession(aItem, aXULBox) {
+ let transfer = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+ transfer.init(null);
+ transfer.addDataFlavor("text/calendar");
+
+ let flavourProvider = {
+ QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]),
+
+ item: aItem,
+ getFlavorData(aInTransferable, aInFlavor, aOutData) {
+ if (
+ aInFlavor == "application/vnd.x-moz-cal-event" ||
+ aInFlavor == "application/vnd.x-moz-cal-task"
+ ) {
+ aOutData.value = aItem;
+ } else {
+ cal.ASSERT(false, "error:" + aInFlavor);
+ }
+ },
+ };
+
+ if (aItem.isEvent()) {
+ transfer.addDataFlavor("application/vnd.x-moz-cal-event");
+ transfer.setTransferData("application/vnd.x-moz-cal-event", flavourProvider);
+ } else if (aItem.isTodo()) {
+ transfer.addDataFlavor("application/vnd.x-moz-cal-task");
+ transfer.setTransferData("application/vnd.x-moz-cal-task", flavourProvider);
+ }
+
+ // Also set some normal data-types, in case we drag into another app
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem]);
+
+ let supportsString = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ supportsString.data = serializer.serializeToString();
+ transfer.setTransferData("text/calendar", supportsString);
+ transfer.setTransferData("text/plain", supportsString);
+
+ let action = Ci.nsIDragService.DRAGDROP_ACTION_MOVE;
+ let mutArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ mutArray.appendElement(transfer);
+ aXULBox.sourceObject = aItem;
+ try {
+ cal.dragService.invokeDragSession(aXULBox, null, null, null, mutArray, action);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ // Pressing Escape on some platforms results in NS_ERROR_FAILURE
+ // being thrown. Catch this exception, but throw anything else.
+ throw e;
+ }
+ }
+}
diff --git a/comm/calendar/base/content/calendar-editable-item.js b/comm/calendar/base/content/calendar-editable-item.js
new file mode 100644
index 0000000000..e45570c995
--- /dev/null
+++ b/comm/calendar/base/content/calendar-editable-item.js
@@ -0,0 +1,464 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements, MozXULElement, onMouseOverItem, invokeEventDragSession */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ /**
+ * The MozCalendarEditableItem widget is used as a full day event item in the
+ * Day and Week views of the calendar. It displays the event name, alarm icon
+ * and the category type color. It gets displayed in the header container of
+ * the respective view of the calendar.
+ *
+ * @augments MozXULElement
+ */
+ class MozCalendarEditableItem extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".alarm-icons-box": "flashing",
+ };
+ }
+ constructor() {
+ super();
+
+ this.mOccurrence = null;
+
+ this.mSelected = false;
+
+ this.mCalendarView = null;
+
+ this.addEventListener(
+ "contextmenu",
+ event => {
+ // If the middle/right button was used for click just select the item.
+ if (!this.selected) {
+ this.select(event);
+ }
+ },
+ true
+ );
+
+ this.addEventListener("click", event => {
+ if (event.button != 0 || this.mEditing) {
+ return;
+ }
+
+ // If the left button was used and the item is already selected
+ // and there are no multiple items selected start
+ // the 'single click edit' timeout. Otherwise select the item too.
+ // Also, check if the calendar is readOnly or we are offline.
+
+ if (
+ this.selected &&
+ !(event.ctrlKey || event.metaKey) &&
+ cal.acl.isCalendarWritable(this.mOccurrence.calendar) &&
+ !cal.itip.isInvitation(this.mOccurrence)
+ ) {
+ if (this.editingTimer) {
+ clearTimeout(this.editingTimer);
+ }
+ this.editingTimer = setTimeout(() => this.startEditing(), 350);
+ } else {
+ this.select(event);
+ if (!this.closest("richlistitem")) {
+ event.stopPropagation();
+ }
+ }
+ });
+
+ this.addEventListener("dblclick", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ // Stop 'single click edit' timeout (if started).
+ if (this.editingTimer) {
+ clearTimeout(this.editingTimer);
+ this.editingTimer = null;
+ }
+
+ if (this.calendarView && this.calendarView.controller) {
+ let item = event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence;
+ if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) {
+ this.calendarView.controller.modifyOccurrence(item);
+ return;
+ }
+ this.calendarView.controller.viewOccurrence(item);
+ }
+ });
+
+ this.addEventListener("mouseover", event => {
+ if (this.calendarView && this.calendarView.controller) {
+ event.stopPropagation();
+ onMouseOverItem(event);
+ }
+ });
+
+ // We have two event listeners for dragstart. This event listener is for the bubbling phase.
+ this.addEventListener("dragstart", event => {
+ if (document.monthDragEvent?.localName == "calendar-event-box") {
+ return;
+ }
+ let item = this.occurrence;
+ let isInvitation =
+ item.calendar instanceof Ci.calISchedulingSupport && item.calendar.isInvitation(item);
+ if (
+ !cal.acl.isCalendarWritable(item.calendar) ||
+ !cal.acl.userCanModifyItem(item) ||
+ isInvitation
+ ) {
+ return;
+ }
+ if (!this.selected) {
+ this.select(event);
+ }
+ invokeEventDragSession(item, this);
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <html:div class="calendar-item-flex">
+ <html:img class="item-type-icon" alt="" />
+ <html:div class="event-name-label"></html:div>
+ <html:input class="plain event-name-input"
+ hidden="hidden"
+ placeholder='${cal.l10n.getCalString("newEvent")}'/>
+ <html:div class="alarm-icons-box"></html:div>
+ <html:img class="item-classification-icon" />
+ <html:img class="item-recurrence-icon" />
+ </html:div>
+ <html:div class="location-desc"></html:div>
+ <html:div class="calendar-category-box"></html:div>
+ `)
+ );
+
+ this.classList.add("calendar-color-box", "calendar-item-container");
+
+ // We have two event listeners for dragstart. This event listener is for the capturing phase
+ // where we are setting up the document.monthDragEvent which will be used in the event listener
+ // in the bubbling phase.
+ this.addEventListener(
+ "dragstart",
+ event => {
+ document.monthDragEvent = this;
+ },
+ true
+ );
+
+ this.style.pointerEvents = "auto";
+ this.setAttribute("tooltip", "itemTooltip");
+ this.setAttribute("tabindex", "-1");
+ this.addEventNameTextboxListener();
+ this.initializeAttributeInheritance();
+ }
+
+ set parentBox(val) {
+ this.mParentBox = val;
+ }
+
+ get parentBox() {
+ return this.mParentBox;
+ }
+
+ set selected(val) {
+ if (val && !this.mSelected) {
+ this.mSelected = true;
+ this.setAttribute("selected", "true");
+ this.focus();
+ } else if (!val && this.mSelected) {
+ this.mSelected = false;
+ this.removeAttribute("selected");
+ this.blur();
+ }
+ }
+
+ get selected() {
+ return this.mSelected;
+ }
+
+ set calendarView(val) {
+ this.mCalendarView = val;
+ }
+
+ get calendarView() {
+ return this.mCalendarView;
+ }
+
+ set occurrence(val) {
+ this.mOccurrence = val;
+ this.setEditableLabel();
+ this.setLocationLabel();
+ this.setCSSClasses();
+ }
+
+ get occurrence() {
+ return this.mOccurrence;
+ }
+
+ get eventNameLabel() {
+ return this.querySelector(".event-name-label");
+ }
+
+ get eventNameTextbox() {
+ return this.querySelector(".event-name-input");
+ }
+
+ addEventNameTextboxListener() {
+ let stopPropagationIfEditing = event => {
+ if (this.mEditing) {
+ event.stopPropagation();
+ }
+ };
+ // While editing, single click positions cursor, so don't propagate.
+ this.eventNameTextbox.onclick = stopPropagationIfEditing;
+ // While editing, double click selects words, so don't propagate.
+ this.eventNameTextbox.ondblclick = stopPropagationIfEditing;
+ // While editing, don't propagate mousedown/up (selects calEvent).
+ this.eventNameTextbox.onmousedown = stopPropagationIfEditing;
+ this.eventNameTextbox.onmouseup = stopPropagationIfEditing;
+ this.eventNameTextbox.onblur = () => {
+ this.stopEditing(true);
+ };
+ this.eventNameTextbox.onkeypress = event => {
+ if (event.key == "Enter") {
+ // Save on enter.
+ this.stopEditing(true);
+ } else if (event.key == "Escape") {
+ // Abort on escape.
+ this.stopEditing(false);
+ }
+ };
+ }
+
+ setEditableLabel() {
+ let label = this.eventNameLabel;
+ let item = this.mOccurrence;
+ label.textContent = item.title
+ ? item.title.replace(/\n/g, " ")
+ : cal.l10n.getCalString("eventUntitled");
+ }
+
+ setLocationLabel() {
+ let locationLabel = this.querySelector(".location-desc");
+ let location = this.mOccurrence.getProperty("LOCATION");
+ let showLocation = Services.prefs.getBoolPref("calendar.view.showLocation", false);
+
+ locationLabel.textContent = showLocation && location ? location : "";
+ locationLabel.hidden = !showLocation || !location;
+ }
+
+ setCSSClasses() {
+ let item = this.mOccurrence;
+ let cssSafeId = cal.view.formatStringForCSSRule(item.calendar.id);
+ this.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeId}-backcolor)`);
+ this.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeId}-forecolor)`);
+ let categoriesBox = this.querySelector(".calendar-category-box");
+
+ let categoriesArray = item.getCategories().map(cal.view.formatStringForCSSRule);
+ // Find the first category with a colour.
+ let firstCategory = categoriesArray.find(
+ category => Services.prefs.getStringPref("calendar.category.color." + category, "") != ""
+ );
+ if (firstCategory) {
+ categoriesBox.hidden = false;
+ categoriesBox.style.backgroundColor = `var(--category-${firstCategory}-color)`;
+ } else {
+ categoriesBox.hidden = true;
+ }
+
+ // Add alarm icons as needed.
+ let alarms = item.getAlarms();
+ if (alarms.length && Services.prefs.getBoolPref("calendar.alarms.indicator.show", true)) {
+ let iconsBox = this.querySelector(".alarm-icons-box");
+ // Set suppressed status on the icons box.
+ iconsBox.toggleAttribute("suppressed", item.calendar.getProperty("suppressAlarms"));
+
+ cal.alarms.addReminderImages(iconsBox, alarms);
+ }
+
+ // Item classification / privacy.
+ let classificationIcon = this.querySelector(".item-classification-icon");
+ if (classificationIcon) {
+ switch (item.privacy) {
+ case "PRIVATE":
+ classificationIcon.setAttribute(
+ "src",
+ "chrome://calendar/skin/shared/icons/private.svg"
+ );
+ // Set the alt attribute.
+ document.l10n.setAttributes(
+ classificationIcon,
+ "calendar-editable-item-privacy-icon-private"
+ );
+ break;
+ case "CONFIDENTIAL":
+ classificationIcon.setAttribute(
+ "src",
+ "chrome://calendar/skin/shared/icons/confidential.svg"
+ );
+ // Set the alt attribute.
+ document.l10n.setAttributes(
+ classificationIcon,
+ "calendar-editable-item-privacy-icon-confidential"
+ );
+ break;
+ default:
+ classificationIcon.removeAttribute("src");
+ classificationIcon.removeAttribute("data-l10n-id");
+ classificationIcon.setAttribute("alt", "");
+ break;
+ }
+ }
+
+ let recurrenceIcon = this.querySelector(".item-recurrence-icon");
+ if (item.parentItem != item && item.parentItem.recurrenceInfo) {
+ if (item.parentItem.recurrenceInfo.getExceptionFor(item.recurrenceId)) {
+ recurrenceIcon.setAttribute(
+ "src",
+ "chrome://messenger/skin/icons/new/recurrence-exception.svg"
+ );
+ document.l10n.setAttributes(
+ recurrenceIcon,
+ "calendar-editable-item-recurrence-exception"
+ );
+ } else {
+ recurrenceIcon.setAttribute("src", "chrome://messenger/skin/icons/new/recurrence.svg");
+ document.l10n.setAttributes(recurrenceIcon, "calendar-editable-item-recurrence");
+ }
+ recurrenceIcon.hidden = false;
+ } else {
+ recurrenceIcon.removeAttribute("src");
+ recurrenceIcon.removeAttribute("data-l10n-id");
+ recurrenceIcon.setAttribute("alt", "");
+ recurrenceIcon.hidden = true;
+ }
+
+ // Event type specific properties.
+ if (item.isEvent() && item.startDate.isDate) {
+ this.setAttribute("allday", "true");
+ }
+ if (item.isTodo()) {
+ let icon = this.querySelector(".item-type-icon");
+ if (cal.item.getProgressAtom(item) === "completed") {
+ icon.setAttribute("src", "chrome://calendar/skin/shared/todo-complete.svg");
+ document.l10n.setAttributes(icon, "calendar-editable-item-todo-icon-completed-task");
+ } else {
+ icon.setAttribute("src", "chrome://calendar/skin/shared/todo.svg");
+ document.l10n.setAttributes(icon, "calendar-editable-item-todo-icon-task");
+ }
+ }
+
+ if (this.calendarView && item.hashId in this.calendarView.mFlashingEvents) {
+ this.setAttribute("flashing", "true");
+ }
+
+ if (alarms.length) {
+ this.setAttribute("alarm", "true");
+ }
+
+ // Priority.
+ if (item.priority > 0 && item.priority < 5) {
+ this.setAttribute("priority", "high");
+ } else if (item.priority > 5 && item.priority < 10) {
+ this.setAttribute("priority", "low");
+ }
+
+ // Status attribute.
+ if (item.status) {
+ this.setAttribute("status", item.status.toUpperCase());
+ }
+
+ // Item class.
+ if (item.hasProperty("CLASS")) {
+ this.setAttribute("itemclass", item.getProperty("CLASS"));
+ }
+
+ // Calendar name.
+ this.setAttribute("calendar", item.calendar.name.toLowerCase());
+
+ // Invitation.
+ if (cal.itip.isInvitation(item)) {
+ this.setAttribute(
+ "invitation-status",
+ cal.itip.getInvitedAttendee(item).participationStatus
+ );
+ }
+ }
+
+ startEditing() {
+ this.editingTimer = null;
+ this.mOriginalTextLabel = this.mOccurrence.title;
+
+ this.eventNameLabel.hidden = true;
+
+ this.mEditing = true;
+
+ this.eventNameTextbox.value = this.mOriginalTextLabel;
+ this.eventNameTextbox.hidden = false;
+ this.eventNameTextbox.focus();
+ }
+
+ get isEditing() {
+ return this.mEditing || false;
+ }
+
+ select(event) {
+ if (!this.calendarView) {
+ return;
+ }
+ let items = this.calendarView.mSelectedItems.slice();
+ if (event.ctrlKey || event.metaKey) {
+ if (this.selected) {
+ let pos = items.indexOf(this.mOccurrence);
+ items.splice(pos, 1);
+ } else {
+ items.push(this.mOccurrence);
+ }
+ } else {
+ items = [this.mOccurrence];
+ }
+ this.calendarView.setSelectedItems(items);
+ }
+
+ stopEditing(saveChanges) {
+ if (!this.mEditing) {
+ return;
+ }
+
+ this.mEditing = false;
+
+ if (saveChanges && this.eventNameTextbox.value != this.mOriginalTextLabel) {
+ this.calendarView.controller.modifyOccurrence(
+ this.mOccurrence,
+ null,
+ null,
+ this.eventNameTextbox.value || cal.l10n.getCalString("eventUntitled")
+ );
+
+ // Note that as soon as we do the modifyItem, this element ceases to exist,
+ // so don't bother trying to modify anything further here! ('this' exists,
+ // because it's being kept alive, but our child content etc. is all gone).
+ return;
+ }
+
+ this.eventNameTextbox.hidden = true;
+ this.eventNameLabel.hidden = false;
+ }
+ }
+
+ MozElements.MozCalendarEditableItem = MozCalendarEditableItem;
+
+ customElements.define("calendar-editable-item", MozCalendarEditableItem);
+}
diff --git a/comm/calendar/base/content/calendar-extract.js b/comm/calendar/base/content/calendar-extract.js
new file mode 100644
index 0000000000..de021cb042
--- /dev/null
+++ b/comm/calendar/base/content/calendar-extract.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals getMessagePaneBrowser, addMenuItem, getSelectedCalendar
+ createEventWithDialog*/
+
+var { Extractor } = ChromeUtils.import("resource:///modules/calendar/calExtract.jsm");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "extractService", () => {
+ const { CalExtractParserService } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParserService.jsm"
+ );
+ return new CalExtractParserService();
+});
+
+var calendarExtract = {
+ onShowLocaleMenu(target) {
+ let localeList = document.getElementById(target.id);
+ let langs = [];
+ let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"]
+ .getService(Ci.nsIXULChromeRegistry)
+ .QueryInterface(Ci.nsIToolkitChromeRegistry);
+ let langRegex = /^(([^-]+)-*(.*))$/;
+
+ for (let locale of chrome.getLocalesForPackage("calendar")) {
+ let localeParts = langRegex.exec(locale);
+ let langName = localeParts[2];
+
+ try {
+ langName = cal.l10n.getAnyString("global", "languageNames", langName);
+ } catch (ex) {
+ // If no language name is found that is ok, keep the technical term
+ }
+
+ let label = cal.l10n.getCalString("extractUsing", [langName]);
+ if (localeParts[3] != "") {
+ label = cal.l10n.getCalString("extractUsingRegion", [langName, localeParts[3]]);
+ }
+
+ langs.push([label, localeParts[1]]);
+ }
+
+ // sort
+ let pref = "calendar.patterns.last.used.languages";
+ let lastUsedLangs = Services.prefs.getStringPref(pref, "");
+
+ langs.sort((a, b) => {
+ let idx_a = lastUsedLangs.indexOf(a[1]);
+ let idx_b = lastUsedLangs.indexOf(b[1]);
+
+ if (idx_a == -1 && idx_b == -1) {
+ return a[0].localeCompare(b[0]);
+ } else if (idx_a != -1 && idx_b != -1) {
+ return idx_a - idx_b;
+ } else if (idx_a == -1) {
+ return 1;
+ }
+ return -1;
+ });
+ while (localeList.lastChild) {
+ localeList.lastChild.remove();
+ }
+
+ for (let lang of langs) {
+ addMenuItem(localeList, lang[0], lang[1], null);
+ }
+ },
+
+ extractWithLocale(event, isEvent) {
+ event.stopPropagation();
+ let locale = event.target.value;
+ this.extractFromEmail(null, isEvent, true, locale);
+ },
+
+ async extractFromEmail(message, isEvent, fixedLang, fixedLocale) {
+ let folder = message.folder;
+ let title = message.mime2DecodedSubject;
+
+ let content = "";
+ await new Promise((resolve, reject) => {
+ let listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ onDataAvailable(request, inputStream, offset, count) {
+ let text = folder.getMsgTextFromStream(
+ inputStream,
+ message.charset,
+ count, // bytesToRead
+ 32768, // maxOutputLen
+ false, // compressQuotes
+ true, // stripHTMLTags
+ {} // out contentType
+ );
+ // If we ever got text, we're good. Ignore further chunks.
+ content ||= text;
+ },
+ onStartRequest(request) {},
+ onStopRequest(request, statusCode) {
+ if (!Components.isSuccessCode(statusCode)) {
+ reject(new Error(statusCode));
+ }
+ resolve();
+ },
+ };
+ let uri = message.folder.getUriForMsg(message);
+ MailServices.messageServiceFromURI(uri).streamMessage(uri, listener, null, null, false, "");
+ });
+
+ cal.LOG("[calExtract] Original email content: \n" + title + "\r\n" + content);
+ let date = new Date(message.date / 1000);
+ let time = new Date().getTime();
+
+ let item = isEvent ? new CalEvent() : new CalTodo();
+ item.title = message.mime2DecodedSubject;
+ item.calendar = getSelectedCalendar();
+ item.setProperty("DESCRIPTION", content);
+ item.setProperty("URL", `mid:${message.messageId}`);
+ cal.dtz.setDefaultStartEndHour(item);
+ cal.alarms.setDefaultValues(item);
+ let tabmail = document.getElementById("tabmail");
+ let messagePaneBrowser =
+ tabmail?.currentTabInfo.chromeBrowser.contentWindow.visibleMessagePaneBrowser?.() ||
+ tabmail?.currentAboutMessage?.getMessagePaneBrowser() ||
+ document.getElementById("messageBrowser")?.contentWindow?.getMessagePaneBrowser();
+ let sel = messagePaneBrowser?.contentWindow?.getSelection();
+ // Check if there's an iframe with a selection (e.g. Thunderbird Conversations)
+ if (sel && sel.type !== "Range") {
+ try {
+ sel = messagePaneBrowser?.contentDocument
+ .querySelector("iframe")
+ .contentDocument.getSelection();
+ } catch (ex) {
+ // If Thunderbird Conversations is not installed that is fine,
+ // we will just have an empty or null selection.
+ }
+ }
+
+ let guessed;
+ let endGuess;
+ let extractor;
+ let collected = [];
+ let useService = Services.prefs.getBoolPref("calendar.extract.service.enabled");
+ if (useService) {
+ let result = extractService.extract(content, { now: date });
+ if (!result) {
+ useService = false;
+ } else {
+ guessed = result.startTime;
+ endGuess = result.endTime;
+ }
+ }
+
+ if (!useService) {
+ let locale = Services.locale.requestedLocale;
+ let dayStart = Services.prefs.getIntPref("calendar.view.daystarthour", 6);
+ if (fixedLang) {
+ extractor = new Extractor(fixedLocale, dayStart);
+ } else {
+ extractor = new Extractor(locale, dayStart, false);
+ }
+ collected = extractor.extract(title, content, date, sel);
+ }
+
+ // if we only have email date then use default start and end
+ if (!useService && collected.length <= 1) {
+ cal.LOG("[calExtract] Date and time information was not found in email/selection.");
+ createEventWithDialog(null, null, null, null, item);
+ } else {
+ if (!useService) {
+ guessed = extractor.guessStart(!isEvent);
+ endGuess = extractor.guessEnd(guessed, !isEvent);
+ }
+ let allDay = (guessed.hour == null || guessed.minute == null) && isEvent;
+
+ if (isEvent) {
+ if (guessed.year != null) {
+ item.startDate.year = guessed.year;
+ }
+ if (guessed.month != null) {
+ item.startDate.month = guessed.month - 1;
+ }
+ if (guessed.day != null) {
+ item.startDate.day = guessed.day;
+ }
+ if (guessed.hour != null) {
+ item.startDate.hour = guessed.hour;
+ }
+ if (guessed.minute != null) {
+ item.startDate.minute = guessed.minute;
+ }
+
+ item.endDate = item.startDate.clone();
+ item.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+
+ if (endGuess.year != null) {
+ item.endDate.year = endGuess.year;
+ }
+ if (endGuess.month != null) {
+ item.endDate.month = endGuess.month - 1;
+ }
+ if (endGuess.day != null) {
+ item.endDate.day = endGuess.day;
+ if (allDay) {
+ item.endDate.day++;
+ }
+ }
+ if (endGuess.hour != null) {
+ item.endDate.hour = endGuess.hour;
+ }
+ if (endGuess.minute != null) {
+ item.endDate.minute = endGuess.minute;
+ }
+ } else {
+ let dtz = cal.dtz.defaultTimezone;
+ let dueDate = new Date();
+ // set default
+ dueDate.setHours(0);
+ dueDate.setMinutes(0);
+ dueDate.setSeconds(0);
+
+ if (endGuess.year != null) {
+ dueDate.setYear(endGuess.year);
+ }
+ if (endGuess.month != null) {
+ dueDate.setMonth(endGuess.month - 1);
+ }
+ if (endGuess.day != null) {
+ dueDate.setDate(endGuess.day);
+ }
+ if (endGuess.hour != null) {
+ dueDate.setHours(endGuess.hour);
+ }
+ if (endGuess.minute != null) {
+ dueDate.setMinutes(endGuess.minute);
+ }
+
+ cal.item.setItemProperty(item, "entryDate", cal.dtz.jsDateToDateTime(date, dtz));
+ if (endGuess.year != null) {
+ cal.item.setItemProperty(item, "dueDate", cal.dtz.jsDateToDateTime(dueDate, dtz));
+ }
+ }
+
+ // if time not guessed set allday for events
+ if (allDay) {
+ createEventWithDialog(null, null, null, null, item, true);
+ } else {
+ createEventWithDialog(null, null, null, null, item);
+ }
+ }
+
+ let timeSpent = new Date().getTime() - time;
+ cal.LOG(
+ "[calExtract] Total time spent for conversion (including loading of dictionaries): " +
+ timeSpent +
+ "ms"
+ );
+ },
+};
diff --git a/comm/calendar/base/content/calendar-invitation-display.js b/comm/calendar/base/content/calendar-invitation-display.js
new file mode 100644
index 0000000000..ed560d02ed
--- /dev/null
+++ b/comm/calendar/base/content/calendar-invitation-display.js
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals gMessageListeners, calImipBar */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ /**
+ * CalInvitationDisplay is the controller responsible for the display of the
+ * invitation panel when an email contains an embedded invitation.
+ */
+ const CalInvitationDisplay = {
+ /**
+ * The itipItem currently displayed.
+ *
+ * @type {calIItipItem}
+ */
+ currentItipItem: null,
+
+ /**
+ * The XUL element that wraps the invitation.
+ *
+ * @type {XULElement}
+ */
+ container: null,
+
+ /**
+ * The node the invitation details are rendered into.
+ *
+ * @type {HTMLElement}
+ */
+ display: null,
+
+ /**
+ * The <browser> element that displays the message body. This is hidden
+ * when the invitation details are displayed.
+ */
+ body: null,
+
+ /**
+ * Creates a new instance and sets up listeners.
+ */
+ init() {
+ this.container = document.getElementById("calendarInvitationDisplayContainer");
+ this.display = document.getElementById("calendarInvitationDisplay");
+ this.body = document.getElementById("messagepane");
+
+ window.addEventListener("onItipItemCreation", this);
+ window.addEventListener("onItipItemActionFinished", this);
+ window.addEventListener("messagepane-unloaded", this);
+ document.getElementById("msgHeaderView").addEventListener("message-header-pane-hidden", this);
+ gMessageListeners.push(this);
+ },
+
+ /**
+ * Renders the panel with invitation details when "onItipItemCreation" is
+ * received.
+ *
+ * @param {Event} evt
+ */
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "DOMContentLoaded":
+ this.init();
+ break;
+ case "onItipItemCreation":
+ case "onItipItemActionFinished":
+ this.show(evt.detail);
+ break;
+ case "messagepane-unloaded":
+ case "message-header-pane-hidden":
+ this.hide();
+ break;
+ case "calendar-invitation-panel-action":
+ if (evt.detail.type == "update") {
+ calImipBar.executeAction();
+ } else {
+ calImipBar.executeAction(evt.detail.type.toUpperCase());
+ }
+ break;
+ default:
+ break;
+ }
+ },
+
+ /**
+ * Hide the invitation display each time a new message to display is
+ * detected. If the message contains an invitation it will be displayed
+ * in the "onItipItemCreation" handler.
+ */
+ onStartHeaders() {
+ this.hide();
+ },
+
+ /**
+ * Called by messageHeaderSink.
+ */
+ onEndHeaders() {},
+
+ /**
+ * Displays the invitation display with the data from the provided
+ * calIItipItem.
+ *
+ * @param {calIItipItem} itipItem
+ */
+ async show(itipItem) {
+ this.currentItipItem = itipItem;
+ this.display.replaceChildren();
+
+ let [, rc, actionFunc, foundItems] = await new Promise(resolve =>
+ cal.itip.processItipItem(itipItem, (targetItipItem, rc, actionFunc, foundItems) =>
+ resolve([targetItipItem, rc, actionFunc, foundItems])
+ )
+ );
+
+ if (this.currentItipItem != itipItem || !Components.isSuccessCode(rc)) {
+ return;
+ }
+
+ let [item] = itipItem.getItemList();
+ let [foundItem] = foundItems;
+ let panel = document.createElement("calendar-invitation-panel");
+ panel.addEventListener("calendar-invitation-panel-action", this);
+
+ let method = actionFunc ? actionFunc.method : itipItem.receivedMethod;
+ switch (method) {
+ case "REQUEST:UPDATE":
+ panel.mode = panel.constructor.MODE_UPDATE_MAJOR;
+ break;
+ case "REQUEST:UPDATE-MINOR":
+ panel.mode = panel.constructor.MODE_UPDATE_MINOR;
+ break;
+ case "REQUEST":
+ panel.mode = foundItem
+ ? panel.constructor.MODE_ALREADY_PROCESSED
+ : panel.constructor.MODE_NEW;
+ break;
+ case "CANCEL":
+ panel.mode = foundItem
+ ? panel.constructor.MODE_CANCELLED
+ : panel.constructor.MODE_CANCELLED_NOT_FOUND;
+ break;
+ default:
+ panel.mode = panel.mode = panel.constructor.MODE_NEW;
+ break;
+ }
+ panel.foundItem = foundItem;
+ panel.item = item;
+ this.display.appendChild(panel);
+ this.body.hidden = true;
+ this.container.hidden = false;
+ },
+
+ /**
+ * Removes the invitation display from view, resetting any changes made
+ * to the container and message pane.
+ */
+ hide() {
+ this.container.hidden = true;
+ this.display.replaceChildren();
+ this.body.hidden = false;
+ },
+ };
+
+ window.addEventListener("DOMContentLoaded", CalInvitationDisplay, { once: true });
+}
diff --git a/comm/calendar/base/content/calendar-invitations-manager.js b/comm/calendar/base/content/calendar-invitations-manager.js
new file mode 100644
index 0000000000..9a758c4c49
--- /dev/null
+++ b/comm/calendar/base/content/calendar-invitations-manager.js
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/* exported openInvitationsDialog, setUpInvitationsManager,
+ * tearDownInvitationsManager
+ */
+
+var gInvitationsManager = null;
+
+/**
+ * Return a cached instance of the invitations manager
+ *
+ * @returns {InvitationsManager} The invitations manager instance.
+ */
+function getInvitationsManager() {
+ if (!gInvitationsManager) {
+ gInvitationsManager = new InvitationsManager();
+ }
+ return gInvitationsManager;
+}
+
+// Listeners, observers, set up, tear down, opening dialog, etc. This code kept
+// separate from the InvitationsManager class itself for separation of concerns.
+
+// == invitations link
+const FIRST_DELAY_STARTUP = 100;
+const FIRST_DELAY_RESCHEDULE = 100;
+const FIRST_DELAY_REGISTER = 10000;
+const FIRST_DELAY_UNREGISTER = 0;
+
+var gInvitationsCalendarManagerObserver = {
+ mStoredThis: this,
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]),
+
+ onCalendarRegistered(aCalendar) {
+ this.mStoredThis.rescheduleInvitationsUpdate(FIRST_DELAY_REGISTER);
+ },
+
+ onCalendarUnregistering(aCalendar) {
+ this.mStoredThis.rescheduleInvitationsUpdate(FIRST_DELAY_UNREGISTER);
+ },
+
+ onCalendarDeleting(aCalendar) {},
+};
+
+function scheduleInvitationsUpdate(firstDelay) {
+ getInvitationsManager().scheduleInvitationsUpdate(firstDelay);
+}
+
+function rescheduleInvitationsUpdate(firstDelay) {
+ getInvitationsManager().cancelInvitationsUpdate();
+ scheduleInvitationsUpdate(firstDelay);
+}
+
+function openInvitationsDialog() {
+ getInvitationsManager().cancelInvitationsUpdate();
+ getInvitationsManager().openInvitationsDialog();
+}
+
+function setUpInvitationsManager() {
+ scheduleInvitationsUpdate(FIRST_DELAY_STARTUP);
+ cal.manager.addObserver(gInvitationsCalendarManagerObserver);
+}
+
+function tearDownInvitationsManager() {
+ cal.manager.removeObserver(gInvitationsCalendarManagerObserver);
+}
+
+/**
+ * The invitations manager class constructor
+ *
+ * XXX do we really need this to be an instance?
+ *
+ * @class
+ */
+function InvitationsManager() {
+ this.mItemList = [];
+ this.mStartDate = null;
+ this.mTimer = null;
+
+ window.addEventListener("unload", () => {
+ // Unload handlers get removed automatically
+ this.cancelInvitationsUpdate();
+ });
+}
+
+InvitationsManager.prototype = {
+ mItemList: null,
+ mStartDate: null,
+ mTimer: null,
+ mPendingRequests: null,
+
+ /**
+ * Schedule an update for the invitations manager asynchronously.
+ *
+ * @param firstDelay The timeout before the operation should start.
+ */
+ scheduleInvitationsUpdate(firstDelay) {
+ this.cancelInvitationsUpdate();
+
+ this.mTimer = setTimeout(async () => {
+ if (Services.prefs.getBoolPref("calendar.invitations.autorefresh.enabled", true)) {
+ this.mTimer = setInterval(
+ async () => this._doInvitationsUpdate(),
+ Services.prefs.getIntPref("calendar.invitations.autorefresh.timeout", 3) * 60000
+ );
+ }
+ await this._doInvitationsUpdate();
+ }, firstDelay);
+ },
+
+ async _doInvitationsUpdate() {
+ let items;
+ try {
+ items = await cal.iterate.streamToArray(this.getInvitations());
+ } catch (e) {
+ cal.ERROR(e);
+ }
+ this.toggleInvitationsPanel(items);
+ },
+
+ /**
+ * Toggles the display of the invitations panel in the status bar depending
+ * on the number of invitation items found.
+ *
+ * @param {calIItemBase[]?} items - The invitations found, if empty or not
+ * provided, the panel will not be displayed.
+ */
+ toggleInvitationsPanel(items) {
+ let invitationsBox = document.getElementById("calendar-invitations-panel");
+ if (items) {
+ let count = items.length;
+ let value = cal.l10n.getLtnString("invitationsLink.label", [count]);
+ document.getElementById("calendar-invitations-label").value = value;
+ if (count) {
+ invitationsBox.removeAttribute("hidden");
+ return;
+ }
+ }
+
+ invitationsBox.setAttribute("hidden", "true");
+ },
+
+ /**
+ * Cancel pending any pending invitations update.
+ */
+ cancelInvitationsUpdate() {
+ clearTimeout(this.mTimer);
+ },
+
+ /**
+ * Cancel any pending queries for invitations.
+ */
+ async cancelPendingRequests() {
+ return this.mPendingRequests && this.mPendingRequests.cancel();
+ },
+
+ /**
+ * Retrieve invitations from all calendars. Notify all passed
+ * operation listeners.
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getInvitations() {
+ this.updateStartDate();
+ this.deleteAllItems();
+
+ let streams = [];
+ for (let calendar of cal.manager.getCalendars()) {
+ if (!cal.acl.isCalendarWritable(calendar) || calendar.getProperty("disabled")) {
+ continue;
+ }
+
+ // temporary hack unless calCachedCalendar supports REQUEST_NEEDS_ACTION filter:
+ calendar = calendar.getProperty("cache.uncachedCalendar");
+ if (!calendar) {
+ continue;
+ }
+
+ let endDate = this.mStartDate.clone();
+ endDate.year += 1;
+ streams.push(
+ calendar.getItems(
+ Ci.calICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION |
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ // we need to retrieve by occurrence to properly filter exceptions,
+ // should be fixed with bug 416975
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES,
+ 0,
+ this.mStartDate,
+ endDate /* we currently cannot pass null here, because of bug 416975 */
+ )
+ );
+ }
+
+ let self = this;
+ let mHandledItems = {};
+ return CalReadableStreamFactory.createReadableStream({
+ async start(controller) {
+ await self.cancelPendingRequests();
+
+ self.mPendingRequests = cal.iterate.streamValues(
+ CalReadableStreamFactory.createCombinedReadableStream(streams)
+ );
+
+ for await (let items of self.mPendingRequests) {
+ for (let item of items) {
+ // we need to retrieve by occurrence to properly filter exceptions,
+ // should be fixed with bug 416975
+ item = item.parentItem;
+ let hid = item.hashId;
+ if (!mHandledItems[hid]) {
+ mHandledItems[hid] = true;
+ self.addItem(item);
+ }
+ }
+ }
+
+ self.mItemList.sort((a, b) => {
+ return a.startDate.compare(b.startDate);
+ });
+
+ controller.enqueue(self.mItemList.slice());
+ controller.close();
+ },
+ close() {
+ self.mPendingRequests = null;
+ },
+ });
+ },
+
+ /**
+ * Open the invitations dialog, non-modal.
+ *
+ * XXX Passing these listeners in instead of keeping them in the window
+ * sounds fishy to me. Maybe there is a more encapsulated solution.
+ */
+ openInvitationsDialog() {
+ let args = {};
+ args.queue = [];
+ args.finishedCallBack = () => this.scheduleInvitationsUpdate(FIRST_DELAY_RESCHEDULE);
+ args.invitationsManager = this;
+ // the dialog will reset this to auto when it is done loading
+ window.setCursor("wait");
+ // open the dialog
+ window.openDialog(
+ "chrome://calendar/content/calendar-invitations-dialog.xhtml",
+ "_blank",
+ "chrome,titlebar,resizable",
+ args
+ );
+ },
+
+ /**
+ * Process the passed job queue. A job is an object that consists of an
+ * action, a newItem and and oldItem. This processor only takes "modify"
+ * operations into account.
+ *
+ * @param queue The array of objects to process.
+ */
+ async processJobQueue(queue) {
+ // TODO: undo/redo
+ for (let i = 0; i < queue.length; i++) {
+ let job = queue[i];
+ let oldItem = job.oldItem;
+ let newItem = job.newItem;
+ switch (job.action) {
+ case "modify":
+ let item = await newItem.calendar.modifyItem(newItem, oldItem);
+ cal.itip.checkAndSend(Ci.calIOperationListener.MODIFY, item, oldItem);
+ this.deleteItem(item);
+ this.addItem(item);
+ break;
+ default:
+ break;
+ }
+ }
+ },
+
+ /**
+ * Checks if the internal item list contains the given item
+ * XXXdbo Please document these correctly.
+ *
+ * @param item The item to look for.
+ * @returns A boolean value indicating if the item was found.
+ */
+ hasItem(item) {
+ let hid = item.hashId;
+ return this.mItemList.some(item_ => hid == item_.hashId);
+ },
+
+ /**
+ * Adds an item to the internal item list.
+ * XXXdbo Please document these correctly.
+ *
+ * @param item The item to add.
+ */
+ addItem(item) {
+ let recInfo = item.recurrenceInfo;
+ if (recInfo && !cal.itip.isOpenInvitation(item)) {
+ // scan exceptions:
+ let ids = recInfo.getExceptionIds();
+ for (let id of ids) {
+ let ex = recInfo.getExceptionFor(id);
+ if (ex && this.validateItem(ex) && !this.hasItem(ex)) {
+ this.mItemList.push(ex);
+ }
+ }
+ } else if (this.validateItem(item) && !this.hasItem(item)) {
+ this.mItemList.push(item);
+ }
+ },
+
+ /**
+ * Removes an item from the internal item list
+ * XXXdbo Please document these correctly.
+ *
+ * @param item The item to remove.
+ */
+ deleteItem(item) {
+ let id = item.id;
+ this.mItemList.filter(item_ => id != item_.id);
+ },
+
+ /**
+ * Remove all items from the internal item list
+ * XXXdbo Please document these correctly.
+ */
+ deleteAllItems() {
+ this.mItemList = [];
+ },
+
+ /**
+ * Helper function to create a start date to search from. This date is the
+ * current time with hour/minute/second set to zero.
+ *
+ * @returns Potential start date.
+ */
+ getStartDate() {
+ let date = cal.dtz.now();
+ date.second = 0;
+ date.minute = 0;
+ date.hour = 0;
+ return date;
+ },
+
+ /**
+ * Updates the start date for the invitations manager to the date returned
+ * from this.getStartDate(), unless the previously existing start date is
+ * the same or after what getStartDate() returned.
+ */
+ updateStartDate() {
+ if (this.mStartDate) {
+ let startDate = this.getStartDate();
+ if (startDate.compare(this.mStartDate) > 0) {
+ this.mStartDate = startDate;
+ }
+ } else {
+ this.mStartDate = this.getStartDate();
+ }
+ },
+
+ /**
+ * Checks if the item is valid for the invitation manager. Checks if the
+ * item is in the range of the invitation manager and if the item is a valid
+ * invitation.
+ *
+ * @param item The item to check
+ * @returns A boolean indicating if the item is a valid invitation.
+ */
+ validateItem(item) {
+ if (item.calendar instanceof Ci.calISchedulingSupport && !item.calendar.isInvitation(item)) {
+ return false; // exclude if organizer has invited himself
+ }
+ let start = item[cal.dtz.startDateProp(item)] || item[cal.dtz.endDateProp(item)];
+ return cal.itip.isOpenInvitation(item) && start.compare(this.mStartDate) >= 0;
+ },
+};
diff --git a/comm/calendar/base/content/calendar-keys.inc.xhtml b/comm/calendar/base/content/calendar-keys.inc.xhtml
new file mode 100644
index 0000000000..572a62d88e
--- /dev/null
+++ b/comm/calendar/base/content/calendar-keys.inc.xhtml
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<keyset id="calendar-keys">
+ <key id="todaypanekey" command="calendar_toggle_todaypane_command" keycode="VK_F11"/>
+ <key id="calendar-new-event-key" key="&lightning.keys.event.new;" modifiers="accel" command="calendar_new_event_command"/>
+ <key id="calendar-new-todo-key" key="&lightning.keys.todo.new;" modifiers="accel" command="calendar_new_todo_command"/>
+ <key id="calendar-go-to-today-key" keycode="VK_END"
+ command="calendar_go_to_today_command" modifiers="alt"/>
+ <key id="calendar-delete-item-key" keycode="VK_DELETE"
+ command="calendar_delete_event_command"/>
+ <key id="calendar-delete-todo-key" keycode="VK_DELETE"
+ command="calendar_delete_todo_command"/>
+</keyset>
diff --git a/comm/calendar/base/content/calendar-management.js b/comm/calendar/base/content/calendar-management.js
new file mode 100644
index 0000000000..351c44b184
--- /dev/null
+++ b/comm/calendar/base/content/calendar-management.js
@@ -0,0 +1,721 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals sortCalendarArray, gDataMigrator, calendarUpdateNewItemsCommand, currentView */
+
+/* exported promptDeleteCalendar, loadCalendarManager, unloadCalendarManager,
+ * calendarListTooltipShowing, calendarListSetupContextMenu,
+ * ensureCalendarVisible, toggleCalendarVisible, showAllCalendars,
+ * showOnlyCalendar, calendarOfflineManager, openLocalCalendar,
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Get this window's currently selected calendar.
+ *
+ * @returns The currently selected calendar.
+ */
+function getSelectedCalendar() {
+ return cal.view.getCompositeCalendar(window).defaultCalendar;
+}
+
+/**
+ * Deletes the passed calendar, prompting the user if he really wants to do
+ * this. If there is only one calendar left, no calendar is removed and the user
+ * is not prompted.
+ *
+ * @param aCalendar The calendar to delete.
+ */
+function promptDeleteCalendar(aCalendar) {
+ let calendars = cal.manager.getCalendars();
+ if (calendars.length <= 1) {
+ // If this is the last calendar, don't delete it.
+ return;
+ }
+
+ let modes = new Set(aCalendar.getProperty("capabilities.removeModes") || ["unsubscribe"]);
+ let title = cal.l10n.getCalString("removeCalendarTitle");
+
+ let textKey, b0text, b2text;
+ let removeFlags = 0;
+ let promptFlags =
+ Ci.nsIPromptService.BUTTON_POS_0 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING +
+ Ci.nsIPromptService.BUTTON_POS_1 * Ci.nsIPromptService.BUTTON_TITLE_CANCEL;
+
+ if (modes.has("delete") && !modes.has("unsubscribe")) {
+ textKey = "removeCalendarMessageDelete";
+ promptFlags += Ci.nsIPromptService.BUTTON_DELAY_ENABLE;
+ b0text = cal.l10n.getCalString("removeCalendarButtonDelete");
+ } else if (modes.has("delete")) {
+ textKey = "removeCalendarMessageDeleteOrUnsubscribe";
+ promptFlags += Ci.nsIPromptService.BUTTON_POS_2 * Ci.nsIPromptService.BUTTON_TITLE_IS_STRING;
+ b0text = cal.l10n.getCalString("removeCalendarButtonUnsubscribe");
+ b2text = cal.l10n.getCalString("removeCalendarButtonDelete");
+ } else if (modes.has("unsubscribe")) {
+ textKey = "removeCalendarMessageUnsubscribe";
+ removeFlags |= Ci.calICalendarManager.REMOVE_NO_DELETE;
+ b0text = cal.l10n.getCalString("removeCalendarButtonUnsubscribe");
+ } else {
+ return;
+ }
+
+ let text = cal.l10n.getCalString(textKey, [aCalendar.name]);
+ let res = Services.prompt.confirmEx(
+ window,
+ title,
+ text,
+ promptFlags,
+ b0text,
+ null,
+ b2text,
+ null,
+ {}
+ );
+
+ if (res != 1) {
+ // Not canceled
+ if (textKey == "removeCalendarMessageDeleteOrUnsubscribe" && res == 0) {
+ // Both unsubscribing and deleting is possible, but unsubscribing was
+ // requested. Make sure no delete is executed.
+ removeFlags |= Ci.calICalendarManager.REMOVE_NO_DELETE;
+ }
+
+ cal.manager.removeCalendar(aCalendar, removeFlags);
+ }
+}
+
+/**
+ * Call to refresh the status image of a calendar item when the
+ * calendar-readfailed or calendar-readonly attributes are added or removed.
+ *
+ * @param {MozRichlistitem} item - The calendar item to update.
+ */
+function updateCalendarStatusIndicators(item) {
+ let calendarName = item.querySelector(".calendar-name").textContent;
+ let image = item.querySelector("img.calendar-readstatus");
+ if (item.hasAttribute("calendar-readfailed")) {
+ image.setAttribute("src", "chrome://messenger/skin/icons/new/compact/warning.svg");
+ let tooltip = cal.l10n.getCalString("tooltipCalendarDisabled", [calendarName]);
+ image.setAttribute("title", tooltip);
+ } else if (item.hasAttribute("calendar-readonly")) {
+ image.setAttribute("src", "chrome://messenger/skin/icons/new/compact/lock.svg");
+ let tooltip = cal.l10n.getCalString("tooltipCalendarReadOnly", [calendarName]);
+ image.setAttribute("title", tooltip);
+ } else {
+ image.removeAttribute("src");
+ image.removeAttribute("title");
+ }
+}
+
+/**
+ * Called to initialize the calendar manager for a window.
+ */
+async function loadCalendarManager() {
+ let calendarList = document.getElementById("calendar-list");
+
+ // Set up the composite calendar in the calendar list widget.
+ let compositeCalendar = cal.view.getCompositeCalendar(window);
+
+ // Initialize our composite observer
+ compositeCalendar.addObserver(compositeObserver);
+
+ // Create the home calendar if no calendar exists.
+ let calendars = cal.manager.getCalendars();
+ if (calendars.length) {
+ // migration code to make sure calendars, which do not support caching have cache enabled
+ // required to further clean up on top of bug 1182264
+ for (let calendar of calendars) {
+ if (
+ calendar.getProperty("cache.supported") === false &&
+ calendar.getProperty("cache.enabled") === true
+ ) {
+ calendar.deleteProperty("cache.enabled");
+ }
+ }
+ } else {
+ initHomeCalendar();
+ }
+
+ for (let calendar of sortCalendarArray(cal.manager.getCalendars())) {
+ addCalendarItem(calendar);
+ }
+
+ function addCalendarItem(calendar) {
+ let item = document
+ .getElementById("calendar-list-item")
+ .content.firstElementChild.cloneNode(true);
+ let forceDisabled = calendar.getProperty("force-disabled");
+ item.id = `calendar-listitem-${calendar.id}`;
+ item.searchLabel = calendar.name;
+ item.setAttribute("aria-label", calendar.name);
+ item.setAttribute("calendar-id", calendar.id);
+ item.toggleAttribute("calendar-disabled", calendar.getProperty("disabled"));
+ item.toggleAttribute(
+ "calendar-readfailed",
+ !Components.isSuccessCode(calendar.getProperty("currentStatus")) || forceDisabled
+ );
+ item.toggleAttribute("calendar-readonly", calendar.readOnly);
+ item.toggleAttribute("calendar-muted", calendar.getProperty("suppressAlarms"));
+ document.l10n.setAttributes(
+ item.querySelector(".calendar-mute-status"),
+ "calendar-no-reminders-tooltip",
+ { calendarName: calendar.name }
+ );
+ document.l10n.setAttributes(
+ item.querySelector(".calendar-more-button"),
+ "calendar-list-item-context-button",
+ { calendarName: calendar.name }
+ );
+
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ let colorMarker = item.querySelector(".calendar-color");
+ if (calendar.getProperty("disabled")) {
+ colorMarker.style.backgroundColor = "transparent";
+ colorMarker.style.border = `2px solid var(--calendar-${cssSafeId}-backcolor)`;
+ } else {
+ colorMarker.style.backgroundColor = `var(--calendar-${cssSafeId}-backcolor)`;
+ }
+
+ let label = item.querySelector(".calendar-name");
+ label.textContent = calendar.name;
+
+ updateCalendarStatusIndicators(item);
+
+ let enable = item.querySelector(".calendar-enable-button");
+ document.l10n.setAttributes(enable, "calendar-enable-button");
+
+ enable.hidden = forceDisabled || !calendar.getProperty("disabled");
+
+ let displayedCheckbox = item.querySelector(".calendar-displayed");
+ displayedCheckbox.checked = calendar.getProperty("calendar-main-in-composite");
+ displayedCheckbox.hidden = calendar.getProperty("disabled");
+ let stringName = cal.view.getCompositeCalendar(window).getCalendarById(calendar.id)
+ ? "hideCalendar"
+ : "showCalendar";
+ displayedCheckbox.setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name]));
+
+ calendarList.appendChild(item);
+ if (calendar.getProperty("calendar-main-default")) {
+ // The list needs to handle the addition of the row before we can select it.
+ setTimeout(() => {
+ calendarList.selectedIndex = calendarList.rows.indexOf(item);
+ });
+ }
+ }
+
+ function saveSortOrder() {
+ let order = [...calendarList.children].map(i => i.getAttribute("calendar-id"));
+ Services.prefs.setStringPref("calendar.list.sortOrder", order.join(" "));
+ try {
+ Services.prefs.savePrefFile(null);
+ } catch (ex) {
+ cal.ERROR(ex);
+ }
+ }
+
+ calendarList.addEventListener("click", event => {
+ if (event.target.matches(".calendar-enable-button")) {
+ let calendar = cal.manager.getCalendarById(
+ event.target.closest("li").getAttribute("calendar-id")
+ );
+ calendar.setProperty("disabled", false);
+ calendarList.focus();
+ return;
+ }
+
+ if (!event.target.matches(".calendar-displayed")) {
+ return;
+ }
+
+ let item = event.target.closest("li");
+ let calendarId = item.getAttribute("calendar-id");
+ let calendar = cal.manager.getCalendarById(calendarId);
+
+ if (event.target.checked) {
+ compositeCalendar.addCalendar(calendar);
+ } else {
+ compositeCalendar.removeCalendar(calendar);
+ }
+
+ let stringName = event.target.checked ? "hideCalendar" : "showCalendar";
+ event.target.setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name]));
+
+ calendarList.focus();
+ });
+ calendarList.addEventListener("dblclick", event => {
+ if (
+ event.target.matches(".calendar-displayed") ||
+ event.target.matches(".calendar-enable-button")
+ ) {
+ return;
+ }
+
+ let item = event.target.closest("li");
+ if (!item) {
+ // Click on an empty part of the richlistbox.
+ cal.window.openCalendarWizard(window);
+ return;
+ }
+
+ let calendarId = item.getAttribute("calendar-id");
+ let calendar = cal.manager.getCalendarById(calendarId);
+ cal.window.openCalendarProperties(window, { calendar });
+ });
+ calendarList.addEventListener("ordered", event => {
+ saveSortOrder();
+ calendarList.selectedIndex = calendarList.rows.indexOf(event.detail);
+ });
+ calendarList.addEventListener("keypress", event => {
+ let item = calendarList.rows[calendarList.selectedIndex];
+ let calendarId = item.getAttribute("calendar-id");
+ let calendar = cal.manager.getCalendarById(calendarId);
+
+ switch (event.key) {
+ case "Delete":
+ promptDeleteCalendar(calendar);
+ break;
+ case " ": {
+ if (item.querySelector(".calendar-displayed").checked) {
+ compositeCalendar.removeCalendar(calendar);
+ } else {
+ compositeCalendar.addCalendar(calendar);
+ }
+ let stringName = item.querySelector(".calendar-displayed").checked
+ ? "hideCalendar"
+ : "showCalendar";
+ item
+ .querySelector(".calendar-displayed")
+ .setAttribute("title", cal.l10n.getCalString(stringName, [calendar.name]));
+ break;
+ }
+ }
+ });
+ calendarList.addEventListener("select", event => {
+ let item = calendarList.rows[calendarList.selectedIndex];
+ let calendarId = item.getAttribute("calendar-id");
+ let calendar = cal.manager.getCalendarById(calendarId);
+
+ compositeCalendar.defaultCalendar = calendar;
+ });
+
+ calendarList._calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(item) {},
+ onModifyItem(newItem, oldItem) {},
+ onDeleteItem(deletedItem) {},
+ onError(calendar, errNo, message) {},
+
+ onPropertyChanged(calendar, name, value, oldValue) {
+ let item = calendarList.getElementsByAttribute("calendar-id", calendar.id)[0];
+ if (!item) {
+ return;
+ }
+
+ switch (name) {
+ case "disabled":
+ item.toggleAttribute("calendar-disabled", value);
+ item.querySelector(".calendar-displayed").hidden = value;
+ // Update the "ENABLE" button.
+ let enableButton = item.querySelector(".calendar-enable-button");
+ enableButton.hidden = !value;
+ // Update the color preview.
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ let colorMarker = item.querySelector(".calendar-color");
+ colorMarker.style.backgroundColor = value
+ ? "transparent"
+ : `var(--calendar-${cssSafeId}-backcolor)`;
+ colorMarker.style.border = value
+ ? `2px solid var(--calendar-${cssSafeId}-backcolor)`
+ : "none";
+ break;
+ case "calendar-main-default":
+ if (value) {
+ calendarList.selectedIndex = calendarList.rows.indexOf(item);
+ }
+ break;
+ case "calendar-main-in-composite":
+ item.querySelector(".calendar-displayed").checked = value;
+ break;
+ case "name":
+ item.searchLabel = calendar.name;
+ item.querySelector(".calendar-name").textContent = value;
+ break;
+ case "currentStatus":
+ case "force-disabled":
+ item.toggleAttribute(
+ "calendar-readfailed",
+ name == "currentStatus" ? !Components.isSuccessCode(value) : value
+ );
+ updateCalendarStatusIndicators(item);
+ break;
+ case "readOnly":
+ item.toggleAttribute("calendar-readonly", value);
+ updateCalendarStatusIndicators(item);
+ break;
+ case "suppressAlarms":
+ item.toggleAttribute("calendar-muted", value);
+ break;
+ }
+ },
+
+ onPropertyDeleting(calendar, name) {
+ // Since the old value is not used directly in onPropertyChanged, but
+ // should not be the same as the value, set it to a different value.
+ this.onPropertyChanged(calendar, name, null, null);
+ },
+ };
+ cal.manager.addCalendarObserver(calendarList._calendarObserver);
+
+ calendarList._calendarManagerObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]),
+
+ onCalendarRegistered(calendar) {
+ addCalendarItem(calendar);
+ saveSortOrder();
+ },
+ onCalendarUnregistering(calendar) {
+ let item = calendarList.getElementsByAttribute("calendar-id", calendar.id)[0];
+ item.remove();
+ saveSortOrder();
+ },
+ onCalendarDeleting(calendar) {},
+ };
+ cal.manager.addObserver(calendarList._calendarManagerObserver);
+}
+
+/**
+ * Creates the initial "Home" calendar if no calendar exists.
+ */
+function initHomeCalendar() {
+ let composite = cal.view.getCompositeCalendar(window);
+ let url = Services.io.newURI("moz-storage-calendar://");
+ let homeCalendar = cal.manager.createCalendar("storage", url);
+ homeCalendar.name = cal.l10n.getCalString("homeCalendarName");
+ homeCalendar.setProperty("disabled", true);
+
+ cal.manager.registerCalendar(homeCalendar);
+ Services.prefs.setStringPref("calendar.list.sortOrder", homeCalendar.id);
+ composite.addCalendar(homeCalendar);
+
+ // Wrapping this in a try/catch block, as if any of the migration code
+ // fails, the app may not load.
+ if (Services.prefs.getBoolPref("calendar.migrator.enabled", true)) {
+ try {
+ gDataMigrator.checkAndMigrate();
+ } catch (e) {
+ console.error("Migrator error: " + e);
+ }
+ }
+
+ return homeCalendar;
+}
+
+/**
+ * Called to clean up the calendar manager for a window.
+ */
+function unloadCalendarManager() {
+ let compositeCalendar = cal.view.getCompositeCalendar(window);
+ compositeCalendar.setStatusObserver(null, null);
+ compositeCalendar.removeObserver(compositeObserver);
+
+ let calendarList = document.getElementById("calendar-list");
+ cal.manager.removeCalendarObserver(calendarList._calendarObserver);
+ cal.manager.removeObserver(calendarList._calendarManagerObserver);
+}
+
+/**
+ * A handler called to set up the context menu on the calendar list.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+
+function calendarListSetupContextMenu(event) {
+ let calendar;
+ let composite = cal.view.getCompositeCalendar(window);
+
+ if (event.target.matches(".calendar-displayed")) {
+ return;
+ }
+
+ let item = event.target.closest("li");
+ if (item) {
+ let calendarList = document.getElementById("calendar-list");
+ calendarList.selectedIndex = calendarList.rows.indexOf(item);
+ let calendarId = item.getAttribute("calendar-id");
+ calendar = cal.manager.getCalendarById(calendarId);
+ }
+
+ document.getElementById("list-calendars-context-menu").contextCalendar = calendar;
+
+ for (let elem of document.querySelectorAll("#list-calendars-context-menu .needs-calendar")) {
+ elem.hidden = !calendar;
+ }
+ if (calendar) {
+ let stringName = composite.getCalendarById(calendar.id) ? "hideCalendar" : "showCalendar";
+ document.getElementById("list-calendars-context-togglevisible").label = cal.l10n.getCalString(
+ stringName,
+ [calendar.name]
+ );
+ let accessKey = document
+ .getElementById("list-calendars-context-togglevisible")
+ .getAttribute(composite.getCalendarById(calendar.id) ? "accesskeyhide" : "accesskeyshow");
+ document.getElementById("list-calendars-context-togglevisible").accessKey = accessKey;
+ document.getElementById("list-calendars-context-showonly").label = cal.l10n.getCalString(
+ "showOnlyCalendar",
+ [calendar.name]
+ );
+ setupDeleteMenuitem("list-calendars-context-delete", calendar);
+ document.getElementById("list-calendar-context-reload").hidden = !calendar.canRefresh;
+ document.getElementById("list-calendars-context-reload-menuseparator").hidden =
+ !calendar.canRefresh;
+ }
+}
+
+/**
+ * Trigger the opening of the calendar list item context menu.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+function openCalendarListItemContext(event) {
+ calendarListSetupContextMenu(event);
+ let popUpCalListMenu = document.getElementById("list-calendars-context-menu");
+ if (event.type == "contextmenu" && event.button == 2) {
+ // This is a right-click. Open where it happened.
+ popUpCalListMenu.openPopupAtScreen(event.screenX, event.screenY, true);
+ return;
+ }
+ popUpCalListMenu.openPopup(event.target, "after_start", 0, 0, true);
+}
+
+/**
+ * Changes the "delete calendar" menuitem to have the right label based on the
+ * removeModes. The menuitem must have the attributes "labelremove",
+ * "labeldelete" and "labelunsubscribe".
+ *
+ * @param aDeleteId The id of the menuitem to delete the calendar
+ */
+function setupDeleteMenuitem(aDeleteId, aCalendar) {
+ let calendar = aCalendar === undefined ? getSelectedCalendar() : aCalendar;
+ let modes = new Set(
+ calendar ? calendar.getProperty("capabilities.removeModes") || ["unsubscribe"] : []
+ );
+
+ let type = "remove";
+ if (modes.has("delete") && !modes.has("unsubscribe")) {
+ type = "delete";
+ } else if (modes.has("unsubscribe") && !modes.has("delete")) {
+ type = "unsubscribe";
+ }
+
+ let deleteItem = document.getElementById(aDeleteId);
+ // Dynamically set labelremove, labeldelete, labelunsubscribe
+ deleteItem.label = deleteItem.getAttribute("label" + type);
+ // Dynamically set accesskeyremove, accesskeydelete, accesskeyunsubscribe
+ deleteItem.accessKey = deleteItem.getAttribute("accesskey" + type);
+}
+
+/**
+ * Makes sure the passed calendar is visible to the user
+ *
+ * @param aCalendar The calendar to make visible.
+ */
+function ensureCalendarVisible(aCalendar) {
+ // We use the main window's calendar list to ensure that the calendar is visible.
+ // If the main window has been closed this function may still be called,
+ // like when an event/task window is still open and the user clicks 'save',
+ // thus we have the extra checks.
+ let calendarList = document.getElementById("calendar-list");
+ if (calendarList) {
+ let compositeCalendar = cal.view.getCompositeCalendar(window);
+ compositeCalendar.addCalendar(aCalendar);
+ }
+}
+
+/**
+ * Hides the specified calendar if it is visible, or shows it if it is hidden.
+ *
+ * @param aCalendar The calendar to show or hide
+ */
+function toggleCalendarVisible(aCalendar) {
+ let composite = cal.view.getCompositeCalendar(window);
+ if (composite.getCalendarById(aCalendar.id)) {
+ composite.removeCalendar(aCalendar);
+ } else {
+ composite.addCalendar(aCalendar);
+ }
+}
+
+/**
+ * Shows all hidden calendars.
+ */
+function showAllCalendars() {
+ let composite = cal.view.getCompositeCalendar(window);
+ let cals = cal.manager.getCalendars();
+
+ composite.startBatch();
+ for (let calendar of cals) {
+ if (!composite.getCalendarById(calendar.id)) {
+ composite.addCalendar(calendar);
+ }
+ }
+ composite.endBatch();
+}
+
+/**
+ * Shows only the specified calendar, and hides all others.
+ *
+ * @param aCalendar The calendar to show as the only visible calendar
+ */
+function showOnlyCalendar(aCalendar) {
+ let composite = cal.view.getCompositeCalendar(window);
+ let cals = composite.getCalendars() || [];
+
+ composite.startBatch();
+ for (let calendar of cals) {
+ if (calendar.id != aCalendar.id) {
+ composite.removeCalendar(calendar);
+ }
+ }
+ composite.addCalendar(aCalendar);
+ composite.endBatch();
+}
+
+var compositeObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver", "calICompositeObserver"]),
+
+ onStartBatch() {},
+ onEndBatch() {},
+
+ onLoad() {
+ calendarUpdateNewItemsCommand();
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+
+ onAddItem() {},
+ onModifyItem() {},
+ onDeleteItem() {},
+ onError() {},
+
+ onPropertyChanged(calendar, name, value, oldValue) {
+ if (name == "disabled" || name == "readOnly") {
+ // Update commands when a calendar has been enabled or disabled.
+ calendarUpdateNewItemsCommand();
+ document.commandDispatcher.updateCommands("calendar_commands");
+ }
+ },
+
+ onPropertyDeleting() {},
+
+ onCalendarAdded(aCalendar) {
+ // Update the calendar commands for number of remote calendars and for
+ // more than one calendar.
+ calendarUpdateNewItemsCommand();
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+
+ onCalendarRemoved(aCalendar) {
+ // Update commands to disallow deleting the last calendar and only
+ // allowing reload remote calendars when there are remote calendars.
+ calendarUpdateNewItemsCommand();
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+
+ onDefaultCalendarChanged(aNewCalendar) {
+ // A new default calendar may mean that the new calendar has different
+ // ACLs. Make sure the commands are updated.
+ calendarUpdateNewItemsCommand();
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+};
+
+/**
+ * Shows the filepicker and creates a new calendar with a local file using the ICS
+ * provider.
+ */
+function openLocalCalendar() {
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ picker.init(window, cal.l10n.getCalString("Open"), Ci.nsIFilePicker.modeOpen);
+ let wildmat = "*.ics";
+ let description = cal.l10n.getCalString("filterIcs", [wildmat]);
+ picker.appendFilter(description, wildmat);
+ picker.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ picker.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !picker.file) {
+ return;
+ }
+
+ let calendars = cal.manager.getCalendars();
+ let calendar = calendars.find(x => x.uri.equals(picker.fileURL));
+ if (!calendar) {
+ calendar = cal.manager.createCalendar("ics", picker.fileURL);
+
+ // Strip ".ics" from filename for use as calendar name.
+ let prettyName = picker.fileURL.spec.match(/([^/:]+)\.ics$/);
+ if (prettyName) {
+ calendar.name = decodeURIComponent(prettyName[1]);
+ } else {
+ calendar.name = cal.l10n.getCalString("untitledCalendarName");
+ }
+
+ cal.manager.registerCalendar(calendar);
+ }
+
+ let calendarList = document.getElementById("calendar-list");
+ for (let index = 0; index < calendarList.rowCount; index++) {
+ if (calendarList.rows[index].getAttribute("calendar-id") == calendar.id) {
+ calendarList.selectedIndex = index;
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Calendar Offline Manager
+ */
+var calendarOfflineManager = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ init() {
+ if (this.initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+ Services.obs.addObserver(this, "network:offline-status-changed");
+
+ this.updateOfflineUI(!this.isOnline());
+ this.initialized = true;
+ },
+
+ uninit() {
+ if (!this.initialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ this.initialized = false;
+ },
+
+ isOnline() {
+ return !Services.io.offline;
+ },
+
+ updateOfflineUI(aIsOffline) {
+ // Refresh the current view
+ currentView().goToDay(currentView().selectedDay);
+
+ // Set up disabled locks for offline
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+
+ observe(aSubject, aTopic, aState) {
+ if (aTopic == "network:offline-status-changed") {
+ this.updateOfflineUI(aState == "offline");
+ }
+ },
+};
diff --git a/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml b/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml
new file mode 100644
index 0000000000..6967fad022
--- /dev/null
+++ b/comm/calendar/base/content/calendar-menu-events-tasks.inc.xhtml
@@ -0,0 +1,105 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<menu id="menu_Event_Task"
+ class="hide-when-calendar-deactivated"
+ label="&lightning.menu.eventtask.label;"
+ accesskey="&lightning.menu.eventtask.accesskey;">
+ <menupopup id="menu_Event_Task_Popup" onpopupshowing="changeMenuForTask(event); setupDeleteMenuitem('calDeleteSelectedCalendar')">
+ <menuitem id="calNewEvent2"
+ label="&event.new.event;"
+ accesskey="&event.new.event.accesskey;"
+ key="calendar-new-event-key"
+ command="calendar_new_event_command"/>
+ <menuitem id="calNewTask2"
+ label="&event.new.task;"
+ accesskey="&event.new.task.accesskey;"
+ key="calendar-new-todo-key"
+ command="calendar_new_todo_command"/>
+ <menuseparator id="before-Calendar-Mode-Section"/>
+ <menuitem id="calMenuSwitchToCalendar"
+ type="checkbox"
+ label="&lightning.toolbar.calendar.label;"
+ accesskey="&lightning.toolbar.calendar.accesskey;"
+ command="switch2calendar"
+ value="calendar"
+ autocheck="false"
+ data-l10n-attrs="acceltext">
+ </menuitem>
+ <menuitem id="calMenuSwitchToTask"
+ type="checkbox"
+ label="&lightning.toolbar.task.label;"
+ accesskey="&lightning.toolbar.task.accesskey;"
+ command="switch2task"
+ value="task"
+ autocheck="false"
+ data-l10n-attrs="acceltext">
+ </menuitem>
+ <menuseparator id="calBeforeCalendarSection"/>
+ <menuitem id="calExportCalendar"
+ label="&calendar.export.label;"
+ accesskey="&calendar.export.accesskey;"
+ command="calendar_export_command"/>
+ <menuitem id="calImportCalendar"
+ label="&calendar.import.label;"
+ accesskey="&calendar.import.accesskey;"
+ command="calendar_import_command"/>
+ <menuitem id="calPublishCalendar"
+ label="&calendar.publish.label;"
+ accesskey="&calendar.publish.accesskey;"
+ command="calendar_publish_calendar_command"/>
+ <menuitem id="calDeleteSelectedCalendar"
+ labeldelete="&calendar.deletecalendar.label;"
+ labelremove="&calendar.removecalendar.label;"
+ labelunsubscribe="&calendar.unsubscribecalendar.label;"
+ accesskeydelete="&calendar.deletecalendar.accesskey;"
+ accesskeyremove="&calendar.removecalendar.accesskey;"
+ accesskeyunsubscribe="&calendar.unsubscribecalendar.accesskey;"
+ command="calendar_delete_calendar_command"/>
+ <menuseparator id="calBeforeTaskActions"/>
+ <menuitem id="calTaskActionsMarkCompletedMenuitem"
+ type="checkbox"
+ label="&calendar.context.markcompleted.label;"
+ accesskey="&calendar.context.markcompleted.accesskey;"
+ command="calendar_toggle_completed_command"/>
+ <menu id="calTaskActionsProgressMenuitem"
+ label="&calendar.context.progress.label;"
+ accesskey="&calendar.context.progress.accesskey;"
+ command="calendar_general-progress_command">
+ <menupopup is="calendar-task-progress-menupopup"/>
+ </menu>
+ <menu id="calTaskActionsPriorityMenuitem"
+ label="&calendar.context.priority.label;"
+ accesskey="&calendar.context.priority.accesskey;"
+ command="calendar_general-priority_command">
+ <menupopup is="calendar-task-priority-menupopup"/>
+ </menu>
+ <menu id="calTaskActionsPostponeMenuitem"
+ label="&calendar.context.postpone.label;"
+ accesskey="&calendar.context.postpone.accesskey;"
+ command="calendar_general-postpone_command">
+ <menupopup id="calTaskActionsPostponeMenuPopup">
+ <menuitem id="calTaskActionsPostponeMenu-1hour"
+ label="&calendar.context.postpone.1hour.label;"
+ accesskey="&calendar.context.postpone.1hour.accesskey;"
+ command="calendar_postpone-1hour_command"/>
+ <menuitem id="calTaskActionsPostponeMenu-1day"
+ label="&calendar.context.postpone.1day.label;"
+ accesskey="&calendar.context.postpone.1day.accesskey;"
+ command="calendar_postpone-1day_command"/>
+ <menuitem id="calTaskActionsPostponeMenu-1week"
+ label="&calendar.context.postpone.1week.label;"
+ accesskey="&calendar.context.postpone.1week.accesskey;"
+ command="calendar_postpone-1week_command"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="calBeforeUnifinderSection" />
+ <menuitem id="calShowUnifinder"
+ type="checkbox"
+ checked="true"
+ label="&showUnifinderCmd.label;"
+ accesskey="&showUnifinderCmd.accesskey;"
+ command="calendar_show_unifinder_command"/>
+ </menupopup>
+</menu>
diff --git a/comm/calendar/base/content/calendar-menus.js b/comm/calendar/base/content/calendar-menus.js
new file mode 100644
index 0000000000..7dd71c3649
--- /dev/null
+++ b/comm/calendar/base/content/calendar-menus.js
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from item-editing/calendar-item-panel.js */
+
+// Importing from calendar-task-tree-utils.js puts ESLint in a fatal loop.
+/* globals getSelectedTasks, MozElements, MozXULElement,
+ setAttributeOnChildrenOrTheirCommands */
+
+"use strict";
+
+// Wrap in a block and use const to define functions to prevent leaking to window scope.
+{
+ /**
+ * Get a property value for a group of tasks. If all the tasks have the same property value
+ * then return that value, otherwise return null.
+ *
+ * @param {string} propertyKey - The property key.
+ * @param {object[]} tasks - The tasks.
+ * @returns {string|null} The property value or null.
+ */
+ const getPropertyValue = (propertyKey, tasks) => {
+ let propertyValue = null;
+ const tasksSelected = tasks != null && tasks.length > 0;
+ if (tasksSelected && tasks.every(task => task[propertyKey] == tasks[0][propertyKey])) {
+ propertyValue = tasks[0][propertyKey];
+ }
+ return propertyValue;
+ };
+
+ /**
+ * Updates the 'checked' state of menu items so they reflect the state of the relevant task(s),
+ * for example, tasks currently selected in the task list, or a task being edited in the
+ * current tab. It operates on commands that are named using the following pattern:
+ *
+ * 'calendar_' + propertyKey + ' + '-' + propertyValue + '_command'
+ *
+ * When the propertyValue part of a command's name matches the propertyValue of the tasks,
+ * set the command to 'checked=true', as long as the tasks all have the same propertyValue.
+ *
+ * @param parent {Element} - Parent element that contains the menu items as direct children.
+ * @param propertyKey {string} - The property key, for example "priority" or "percentComplete".
+ */
+ const updateMenuItemsState = (parent, propertyKey) => {
+ setAttributeOnChildrenOrTheirCommands("checked", false, parent);
+
+ const inSingleTaskTab =
+ gTabmail && gTabmail.currentTabInfo && gTabmail.currentTabInfo.mode.type == "calendarTask";
+
+ const propertyValue = inSingleTaskTab
+ ? gConfig[propertyKey]
+ : getPropertyValue(propertyKey, getSelectedTasks());
+
+ if (propertyValue || propertyValue === 0) {
+ const commandName = "calendar_" + propertyKey + "-" + propertyValue + "_command";
+ const command = document.getElementById(commandName);
+ if (command) {
+ command.setAttribute("checked", "true");
+ }
+ }
+ };
+
+ /**
+ * A menupopup for changing the "progress" (percent complete) status for a task or tasks. It
+ * indicates the current status by displaying a checkmark next to the menu item for that status.
+ *
+ * @augments MozElements.MozMenuPopup
+ */
+ class CalendarTaskProgressMenupopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true in super.connectedCallback
+ super.connectedCallback();
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menuitem class="percent-0-menuitem"
+ type="checkbox"
+ label="&progress.level.0;"
+ accesskey="&progress.level.0.accesskey;"
+ command="calendar_percentComplete-0_command"/>
+ <menuitem class="percent-25-menuitem"
+ type="checkbox"
+ label="&progress.level.25;"
+ accesskey="&progress.level.25.accesskey;"
+ command="calendar_percentComplete-25_command"/>
+ <menuitem class="percent-50-menuitem"
+ type="checkbox"
+ label="&progress.level.50;"
+ accesskey="&progress.level.50.accesskey;"
+ command="calendar_percentComplete-50_command"/>
+ <menuitem class="percent-75-menuitem"
+ type="checkbox"
+ label="&progress.level.75;"
+ accesskey="&progress.level.75.accesskey;"
+ command="calendar_percentComplete-75_command"/>
+ <menuitem class="percent-100-menuitem"
+ type="checkbox"
+ label="&progress.level.100;"
+ accesskey="&progress.level.100.accesskey;"
+ command="calendar_percentComplete-100_command"/>
+ `,
+ ["chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+
+ this.addEventListener(
+ "popupshowing",
+ updateMenuItemsState.bind(null, this, "percentComplete"),
+ true
+ );
+ }
+ }
+
+ customElements.define("calendar-task-progress-menupopup", CalendarTaskProgressMenupopup, {
+ extends: "menupopup",
+ });
+
+ /**
+ * A menupopup for changing the "priority" status for a task or tasks. It indicates the current
+ * status by displaying a checkmark next to the menu item for that status.
+ *
+ * @augments MozElements.MozMenuPopup
+ */
+ class CalendarTaskPriorityMenupopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true in super.connectedCallback
+ super.connectedCallback();
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menuitem class="priority-0-menuitem"
+ type="checkbox"
+ label="&priority.level.none;"
+ accesskey="&priority.level.none.accesskey;"
+ command="calendar_priority-0_command"/>
+ <menuitem class="priority-9-menuitem"
+ type="checkbox"
+ label="&priority.level.low;"
+ accesskey="&priority.level.low.accesskey;"
+ command="calendar_priority-9_command"/>
+ <menuitem class="priority-5-menuitem"
+ type="checkbox"
+ label="&priority.level.normal;"
+ accesskey="&priority.level.normal.accesskey;"
+ command="calendar_priority-5_command"/>
+ <menuitem class="priority-1-menuitem"
+ type="checkbox"
+ label="&priority.level.high;"
+ accesskey="&priority.level.high.accesskey;"
+ command="calendar_priority-1_command"/>
+ `,
+ ["chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+
+ this.addEventListener(
+ "popupshowing",
+ updateMenuItemsState.bind(null, this, "priority"),
+ true
+ );
+ }
+ }
+
+ customElements.define("calendar-task-priority-menupopup", CalendarTaskPriorityMenupopup, {
+ extends: "menupopup",
+ });
+}
diff --git a/comm/calendar/base/content/calendar-migration.js b/comm/calendar/base/content/calendar-migration.js
new file mode 100644
index 0000000000..e5a5a1add2
--- /dev/null
+++ b/comm/calendar/base/content/calendar-migration.js
@@ -0,0 +1,323 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals putItemsIntoCal*/
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+
+/**
+ * A data migrator prototype, holding the information for migration
+ *
+ * @class
+ * @param aTitle The title of the migrator
+ * @param aMigrateFunction The function to call when migrating
+ * @param aArguments The arguments to pass in.
+ */
+function dataMigrator(aTitle, aMigrateFunction, aArguments) {
+ this.title = aTitle;
+ this.migrate = aMigrateFunction;
+ this.args = aArguments || [];
+}
+
+var gDataMigrator = {
+ /**
+ * Call to do a general data migration (for a clean profile) Will run
+ * through all of the known migrator-checkers. These checkers will return
+ * an array of valid dataMigrator objects, for each kind of data they find.
+ * If there is at least one valid migrator, we'll pop open the migration
+ * wizard, otherwise, we'll return silently.
+ */
+ checkAndMigrate() {
+ let DMs = [];
+ let migrators = [this.checkEvolution, this.checkWindowsMail, this.checkIcal];
+ for (let migrator of migrators) {
+ let migs = migrator.call(this);
+ for (let mig of migs) {
+ DMs.push(mig);
+ }
+ }
+
+ if (DMs.length == 0) {
+ // No migration available
+ return;
+ }
+
+ let url = "chrome://calendar/content/calendar-migration-dialog.xhtml";
+ if (AppConstants.platform == "macosx") {
+ let win = Services.wm.getMostRecentWindow("Calendar:MigrationWizard");
+ if (win) {
+ win.focus();
+ } else {
+ openDialog(url, "migration", "centerscreen,chrome,resizable=no,width=500,height=400", DMs);
+ }
+ } else {
+ openDialog(
+ url,
+ "migration",
+ "modal,centerscreen,chrome,resizable=no,width=500,height=400",
+ DMs
+ );
+ }
+ },
+
+ /**
+ * Checks to see if Apple's iCal is installed and offers to migrate any data
+ * the user has created in it.
+ */
+ checkIcal() {
+ function icalMigrate(aDataDir, aCallback) {
+ aDataDir.append("Sources");
+
+ let i = 1;
+ for (let dataDir of aDataDir.directoryEntries) {
+ let dataStore = dataDir.clone();
+ dataStore.append("corestorage.ics");
+ if (!dataStore.exists()) {
+ continue;
+ }
+
+ let fileStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+
+ fileStream.init(dataStore, 0x01, parseInt("0444", 8), {});
+ let convIStream = Cc["@mozilla.org/intl/converter-input-stream;1"].getService(
+ Ci.nsIConverterInputStream
+ );
+ convIStream.init(fileStream, "UTF-8", 0, 0x0000);
+ let tmpStr = {};
+ let str = "";
+ while (convIStream.readString(-1, tmpStr)) {
+ str += tmpStr.value;
+ }
+
+ // Strip out the timezone definitions, since it makes the file
+ // invalid otherwise
+ let index = str.indexOf(";TZID=");
+ while (index != -1) {
+ let endIndex = str.indexOf(":", index);
+ let otherEnd = str.indexOf(";", index + 2);
+ if (otherEnd < endIndex) {
+ endIndex = otherEnd;
+ }
+ let sub = str.substring(index, endIndex);
+ str = str.split(sub).join("");
+ index = str.indexOf(";TZID=");
+ }
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("icalTemp.ics");
+ tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ let stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ stream.init(tempFile, 0x2a, parseInt("0600", 8), 0);
+ let convOStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(
+ Ci.nsIConverterOutputStream
+ );
+ convOStream.init(stream, "UTF-8");
+ convOStream.writeString(str);
+
+ let calendar = gDataMigrator.importICSToStorage(tempFile);
+ calendar.name = "iCalendar" + i;
+ i++;
+ cal.manager.registerCalendar(calendar);
+ cal.view.getCompositeCalendar(window).addCalendar(calendar);
+ }
+ console.debug("icalMig making callback");
+ aCallback();
+ }
+
+ console.debug("Checking for ical data");
+ let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let icalSpec = profileDir.path;
+ let diverge = icalSpec.indexOf("Thunderbird");
+ if (diverge == -1) {
+ return [];
+ }
+ icalSpec = icalSpec.substr(0, diverge);
+ let icalFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ icalFile.initWithPath(icalSpec);
+ icalFile.append("Application Support");
+
+ icalFile.append("iCal");
+ if (icalFile.exists()) {
+ return [new dataMigrator("Apple iCal", icalMigrate, [icalFile])];
+ }
+
+ return [];
+ },
+
+ /**
+ * Checks to see if Evolution is installed and offers to migrate any data
+ * stored there.
+ */
+ checkEvolution() {
+ function evoMigrate(aDataDir, aCallback) {
+ let i = 1;
+ let evoDataMigrate = function (dataStore) {
+ console.debug("Migrating evolution data file in " + dataStore.path);
+ if (dataStore.exists()) {
+ let calendar = gDataMigrator.importICSToStorage(dataStore);
+ calendar.name = "Evolution " + i++;
+ cal.manager.registerCalendar(calendar);
+ cal.view.getCompositeCalendar(window).addCalendar(calendar);
+ }
+ return dataStore.exists();
+ };
+
+ for (let dataDir of aDataDir.directoryEntries) {
+ let dataStore = dataDir.clone();
+ dataStore.append("calendar.ics");
+ evoDataMigrate(dataStore);
+ }
+
+ aCallback();
+ }
+
+ let evoDir = Services.dirsvc.get("Home", Ci.nsIFile);
+ evoDir.append(".evolution");
+ evoDir.append("calendar");
+ evoDir.append("local");
+ return evoDir.exists() ? [new dataMigrator("Evolution", evoMigrate, [evoDir])] : [];
+ },
+
+ checkWindowsMail() {
+ function doMigrate(aCalendarNodes, aMailDir, aCallback) {
+ for (let node of aCalendarNodes) {
+ let name = node.getElementsByTagName("Name")[0].textContent;
+ let color = node.getElementsByTagName("Color")[0].textContent;
+ let enabled = node.getElementsByTagName("Enabled")[0].textContent == "True";
+
+ // The name is quoted, and the color also contains an alpha
+ // value. Lets just ignore the alpha value and take the
+ // color part.
+ name = name.replace(/(^'|'$)/g, "");
+ color = color.replace(/0x[0-9a-fA-F]{2}([0-9a-fA-F]{4})/, "#$1");
+
+ let calfile = aMailDir.clone();
+ calfile.append(name + ".ics");
+
+ if (calfile.exists()) {
+ let storage = gDataMigrator.importICSToStorage(calfile);
+ storage.name = name;
+
+ if (color) {
+ storage.setProperty("color", color);
+ }
+ cal.manager.registerCalendar(storage);
+
+ if (enabled) {
+ cal.view.getCompositeCalendar(window).addCalendar(storage);
+ }
+ }
+ }
+ aCallback();
+ }
+
+ if (!Services.dirsvc.has("LocalAppData")) {
+ // We are probably not on windows
+ return [];
+ }
+
+ let maildir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+
+ maildir.append("Microsoft");
+ maildir.append("Windows Calendar");
+ maildir.append("Calendars");
+
+ let settingsxml = maildir.clone();
+ settingsxml.append("Settings.xml");
+
+ let migrators = [];
+ if (settingsxml.exists()) {
+ let settingsXmlUri = Services.io.newFileURI(settingsxml);
+
+ let req = new XMLHttpRequest();
+ req.open("GET", settingsXmlUri.spec, false);
+ req.send(null);
+ if (req.status == 0) {
+ // The file was found, it seems we are on windows vista.
+ let doc = req.responseXML;
+
+ // Get all calendar property tags and return the migrator.
+ let calendars = doc.getElementsByTagName("VCalendar");
+ if (calendars.length > 0) {
+ migrators = [
+ new dataMigrator("Windows Calendar", doMigrate.bind(null, calendars, maildir)),
+ ];
+ }
+ }
+ }
+ return migrators;
+ },
+
+ /**
+ * Creates and registers a storage calendar and imports the given ics file into it.
+ *
+ * @param icsFile The nsI(Local)File to import.
+ */
+ importICSToStorage(icsFile) {
+ const uri = "moz-storage-calendar://";
+ let calendar = cal.manager.createCalendar("storage", Services.io.newURI(uri));
+ let icsImporter = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter);
+
+ let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let items = [];
+
+ calendar.id = cal.getUUID();
+
+ try {
+ const MODE_RDONLY = 0x01;
+ inputStream.init(icsFile, MODE_RDONLY, parseInt("0444", 8), {});
+ items = icsImporter.importFromStream(inputStream);
+ } catch (ex) {
+ switch (ex.result) {
+ case Ci.calIErrors.INVALID_TIMEZONE:
+ cal.showError(cal.l10n.getCalString("timezoneError", [icsFile.path]), window);
+ break;
+ default:
+ cal.showError(cal.l10n.getCalString("unableToRead") + icsFile.path + "\n" + ex, window);
+ }
+ } finally {
+ inputStream.close();
+ }
+
+ // Defined in import-export.js
+ putItemsIntoCal(calendar, items, {
+ duplicateCount: 0,
+ failedCount: 0,
+ lastError: null,
+
+ onDuplicate(item, error) {
+ this.duplicateCount++;
+ },
+ onError(item, error) {
+ this.failedCount++;
+ this.lastError = error;
+ },
+ onEnd() {
+ if (this.failedCount) {
+ cal.showError(
+ cal.l10n.getCalString("importItemsFailed", [
+ this.failedCount,
+ this.lastError.toString(),
+ ]),
+ window
+ );
+ } else if (this.duplicateCount) {
+ cal.showError(
+ cal.l10n.getCalString("duplicateError", [this.duplicateCount, icsFile.path]),
+ window
+ );
+ }
+ },
+ });
+
+ return calendar;
+ },
+};
diff --git a/comm/calendar/base/content/calendar-modes.js b/comm/calendar/base/content/calendar-modes.js
new file mode 100644
index 0000000000..280a1c7a54
--- /dev/null
+++ b/comm/calendar/base/content/calendar-modes.js
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals TodayPane, switchToView, gLastShownCalendarView, ensureUnifinderLoaded */
+
+/* exported calSwitchToCalendarMode, calSwitchToMode, calSwitchToTaskMode,
+ * changeMode
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * The current mode defining the current mode we're in. Allowed values are:
+ * - 'mail'
+ * - 'calendar'
+ * - 'task'
+ * - 'chat'
+ * - 'calendarEvent'
+ * - 'calendarTask'
+ * - 'special' - For special tabs like preferences, add-ons manager, about:xyz, etc.
+ *
+ * @global
+ */
+var gCurrentMode = "mail";
+
+/**
+ * Changes the mode (gCurrentMode) and adapts the UI to the new mode.
+ *
+ * @param {string} [mode="mail"] - the new mode: 'mail', 'calendar', 'task', etc.
+ */
+function changeMode(mode = "mail") {
+ gCurrentMode = mode; // eslint-disable-line no-global-assign
+
+ document
+ .querySelectorAll(
+ `menuitem[command="switch2calendar"],menuitem[command="switch2task"],
+ toolbarbutton[command="switch2calendar"],toolbarbutton[command="switch2task"]`
+ )
+ .forEach(elem => {
+ elem.setAttribute("checked", elem.getAttribute("value") == gCurrentMode);
+ });
+
+ document.querySelectorAll("calendar-modebox,calendar-modevbox").forEach(elem => {
+ elem.setAttribute("current", gCurrentMode);
+ });
+
+ TodayPane.onModeModified();
+}
+
+/**
+ * For switching to modes like "mail", "chat", "calendarEvent", "calendarTask", or "special".
+ * (For "calendar" and "task" modes use calSwitchToCalendarMode and calSwitchToTaskMode.)
+ *
+ * @param {string} mode - The mode to switch to.
+ */
+function calSwitchToMode(mode) {
+ if (!["mail", "chat", "calendarEvent", "calendarTask", "special"].includes(mode)) {
+ cal.WARN("Attempted to switch to unknown mode: " + mode);
+ return;
+ }
+ if (gCurrentMode != mode) {
+ const previousMode = gCurrentMode;
+ changeMode(mode);
+
+ if (previousMode == "calendar" || previousMode == "task") {
+ document.commandDispatcher.updateCommands("calendar_commands");
+ }
+ window.setCursor("auto");
+ }
+}
+
+/**
+ * Switches to the calendar mode.
+ */
+function calSwitchToCalendarMode() {
+ if (gCurrentMode != "calendar") {
+ changeMode("calendar");
+
+ // display the calendar panel on the display deck
+ document.getElementById("calendar-view-box").collapsed = false;
+ document.getElementById("calendar-task-box").collapsed = true;
+ document.getElementById("sidePanelNewEvent").hidden = false;
+ document.getElementById("sidePanelNewTask").hidden = true;
+
+ // show the last displayed type of calendar view
+ switchToView(gLastShownCalendarView.get());
+ document.getElementById("calMinimonth").setAttribute("freebusy", "true");
+
+ document.commandDispatcher.updateCommands("calendar_commands");
+ window.setCursor("auto");
+
+ // make sure the view is sized correctly
+ window.dispatchEvent(new CustomEvent("viewresize"));
+
+ // Load the unifinder if it isn't already loaded.
+ ensureUnifinderLoaded();
+ }
+}
+
+/**
+ * Switches to the task mode.
+ */
+function calSwitchToTaskMode() {
+ if (gCurrentMode != "task") {
+ changeMode("task");
+
+ // display the task panel on the display deck
+ document.getElementById("calendar-view-box").collapsed = true;
+ document.getElementById("calendar-task-box").collapsed = false;
+ document.getElementById("sidePanelNewEvent").hidden = true;
+ document.getElementById("sidePanelNewTask").hidden = false;
+
+ document.getElementById("calMinimonth").setAttribute("freebusy", "true");
+
+ let tree = document.getElementById("calendar-task-tree");
+ if (!tree.hasBeenVisible) {
+ tree.hasBeenVisible = true;
+ tree.refresh();
+ }
+
+ document.commandDispatcher.updateCommands("calendar_commands");
+ window.setCursor("auto");
+ }
+}
diff --git a/comm/calendar/base/content/calendar-month-view.js b/comm/calendar/base/content/calendar-month-view.js
new file mode 100644
index 0000000000..f4bd93b02d
--- /dev/null
+++ b/comm/calendar/base/content/calendar-month-view.js
@@ -0,0 +1,1242 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals calendarNavigationBar, MozElements, MozXULElement */
+
+/* import-globals-from calendar-ui-utils.js */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ /**
+ * Implements the Drag and Drop class for the Month Day Box view.
+ *
+ * @augments {MozElements.CalendarDnDContainer}
+ */
+ class CalendarMonthDayBox extends MozElements.CalendarDnDContainer {
+ static get inheritedAttributes() {
+ return {
+ ".calendar-month-week-label": "relation,selected",
+ ".calendar-month-day-label": "relation,selected,text=value",
+ };
+ }
+
+ constructor() {
+ super();
+ this.addEventListener("mousedown", this.onMouseDown);
+ this.addEventListener("dblclick", this.onDblClick);
+ this.addEventListener("click", this.onClick);
+ this.addEventListener("wheel", this.onWheel);
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true in super.connectedCallback.
+ super.connectedCallback();
+
+ this.mDate = null;
+ this.mItemHash = {};
+ this.mShowMonthLabel = false;
+
+ this.setAttribute("orient", "vertical");
+
+ let monthDayLabels = document.createElement("h2");
+ monthDayLabels.classList.add("calendar-month-day-box-dates");
+
+ let weekLabel = document.createElement("span");
+ weekLabel.setAttribute("data-label", "week");
+ weekLabel.setAttribute("hidden", "true");
+ weekLabel.style.pointerEvents = "none";
+ weekLabel.classList.add("calendar-month-day-box-week-label", "calendar-month-week-label");
+
+ let dayLabel = document.createElement("span");
+ dayLabel.setAttribute("data-label", "day");
+ dayLabel.style.pointerEvents = "none";
+ dayLabel.classList.add("calendar-month-day-box-date-label", "calendar-month-day-label");
+
+ monthDayLabels.appendChild(weekLabel);
+ monthDayLabels.appendChild(dayLabel);
+
+ this.dayList = document.createElement("ol");
+ this.dayList.classList.add("calendar-month-day-box-list");
+
+ this.appendChild(monthDayLabels);
+ this.appendChild(this.dayList);
+
+ this.initializeAttributeInheritance();
+ }
+
+ get date() {
+ return this.mDate;
+ }
+
+ set date(val) {
+ this.setDate(val);
+ }
+
+ get selected() {
+ let sel = this.getAttribute("selected");
+ if (sel && sel == "true") {
+ return true;
+ }
+
+ return false;
+ }
+
+ set selected(val) {
+ if (val) {
+ this.setAttribute("selected", "true");
+ this.parentNode.setAttribute("selected", "true");
+ } else {
+ this.removeAttribute("selected");
+ this.parentNode.removeAttribute("selected");
+ }
+ }
+
+ get showMonthLabel() {
+ return this.mShowMonthLabel;
+ }
+
+ set showMonthLabel(val) {
+ if (this.mShowMonthLabel == val) {
+ return;
+ }
+ this.mShowMonthLabel = val;
+
+ if (!this.mDate) {
+ return;
+ }
+ if (val) {
+ this.setAttribute("value", cal.dtz.formatter.formatDateWithoutYear(this.mDate));
+ } else {
+ this.setAttribute("value", this.mDate.day);
+ }
+ }
+
+ clear() {
+ // Remove all the old events.
+ this.mItemHash = {};
+ while (this.dayList.lastChild) {
+ this.dayList.lastChild.remove();
+ }
+ }
+
+ setDate(aDate) {
+ this.clear();
+
+ if (this.mDate && aDate && this.mDate.compare(aDate) == 0) {
+ return;
+ }
+
+ this.mDate = aDate;
+
+ if (!aDate) {
+ // Clearing out these attributes isn't strictly necessary but saves some confusion.
+ this.removeAttribute("year");
+ this.removeAttribute("month");
+ this.removeAttribute("week");
+ this.removeAttribute("day");
+ this.removeAttribute("value");
+ return;
+ }
+
+ // Set up DOM attributes for custom CSS coloring.
+ let weekTitle = cal.weekInfoService.getWeekTitle(aDate);
+ this.setAttribute("year", aDate.year);
+ this.setAttribute("month", aDate.month + 1);
+ this.setAttribute("week", weekTitle);
+ this.setAttribute("day", aDate.day);
+
+ if (this.mShowMonthLabel) {
+ this.setAttribute("value", cal.dtz.formatter.formatDateWithoutYear(this.mDate));
+ } else {
+ this.setAttribute("value", aDate.day);
+ }
+ }
+
+ addItem(aItem) {
+ if (aItem.hashId in this.mItemHash) {
+ this.removeItem(aItem);
+ }
+
+ let cssSafeId = cal.view.formatStringForCSSRule(aItem.calendar.id);
+ let box = document.createXULElement("calendar-month-day-box-item");
+ let context = this.getAttribute("item-context") || this.getAttribute("context");
+ box.setAttribute("context", context);
+ box.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeId}-backcolor)`);
+ box.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeId}-forecolor)`);
+
+ let listItemWrapper = document.createElement("li");
+ listItemWrapper.classList.add("calendar-month-day-box-list-item");
+ listItemWrapper.appendChild(box);
+ cal.data.binaryInsertNode(
+ this.dayList,
+ listItemWrapper,
+ aItem,
+ cal.view.compareItems,
+ false,
+ // Access the calendar item from a list item wrapper.
+ wrapper => wrapper.firstChild.item
+ );
+
+ box.calendarView = this.calendarView;
+ box.item = aItem;
+ box.parentBox = this;
+ box.occurrence = aItem;
+
+ this.mItemHash[aItem.hashId] = box;
+ return box;
+ }
+
+ selectItem(aItem) {
+ if (aItem.hashId in this.mItemHash) {
+ this.mItemHash[aItem.hashId].selected = true;
+ }
+ }
+
+ unselectItem(aItem) {
+ if (aItem.hashId in this.mItemHash) {
+ this.mItemHash[aItem.hashId].selected = false;
+ }
+ }
+
+ removeItem(aItem) {
+ if (aItem.hashId in this.mItemHash) {
+ // Delete the list item wrapper.
+ let node = this.mItemHash[aItem.hashId].parentNode;
+ node.remove();
+ delete this.mItemHash[aItem.hashId];
+ }
+ }
+
+ setDropShadow(on) {
+ let existing = this.dayList.querySelector(".dropshadow");
+ if (on) {
+ if (!existing) {
+ // Insert an empty list item.
+ let dropshadow = document.createElement("li");
+ dropshadow.classList.add("dropshadow", "calendar-month-day-box-list-item");
+ this.dayList.insertBefore(dropshadow, this.dayList.firstElementChild);
+ }
+ } else if (existing) {
+ existing.remove();
+ }
+ }
+
+ onDropItem(aItem) {
+ // When item's timezone is different than the default one, the
+ // item might get moved on a day different than the drop day.
+ // Changing the drop day allows to compensate a possible difference.
+
+ // Figure out if the timezones cause a days difference.
+ let start = (
+ aItem[cal.dtz.startDateProp(aItem)] || aItem[cal.dtz.endDateProp(aItem)]
+ ).clone();
+ let dayboxDate = this.mDate.clone();
+ if (start.timezone != dayboxDate.timezone) {
+ let startInDefaultTz = start.clone().getInTimezone(dayboxDate.timezone);
+ start.isDate = true;
+ startInDefaultTz.isDate = true;
+ startInDefaultTz.timezone = start.timezone;
+ let dayDiff = start.subtractDate(startInDefaultTz);
+ // Change the day where to drop the item.
+ dayboxDate.addDuration(dayDiff);
+ }
+
+ return cal.item.moveToDate(aItem, dayboxDate);
+ }
+
+ onMouseDown(event) {
+ event.stopPropagation();
+ if (this.mDate) {
+ this.calendarView.selectedDay = this.mDate;
+ }
+ }
+
+ onDblClick(event) {
+ event.stopPropagation();
+ this.calendarView.controller.createNewEvent();
+ }
+
+ onClick(event) {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (!(event.ctrlKey || event.metaKey)) {
+ this.calendarView.setSelectedItems([]);
+ }
+ }
+
+ onWheel(event) {
+ if (cal.view.getParentNodeOrThisByAttribute(event.target, "data-label", "day") == null) {
+ if (this.dayList.scrollHeight > this.dayList.clientHeight) {
+ event.stopPropagation();
+ }
+ }
+ }
+ }
+
+ customElements.define("calendar-month-day-box", CalendarMonthDayBox);
+
+ /**
+ * The MozCalendarMonthDayBoxItem widget is used as event item in the
+ * Multiweek and Month views of the calendar. It displays the event name,
+ * alarm icon and the category type color.
+ *
+ * @augments {MozElements.MozCalendarEditableItem}
+ */
+ class MozCalendarMonthDayBoxItem extends MozElements.MozCalendarEditableItem {
+ static get inheritedAttributes() {
+ return {
+ ".alarm-icons-box": "flashing",
+ };
+ }
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+ // NOTE: This is the same structure as EditableItem, except this has a
+ // time label and we are missing the location-desc.
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <html:img class="item-type-icon" alt="" />
+ <html:div class="item-time-label"></html:div>
+ <html:div class="event-name-label"></html:div>
+ <html:input class="plain event-name-input"
+ hidden="hidden"
+ placeholder='${cal.l10n.getCalString("newEvent")}' />
+ <html:div class="alarm-icons-box"></html:div>
+ <html:img class="item-classification-icon" />
+ <html:img class="item-recurrence-icon" />
+ <html:div class="calendar-category-box"></html:div>
+ `)
+ );
+ this.timeLabel = this.querySelector(".item-time-label");
+
+ this.classList.add("calendar-color-box", "calendar-item-flex");
+
+ // We have two event listeners for dragstart. This event listener is for the capturing phase
+ // where we are setting up the document.monthDragEvent which will be used in the event listener
+ // in the bubbling phase which is set up in the calendar-editable-item.
+ this.addEventListener(
+ "dragstart",
+ event => {
+ document.monthDragEvent = this;
+ },
+ true
+ );
+
+ this.style.pointerEvents = "auto";
+ this.setAttribute("tooltip", "itemTooltip");
+ this.addEventNameTextboxListener();
+ this.initializeAttributeInheritance();
+ }
+
+ set occurrence(val) {
+ cal.ASSERT(!this.mOccurrence, "Code changes needed to set the occurrence twice", true);
+ this.mOccurrence = val;
+ let displayTime;
+ if (val.isEvent()) {
+ let type;
+ if (!val.startDate.isDate) {
+ let formatter = cal.dtz.formatter;
+ let parentTime = this.parentBox.date.clone();
+ // Convert to the date-time for the start of the day.
+ parentTime.isDate = false;
+ // NOTE: Since this event was placed in this box, then we should be
+ // able to assume that the event starts before or on the same day, and
+ // it ends after or on the same day.
+ let startCompare = val.startDate.compare(parentTime);
+ // Go to the end of the day (midnight).
+ parentTime.day++;
+ let endCompare = val.endDate.compare(parentTime);
+ if (startCompare == -1) {
+ // Starts before this day.
+ switch (endCompare) {
+ case 1: // Ends on a later day.
+ type = "continue";
+ // We have no time to show in this case.
+ break;
+ case 0: // Ends at midnight.
+ case -1: // Ends on this day.
+ type = "end";
+ displayTime = formatter.formatTime(
+ val.endDate.getInTimezone(this.parentBox.date.timezone),
+ // We prefer to show midnight as 24:00 if possible to indicate
+ // that the event ends at the end of this day, rather than the
+ // start of the next day.
+ true
+ );
+ break;
+ }
+ } else {
+ // Starts on this day.
+ if (endCompare == 1) {
+ // Ends on a later day.
+ type = "start";
+ }
+ // Use the same format as ending on the day.
+ displayTime = formatter.formatTime(
+ val.startDate.getInTimezone(this.parentBox.date.timezone)
+ );
+ }
+ }
+ let icon = this.querySelector(".item-type-icon");
+ icon.classList.toggle("rotated-to-read-direction", !!type);
+ switch (type) {
+ case "start":
+ icon.setAttribute("src", "chrome://calendar/skin/shared/event-start.svg");
+ document.l10n.setAttributes(icon, "calendar-editable-item-multiday-event-icon-start");
+ break;
+ case "continue":
+ icon.setAttribute("src", "chrome://calendar/skin/shared/event-continue.svg");
+ document.l10n.setAttributes(
+ icon,
+ "calendar-editable-item-multiday-event-icon-continue"
+ );
+ break;
+ case "end":
+ icon.setAttribute("src", "chrome://calendar/skin/shared/event-end.svg");
+ document.l10n.setAttributes(icon, "calendar-editable-item-multiday-event-icon-end");
+ break;
+ default:
+ icon.removeAttribute("src");
+ icon.removeAttribute("data-l10n-id");
+ icon.setAttribute("alt", "");
+ }
+ }
+
+ if (displayTime) {
+ this.timeLabel.textContent = displayTime;
+ this.timeLabel.hidden = false;
+ } else {
+ this.timeLabel.textContent = "";
+ this.timeLabel.hidden = true;
+ }
+
+ this.setEditableLabel();
+ this.setCSSClasses();
+ }
+
+ get occurrence() {
+ return this.mOccurrence;
+ }
+ }
+
+ customElements.define("calendar-month-day-box-item", MozCalendarMonthDayBoxItem);
+
+ /**
+ * Abstract base class that is used for the month and multiweek calendar view custom elements.
+ *
+ * @implements {calICalendarView}
+ * @augments {MozElements.CalendarBaseView}
+ * @abstract
+ */
+ class CalendarMonthBaseView extends MozElements.CalendarBaseView {
+ ensureInitialized() {
+ if (this.isInitialized) {
+ return;
+ }
+ super.ensureInitialized();
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <html:table class="mainbox monthtable">
+ <html:thead>
+ <html:tr></html:tr>
+ </html:thead>
+ <html:tbody class="monthbody"></html:tbody>
+ </html:table>
+ `)
+ );
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 150;
+ const scrollEnabled = Services.prefs.getBoolPref("calendar.view.mousescroll", true);
+ if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey && scrollEnabled) {
+ // In the month view, the only thing that can be scrolled
+ // is the month the user is in. calendar-base-view takes care of
+ // the shift key, so only move the view when no modifier is pressed.
+ let deltaView = 0;
+ if (event.deltaMode == event.DOM_DELTA_LINE) {
+ if (event.deltaY != 0) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ this.mPixelScrollDelta += event.deltaY;
+ if (this.mPixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.mPixelScrollDelta = 0;
+ } else if (this.mPixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.mPixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveView(deltaView);
+ }
+ }
+ });
+
+ this.mDateBoxes = null;
+ this.mSelectedDayBox = null;
+
+ this.mShowFullMonth = true;
+ this.mShowWeekNumber = true;
+
+ this.mClickedTime = null;
+
+ let dayHeaderRow = this.querySelector("thead > tr");
+ this.dayHeaders = new Array(7);
+ for (let i = 0; i < 7; i++) {
+ let hdr = document.createXULElement("calendar-day-label");
+ let headerCell = document.createElement("th");
+ headerCell.setAttribute("scope", "col");
+ // NOTE: At the time of implementation, the natural columnheader role is
+ // lost, probably from setting the CSS display of the container table
+ // and row (Bug 1711273).
+ // For now, we restore the role explicitly.
+ headerCell.setAttribute("role", "columnheader");
+ headerCell.appendChild(hdr);
+ this.dayHeaders[i] = hdr;
+ dayHeaderRow.appendChild(headerCell);
+ hdr.weekDay = (i + this.weekStartOffset) % 7;
+ hdr.shortWeekNames = false;
+ hdr.style.gridRow = 1;
+ }
+
+ this.monthbody = this.querySelector(".monthbody");
+ for (let week = 1; week <= 6; week++) {
+ let weekRow = document.createElement("tr");
+ for (let day = 1; day <= 7; day++) {
+ let dayCell = document.createElement("td");
+ let dayContent = document.createXULElement("calendar-month-day-box");
+ dayCell.appendChild(dayContent);
+ weekRow.appendChild(dayCell);
+ // Set the grid row for the element. This is needed to ensure the
+ // elements appear on different lines. We don't set the gridColumn
+ // because some days may become hidden.
+ dayContent.style.gridRow = week + 1;
+ }
+ this.monthbody.appendChild(weekRow);
+ }
+
+ // Set the preference for displaying the week number.
+ this.mShowWeekNumber = Services.prefs.getBoolPref(
+ "calendar.view-minimonth.showWeekNumber",
+ true
+ );
+ }
+
+ // calICalendarView Properties
+
+ get supportsDisjointDates() {
+ return false;
+ }
+
+ get hasDisjointDates() {
+ return false;
+ }
+
+ set selectedDay(day) {
+ if (this.mSelectedDayBox) {
+ this.mSelectedDayBox.selected = false;
+ }
+
+ let realDay = day;
+ if (!realDay.isDate) {
+ realDay = day.clone();
+ realDay.isDate = true;
+ }
+ const box = this.findDayBoxForDate(realDay);
+ if (box) {
+ box.selected = true;
+ this.mSelectedDayBox = box;
+ }
+ this.fireEvent("dayselect", realDay);
+ }
+
+ get selectedDay() {
+ if (this.mSelectedDayBox) {
+ return this.mSelectedDayBox.date.clone();
+ }
+
+ return null;
+ }
+
+ // End calICalendarView Properties
+
+ set selectedDateTime(dateTime) {
+ this.mClickedTime = dateTime;
+ }
+
+ get selectedDateTime() {
+ return cal.dtz.getDefaultStartDate(this.selectedDay);
+ }
+
+ set showFullMonth(showFullMonth) {
+ this.mShowFullMonth = showFullMonth;
+ }
+
+ get showFullMonth() {
+ return this.mShowFullMonth;
+ }
+
+ // This property may be overridden by subclasses if needed.
+ set weeksInView(weeksInView) {}
+
+ get weeksInView() {
+ return 0;
+ }
+
+ // calICalendarView Methods
+
+ setSelectedItems(items, suppressEvent) {
+ if (this.mSelectedItems.length) {
+ for (const item of this.mSelectedItems) {
+ const oldboxes = this.findDayBoxesForItem(item);
+ for (const oldbox of oldboxes) {
+ oldbox.unselectItem(item);
+ }
+ }
+ }
+
+ this.mSelectedItems = items || [];
+
+ if (this.mSelectedItems.length) {
+ for (const item of this.mSelectedItems) {
+ const newboxes = this.findDayBoxesForItem(item);
+ for (const newbox of newboxes) {
+ newbox.selectItem(item);
+ }
+ }
+ }
+
+ if (!suppressEvent) {
+ this.fireEvent("itemselect", this.mSelectedItems);
+ }
+ }
+
+ centerSelectedItems() {}
+
+ showDate(date) {
+ if (date) {
+ this.setDateRange(date.startOfMonth, date.endOfMonth);
+ this.selectedDay = date;
+ } else {
+ this.setDateRange(this.rangeStartDate, this.rangeEndDate);
+ }
+ }
+
+ setDateRange(startDate, endDate) {
+ this.rangeStartDate = startDate;
+ this.rangeEndDate = endDate;
+
+ const viewStart = cal.weekInfoService.getStartOfWeek(startDate.getInTimezone(this.mTimezone));
+
+ const viewEnd = cal.weekInfoService.getEndOfWeek(endDate.getInTimezone(this.mTimezone));
+
+ viewStart.isDate = true;
+ viewStart.makeImmutable();
+ viewEnd.isDate = true;
+ viewEnd.makeImmutable();
+
+ this.mStartDate = viewStart;
+ this.mEndDate = viewEnd;
+
+ // The start and end dates to query calendars with (in CalendarFilteredViewMixin).
+ this.startDate = viewStart;
+ let viewEndPlusOne = viewEnd.clone();
+ viewEndPlusOne.day++;
+ this.endDate = viewEndPlusOne;
+
+ // Check values of tasksInView, workdaysOnly, showCompleted.
+ // See setDateRange comment in calendar-multiday-base-view.js.
+ let toggleStatus = 0;
+
+ if (this.mTasksInView) {
+ toggleStatus |= this.mToggleStatusFlag.TasksInView;
+ }
+ if (this.mWorkdaysOnly) {
+ toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly;
+ }
+ if (this.mShowCompleted) {
+ toggleStatus |= this.mToggleStatusFlag.ShowCompleted;
+ }
+
+ // Update the navigation bar only when changes are related to the current view.
+ if (this.isVisible()) {
+ calendarNavigationBar.setDateRange(startDate, endDate);
+ }
+
+ // Check whether view range has been changed since last call to relayout().
+ if (
+ !this.mViewStart ||
+ !this.mViewEnd ||
+ this.mViewEnd.compare(viewEnd) != 0 ||
+ this.mViewStart.compare(viewStart) != 0 ||
+ this.mToggleStatus != toggleStatus
+ ) {
+ this.relayout();
+ }
+ }
+
+ getDateList() {
+ if (!this.mStartDate || !this.mEndDate) {
+ return [];
+ }
+
+ const results = [];
+ const curDate = this.mStartDate.clone();
+ curDate.isDate = true;
+
+ while (curDate.compare(this.mEndDate) <= 0) {
+ results.push(curDate.clone());
+ curDate.day += 1;
+ }
+ return results;
+ }
+
+ // End calICalendarView Methods
+
+ /**
+ * Set an attribute on the view element, and do re-layout if needed.
+ *
+ * @param {string} attr - The attribute to set.
+ * @param {string} value - The value to set.
+ */
+ setAttribute(attr, value) {
+ const needsRelayout = attr == "context" || attr == "item-context";
+
+ const ret = XULElement.prototype.setAttribute.call(this, attr, value);
+
+ if (needsRelayout) {
+ this.relayout();
+ }
+
+ return ret;
+ }
+
+ /**
+ * Handle preference changes. Typically called by a preference observer.
+ *
+ * @param {object} subject - The subject, a prefs object.
+ * @param {string} topic - The notification topic.
+ * @param {string} preference - The preference to handle.
+ */
+ handlePreference(subject, topic, preference) {
+ subject.QueryInterface(Ci.nsIPrefBranch);
+
+ switch (preference) {
+ case "calendar.previousweeks.inview":
+ this.updateDaysOffPrefs();
+ this.refreshView();
+ break;
+
+ case "calendar.week.start":
+ // Refresh the view so the settings take effect.
+ this.refreshView();
+ break;
+
+ case "calendar.weeks.inview":
+ this.weeksInView = subject.getIntPref(preference);
+ break;
+
+ case "calendar.view-minimonth.showWeekNumber":
+ this.mShowWeekNumber = subject.getBoolPref(preference);
+ if (this.mShowWeekNumber) {
+ this.refreshView();
+ } else {
+ this.hideWeekNumbers();
+ }
+ break;
+
+ default:
+ this.handleCommonPreference(subject, topic, preference);
+ break;
+ }
+ }
+
+ /**
+ * Guarantee that the labels are clipped when an overflow occurs, to
+ * prevent horizontal scrollbars from appearing briefly.
+ */
+ adjustWeekdayLength() {
+ let dayLabels = this.querySelectorAll("calendar-day-label");
+ if (!this.longWeekdayTotalPixels) {
+ let maxDayWidth = 0;
+
+ for (const label of dayLabels) {
+ label.shortWeekNames = false;
+ maxDayWidth = Math.max(maxDayWidth, label.getLongWeekdayPixels());
+ }
+ if (maxDayWidth > 0) {
+ // FIXME: Where does the + 10 come from?
+ this.longWeekdayTotalPixels = maxDayWidth * dayLabels.length + 10;
+ } else {
+ this.longWeekdayTotalPixels = 0;
+ }
+ }
+ let useShortNames = this.longWeekdayTotalPixels > 0.95 * this.clientWidth;
+
+ for (let label of dayLabels) {
+ label.shortWeekNames = useShortNames;
+ }
+ }
+
+ /**
+ * Handle resizing by adjusting the view to the new size.
+ *
+ * @param {Element} viewElement - A calendar view element (calICalendarView).
+ */
+ onResize() {
+ let { width, height } = this.getBoundingClientRect();
+ if (width == this.mWidth && height == this.mHeight) {
+ // Return early if we're still the previous size.
+ return;
+ }
+ this.mWidth = width;
+ this.mHeight = height;
+
+ this.adjustWeekdayLength();
+ }
+
+ /**
+ * Re-render the view.
+ */
+ relayout() {
+ // Adjust headers based on the starting day of the week, if necessary.
+ if (this.dayHeaders[0].weekDay != this.weekStartOffset) {
+ for (let i = 0; i < this.dayHeaders.length; i++) {
+ this.dayHeaders[i].weekDay = (i + this.weekStartOffset) % 7;
+ }
+ }
+
+ if (this.mSelectedItems.length) {
+ this.mSelectedItems = [];
+ }
+
+ if (!this.mStartDate || !this.mEndDate) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ // Days that are not in the main month on display are displayed with
+ // a gray background. Unless the month actually starts on a Sunday,
+ // this means that mStartDate.month is 1 month less than the main month.
+ let mainMonth = this.mStartDate.month;
+ if (this.mStartDate.day != 1) {
+ mainMonth++;
+ mainMonth = mainMonth % 12;
+ }
+
+ const dateBoxes = [];
+
+ // This gets set to true, telling us to collapse the rest of the rows.
+ let finished = false;
+ const dateList = this.getDateList();
+
+ // This allows finding the first column of dayboxes where to set the
+ // week labels, taking into account whether days-off are displayed or not.
+ let weekLabelColumnPos = -1;
+
+ const rows = this.monthbody.children;
+
+ // Iterate through each monthbody row and set up the day-boxes that
+ // are its child nodes. Remember, children is not a normal array,
+ // so don't use the in operator if you don't want extra properties
+ // coming out.
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ // If we've already assigned all of the day-boxes that we need, just
+ // collapse the rest of the rows, otherwise expand them if needed.
+ row.toggleAttribute("hidden", finished);
+ if (finished) {
+ for (let cell of row.cells) {
+ // Clear out the hidden cells for to avoid holding events in memory
+ // for no reason. Also prevents tests failing due to stray event
+ // boxes from months that are no longer displayed.
+ cell.firstElementChild.setDate();
+ }
+ continue;
+ }
+ for (let j = 0; j < row.children.length; j++) {
+ const daybox = row.children[j].firstElementChild;
+ const date = dateList[dateBoxes.length];
+
+ // Remove the attribute "relation" for all the column headers.
+ // Consider only the first row index otherwise it will be
+ // removed again afterwards the correct setting.
+ if (i == 0) {
+ this.dayHeaders[j].removeAttribute("relation");
+ }
+
+ daybox.setAttribute("context", this.getAttribute("context"));
+
+ daybox.setAttribute(
+ "item-context",
+ this.getAttribute("item-context") || this.getAttribute("context")
+ );
+
+ // Set the box-class depending on if this box displays a day in
+ // the month being currently shown or not.
+ let boxClass;
+ if (this.showFullMonth) {
+ boxClass =
+ "calendar-month-day-box-" +
+ (mainMonth == date.month ? "current-month" : "other-month");
+ } else {
+ boxClass = "calendar-month-day-box-current-month";
+ }
+ if (this.mDaysOffArray.some(dayOffNum => dayOffNum == date.weekday)) {
+ boxClass = "calendar-month-day-box-day-off " + boxClass;
+ }
+
+ // Set up label with the week number in the first day of the row.
+ if (this.mShowWeekNumber) {
+ const weekLabel = daybox.querySelector("[data-label='week']");
+ if (weekLabelColumnPos < 0) {
+ const isDayOff = this.mDaysOffArray.includes((j + this.weekStartOffset) % 7);
+ if (this.mDisplayDaysOff || !isDayOff) {
+ weekLabelColumnPos = j;
+ }
+ }
+ // Build and set the label.
+ if (j == weekLabelColumnPos) {
+ weekLabel.removeAttribute("hidden");
+ const weekNumber = cal.weekInfoService.getWeekTitle(date);
+ const weekString = cal.l10n.getCalString("multiweekViewWeek", [weekNumber]);
+ weekLabel.textContent = weekString;
+ } else {
+ weekLabel.hidden = true;
+ }
+ }
+
+ daybox.setAttribute("class", boxClass);
+
+ daybox.calendarView = this;
+ daybox.showMonthLabel = date.day == 1 || date.day == date.endOfMonth.day;
+ daybox.date = date;
+ dateBoxes.push(daybox);
+
+ // If we've now assigned all of our dates, set this to true so we
+ // know we can just collapse the rest of the rows.
+ if (dateBoxes.length == dateList.length) {
+ finished = true;
+ }
+ }
+ }
+
+ // If we're not showing a full month, then add a few extra labels to
+ // help the user orient themselves in the view.
+ if (!this.mShowFullMonth) {
+ dateBoxes[0].showMonthLabel = true;
+ dateBoxes[dateBoxes.length - 1].showMonthLabel = true;
+ }
+
+ // Store these, so that we can access them later.
+ this.mDateBoxes = dateBoxes;
+ this.setDateBoxRelations();
+ this.hideDaysOff();
+
+ this.adjustWeekdayLength();
+
+ // Store the start and end of current view. Next time when
+ // setDateRange is called, it will use mViewStart and mViewEnd to
+ // check if view range has been changed.
+ this.mViewStart = this.mStartDate;
+ this.mViewEnd = this.mEndDate;
+
+ // Store toggle status of current view.
+ let toggleStatus = 0;
+
+ if (this.mTasksInView) {
+ toggleStatus |= this.mToggleStatusFlag.TasksInView;
+ }
+ if (this.mWorkdaysOnly) {
+ toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly;
+ }
+ if (this.mShowCompleted) {
+ toggleStatus |= this.mToggleStatusFlag.ShowCompleted;
+ }
+
+ this.mToggleStatus = toggleStatus;
+ this.refreshItems(true);
+ }
+
+ /**
+ * Marks the box for today and the header for the current day of the week.
+ */
+ setDateBoxRelations() {
+ const today = this.today();
+
+ for (let header of this.dayHeaders) {
+ if (header.weekDay == today.weekday) {
+ header.setAttribute("relation", "today");
+ } else {
+ header.removeAttribute("relation");
+ }
+ }
+
+ for (let daybox of this.mDateBoxes) {
+ // Set up date relations.
+ switch (daybox.mDate.compare(today)) {
+ case -1:
+ daybox.setAttribute("relation", "past");
+ break;
+ case 0:
+ daybox.setAttribute("relation", "today");
+ break;
+ case 1:
+ daybox.setAttribute("relation", "future");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Hide the week numbers.
+ */
+ hideWeekNumbers() {
+ const rows = this.monthbody.children;
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ for (let j = 0; j < row.children.length; j++) {
+ const daybox = row.children[j].firstElementChild;
+ const weekLabel = daybox.querySelector("[data-label='week']");
+ weekLabel.hidden = true;
+ }
+ }
+ }
+
+ /**
+ * Hide the days off.
+ */
+ hideDaysOff() {
+ const rows = this.monthbody.children;
+
+ const lastColNum = rows[0].children.length - 1;
+ for (let colNum = 0; colNum <= lastColNum; colNum++) {
+ const dayForColumn = (colNum + this.weekStartOffset) % 7;
+ const dayOff = this.mDaysOffArray.includes(dayForColumn) && !this.mDisplayDaysOff;
+ // Set the hidden attribute on the parentNode td.
+ this.dayHeaders[colNum].parentNode.toggleAttribute("hidden", dayOff);
+ for (let row of rows) {
+ row.children[colNum].toggleAttribute("hidden", dayOff);
+ }
+ }
+ }
+
+ /**
+ * Return the day box element for a given date.
+ *
+ * @param {calIDateTime} date - A date.
+ * @returns {?Element} A `calendar-month-day-box` element.
+ */
+ findDayBoxForDate(date) {
+ if (!this.mDateBoxes) {
+ return null;
+ }
+ for (const box of this.mDateBoxes) {
+ if (box.mDate.compare(date) == 0) {
+ return box;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the day box elements for a given calendar item.
+ *
+ * @param {calIItemBase} item - A calendar item.
+ * @returns {Element[]} An array of `calendar-month-day-box` elements.
+ */
+ findDayBoxesForItem(item) {
+ let targetDate = null;
+ let finishDate = null;
+ const boxes = [];
+
+ // All our boxes are in default time zone, so we need these times in them too.
+ if (item.isEvent()) {
+ targetDate = item.startDate.getInTimezone(this.mTimezone);
+ finishDate = item.endDate.getInTimezone(this.mTimezone);
+ } else if (item.isTodo()) {
+ // Consider tasks without entry OR due date.
+ if (item.entryDate || item.dueDate) {
+ targetDate = (item.entryDate || item.dueDate).getInTimezone(this.mTimezone);
+ finishDate = (item.dueDate || item.entryDate).getInTimezone(this.mTimezone);
+ }
+ }
+
+ if (!targetDate) {
+ return boxes;
+ }
+
+ if (!finishDate) {
+ const maybeBox = this.findDayBoxForDate(targetDate);
+ if (maybeBox) {
+ boxes.push(maybeBox);
+ }
+ return boxes;
+ }
+
+ if (targetDate.compare(this.mStartDate) < 0) {
+ targetDate = this.mStartDate.clone();
+ }
+
+ if (finishDate.compare(this.mEndDate) > 0) {
+ finishDate = this.mEndDate.clone();
+ finishDate.day++;
+ }
+
+ // Reset the time to 00:00, so that we really get all the boxes.
+ targetDate.isDate = false;
+ targetDate.hour = 0;
+ targetDate.minute = 0;
+ targetDate.second = 0;
+
+ if (targetDate.compare(finishDate) == 0) {
+ // We have also to handle zero length events in particular for
+ // tasks without entry or due date.
+ const box = this.findDayBoxForDate(targetDate);
+ if (box) {
+ boxes.push(box);
+ }
+ }
+
+ while (targetDate.compare(finishDate) == -1) {
+ const box = this.findDayBoxForDate(targetDate);
+
+ // This might not exist if the event spans the view start or end.
+ if (box) {
+ boxes.push(box);
+ }
+ targetDate.day += 1;
+ }
+
+ return boxes;
+ }
+
+ /**
+ * Display a calendar item.
+ *
+ * @param {calIItemBase} item - A calendar item.
+ */
+ doAddItem(item) {
+ this.findDayBoxesForItem(item).forEach(box => box.addItem(item));
+ }
+
+ /**
+ * Remove a calendar item so it is no longer displayed.
+ *
+ * @param {calIItemBase} item - A calendar item.
+ */
+ doRemoveItem(item) {
+ const boxes = this.findDayBoxesForItem(item);
+
+ if (!boxes.length) {
+ return;
+ }
+
+ const oldLength = this.mSelectedItems.length;
+
+ const isNotItem = a => a.hashId != item.hashId;
+ this.mSelectedItems = this.mSelectedItems.filter(isNotItem);
+
+ boxes.forEach(box => box.removeItem(item));
+
+ // If a deleted event was selected, announce that the selection changed.
+ if (oldLength != this.mSelectedItems.length) {
+ this.fireEvent("itemselect", this.mSelectedItems);
+ }
+ }
+
+ // CalendarFilteredViewMixin implementation.
+
+ /**
+ * Removes all items so they are no longer displayed.
+ */
+ clearItems() {
+ for (let dayBox of this.querySelectorAll("calendar-month-day-box")) {
+ dayBox.clear();
+ }
+ }
+
+ /**
+ * Remove all items for a given calendar so they are no longer displayed.
+ *
+ * @param {string} calendarId - The ID of the calendar to remove items from.
+ */
+ removeItemsFromCalendar(calendarId) {
+ if (!this.mDateBoxes) {
+ return;
+ }
+ for (const box of this.mDateBoxes) {
+ for (const id in box.mItemHash) {
+ const node = box.mItemHash[id];
+ const item = node.item;
+
+ if (item.calendar.id == calendarId) {
+ box.removeItem(item);
+ }
+ }
+ }
+ }
+
+ // End of CalendarFilteredViewMixin implementation.
+
+ /**
+ * Make a calendar item flash. Used when an alarm goes off to make the related item flash.
+ *
+ * @param {object} item - The calendar item to flash.
+ * @param {boolean} stop - Whether to stop flashing that's already started.
+ */
+ flashAlarm(item, stop) {
+ if (!this.mStartDate || !this.mEndDate) {
+ return;
+ }
+
+ const showIndicator = Services.prefs.getBoolPref("calendar.alarms.indicator.show", true);
+ const totaltime = Services.prefs.getIntPref("calendar.alarms.indicator.totaltime", 3600);
+
+ if (!stop && (!showIndicator || totaltime < 1)) {
+ // No need to animate if the indicator should not be shown.
+ return;
+ }
+
+ // Make sure the flashing attribute is set or reset on all visible boxes.
+ const boxes = this.findDayBoxesForItem(item);
+ for (const box of boxes) {
+ for (const id in box.mItemHash) {
+ const itemData = box.mItemHash[id];
+
+ if (itemData.item.hasSameIds(item)) {
+ if (stop) {
+ itemData.removeAttribute("flashing");
+ } else {
+ itemData.setAttribute("flashing", "true");
+ }
+ }
+ }
+ }
+
+ if (stop) {
+ // We are done flashing, prevent newly created event boxes from flashing.
+ delete this.mFlashingEvents[item.hashId];
+ } else {
+ // Set up a timer to stop the flashing after the total time.
+ this.mFlashingEvents[item.hashId] = item;
+ setTimeout(() => this.flashAlarm(item, true), totaltime);
+ }
+ }
+ }
+
+ MozElements.CalendarMonthBaseView = CalendarMonthBaseView;
+}
diff --git a/comm/calendar/base/content/calendar-multiday-view.js b/comm/calendar/base/content/calendar-multiday-view.js
new file mode 100644
index 0000000000..041c8ae335
--- /dev/null
+++ b/comm/calendar/base/content/calendar-multiday-view.js
@@ -0,0 +1,3512 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from widgets/mouseoverPreviews.js */
+/* import-globals-from calendar-ui-utils.js */
+
+/* global calendarNavigationBar, currentView, gCurrentMode, getSelectedCalendar,
+ invokeEventDragSession, MozElements, MozXULElement, timeIndicator */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ const MINUTES_IN_DAY = 24 * 60;
+
+ /**
+ * Get the nearest or next snap point for the given minute. The set of snap
+ * points is given by `n * snapInterval`, where `n` is some integer.
+ *
+ * @param {number} minute - The minute to snap.
+ * @param {number} snapInterval - The integer number of minutes between snap
+ * points.
+ * @param {"nearest","forward","backward"} [direction="nearest"] - Where to
+ * find the snap point. "nearest" will return the closest snap point,
+ * "forward" will return the closest snap point that is greater (and not
+ * equal), and "backward" will return the closest snap point that is lower
+ * (and not equal).
+ *
+ * @returns {number} - The nearest snap point.
+ */
+ function snapMinute(minute, snapInterval, direction = "nearest") {
+ switch (direction) {
+ case "forward":
+ return Math.floor((minute + snapInterval) / snapInterval) * snapInterval;
+ case "backward":
+ return Math.ceil((minute - snapInterval) / snapInterval) * snapInterval;
+ case "nearest":
+ return Math.round(minute / snapInterval) * snapInterval;
+ default:
+ throw new RangeError(`"${direction}" is not one of the allowed values for the direction`);
+ }
+ }
+
+ /**
+ * Determine whether the given event item can be edited by the user.
+ *
+ * @param {calItemBase} eventItem - The event item.
+ *
+ * @returns {boolean} - Whether the given event can be edited by the user.
+ */
+ function canEditEventItem(eventItem) {
+ return (
+ cal.acl.isCalendarWritable(eventItem.calendar) &&
+ cal.acl.userCanModifyItem(eventItem) &&
+ !(
+ eventItem.calendar instanceof Ci.calISchedulingSupport &&
+ eventItem.calendar.isInvitation(eventItem)
+ ) &&
+ eventItem.calendar.getProperty("capabilities.events.supported") !== false
+ );
+ }
+
+ /**
+ * The MozCalendarEventColumn widget used for displaying event boxes in one column per day.
+ * It is used to make the week view layout in the calendar. It manages the layout of the
+ * events given via add/deleteEvent.
+ */
+ class MozCalendarEventColumn extends MozXULElement {
+ static get inheritedAttributes() {
+ return {
+ ".multiday-events-list": "context",
+ ".timeIndicator": "orient",
+ };
+ }
+
+ /**
+ * The background hour box elements this event column owns, ordered and
+ * indexed by their starting hour.
+ *
+ * @type {Element[]}
+ */
+ hourBoxes = [];
+
+ /**
+ * The date of the day this event column represents.
+ *
+ * @type {calIDateTime}
+ */
+ date;
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <stack class="multiday-column-box-stack" flex="1">
+ <html:div class="multiday-hour-box-container"></html:div>
+ <html:ol class="multiday-events-list"></html:ol>
+ <box class="timeIndicator" hidden="true"/>
+ <box class="fgdragcontainer" flex="1">
+ <box class="fgdragspacer">
+ <spacer flex="1"/>
+ <label class="fgdragbox-label fgdragbox-startlabel"/>
+ </box>
+ <box class="fgdragbox"/>
+ <label class="fgdragbox-label fgdragbox-endlabel"/>
+ </box>
+ </stack>
+ <calendar-event-box hidden="true"/>
+ `)
+ );
+ this.hourBoxContainer = this.querySelector(".multiday-hour-box-container");
+ for (let hour = 0; hour < 24; hour++) {
+ let hourBox = document.createElement("div");
+ hourBox.classList.add("multiday-hour-box");
+ this.hourBoxContainer.appendChild(hourBox);
+ this.hourBoxes.push(hourBox);
+ }
+
+ this.eventsListElement = this.querySelector(".multiday-events-list");
+
+ this.addEventListener("dblclick", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (this.calendarView.controller) {
+ event.stopPropagation();
+ this.calendarView.controller.createNewEvent(null, this.getMouseDateTime(event), null);
+ }
+ });
+
+ this.addEventListener("click", event => {
+ if (event.button != 0 || event.ctrlKey || event.metaKey) {
+ return;
+ }
+ this.calendarView.setSelectedItems([]);
+ this.focus();
+ });
+
+ // Mouse down handler, in empty event column regions. Starts sweeping out a new event.
+ this.addEventListener("mousedown", event => {
+ // Select this column.
+ this.calendarView.selectedDay = this.date;
+
+ // If the selected calendar is readOnly, we don't want any sweeping.
+ let calendar = getSelectedCalendar();
+ if (
+ !cal.acl.isCalendarWritable(calendar) ||
+ calendar.getProperty("capabilities.events.supported") === false
+ ) {
+ return;
+ }
+
+ if (event.button == 2) {
+ // Set a selected datetime for the context menu.
+ this.calendarView.selectedDateTime = this.getMouseDateTime(event);
+ return;
+ }
+ // Only start sweeping out an event if the left button was clicked.
+ if (event.button != 0) {
+ return;
+ }
+
+ this.mDragState = {
+ origColumn: this,
+ dragType: "new",
+ mouseMinuteOffset: 0,
+ offset: null,
+ shadows: null,
+ limitStartMin: null,
+ limitEndMin: null,
+ jumpedColumns: 0,
+ };
+
+ // Snap interval: 15 minutes or 1 minute if modifier key is pressed.
+ this.mDragState.origMin = snapMinute(
+ this.getMouseMinute(event),
+ event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15
+ );
+
+ if (this.getAttribute("orient") == "vertical") {
+ this.mDragState.origLoc = event.clientY;
+ this.mDragState.limitEndMin = this.mDragState.origMin;
+ this.mDragState.limitStartMin = this.mDragState.origMin;
+ this.fgboxes.dragspacer.setAttribute(
+ "height",
+ this.mDragState.origMin * this.pixelsPerMinute
+ );
+ } else {
+ this.mDragState.origLoc = event.clientX;
+ this.fgboxes.dragspacer.setAttribute(
+ "width",
+ this.mDragState.origMin * this.pixelsPerMinute
+ );
+ }
+
+ document.calendarEventColumnDragging = this;
+
+ window.addEventListener("mousemove", this.onEventSweepMouseMove);
+ window.addEventListener("mouseup", this.onEventSweepMouseUp);
+ window.addEventListener("keypress", this.onEventSweepKeypress);
+ });
+
+ /**
+ * An internal collection of data for events.
+ *
+ * @typedef {object} EventData
+ * @property {calItemBase} eventItem - The event item.
+ * @property {Element} element - The displayed event in this column.
+ * @property {boolean} selected - Whether the event is selected.
+ * @property {boolean} needsUpdate - True whilst the eventItem has changed
+ * and we are still pending updating the 'element' property.
+ */
+ /**
+ * Event data for all the events displayed in this column.
+ *
+ * @type {Map<string, EventData} - A map from an event item's hashId to
+ * its data.
+ */
+ this.eventDataMap = new Map();
+
+ this.mCalendarView = null;
+
+ this.mDragState = null;
+
+ this.mLayoutBatchCount = 0;
+
+ // Since we'll often be getting many events in rapid succession, this
+ // timer helps ensure that we don't re-compute the event map too many
+ // times in a short interval, and therefore improves performance.
+ this.mEventMapTimeout = null;
+
+ // Whether the next added event should be created in the editing state.
+ this.newEventNeedsEditing = false;
+ // The hashId of the event we should set to editing in the next relayout.
+ this.eventToEdit = null;
+
+ this.mSelected = false;
+
+ this.mFgboxes = null;
+
+ this.initializeAttributeInheritance();
+ }
+
+ /**
+ * The number of pixels that a one minute duration should occupy in the
+ * column.
+ *
+ * @type {number}
+ */
+ set pixelsPerMinute(val) {
+ this._pixelsPerMinute = val;
+ this.relayout();
+ }
+
+ get pixelsPerMinute() {
+ return this._pixelsPerMinute;
+ }
+
+ set calendarView(val) {
+ this.mCalendarView = val;
+ }
+
+ get calendarView() {
+ return this.mCalendarView;
+ }
+
+ get fgboxes() {
+ if (this.mFgboxes == null) {
+ this.mFgboxes = {
+ box: this.querySelector(".fgdragcontainer"),
+ dragbox: this.querySelector(".fgdragbox"),
+ dragspacer: this.querySelector(".fgdragspacer"),
+ startlabel: this.querySelector(".fgdragbox-startlabel"),
+ endlabel: this.querySelector(".fgdragbox-endlabel"),
+ };
+ }
+ return this.mFgboxes;
+ }
+
+ get timeIndicatorBox() {
+ return this.querySelector(".timeIndicator");
+ }
+
+ get events() {
+ return this.methods;
+ }
+
+ /**
+ * Set whether the calendar-event-box element for the given event item
+ * should be displayed as selected or unselected.
+ *
+ * @param {calItemBase} eventItem - The event item.
+ * @param {boolean} select - Whether to show the corresponding event element
+ * as selected.
+ */
+ selectEvent(eventItem, select) {
+ let data = this.eventDataMap.get(eventItem.hashId);
+ if (!data) {
+ return;
+ }
+ data.selected = select;
+ if (data.element) {
+ // There is a small window between an event item being added and it
+ // actually having an element. If it doesn't have an element yet, it
+ // will be selected on its creation instead.
+ data.element.selected = select;
+ }
+ }
+
+ /**
+ * Return the displayed calendar-event-box element for the given event item.
+ *
+ * @param {calItemBase} eventItem - The event item.
+ *
+ * @returns {Element} - The corresponding element, or undefined if none.
+ */
+ findElementForEventItem(eventItem) {
+ return this.eventDataMap.get(eventItem.hashId)?.element;
+ }
+
+ /**
+ * Return all the event items that are displayed in this columns.
+ *
+ * @returns {calItemBase[]} - An array of all the displayed event items.
+ */
+ getAllEventItems() {
+ return Array.from(this.eventDataMap.values(), data => data.eventItem);
+ }
+
+ startLayoutBatchChange() {
+ this.mLayoutBatchCount++;
+ }
+
+ endLayoutBatchChange() {
+ this.mLayoutBatchCount--;
+ if (this.mLayoutBatchCount == 0) {
+ this.relayout();
+ }
+ }
+
+ setAttribute(attr, val) {
+ // this should be done using lookupMethod(), see bug 286629
+ let ret = super.setAttribute(attr, val);
+
+ if (attr == "orient" && this.getAttribute("orient") != val) {
+ this.relayout();
+ }
+
+ return ret;
+ }
+
+ /**
+ * Create or update a displayed calendar-event-box element for the given
+ * event item.
+ *
+ * @param {calItemBase} eventItem - The event item to create or update an
+ * element for.
+ */
+ addEvent(eventItem) {
+ let eventData = this.eventDataMap.get(eventItem.hashId);
+ if (!eventData) {
+ // New event with no pre-existing data.
+ eventData = { selected: false };
+ this.eventDataMap.set(eventItem.hashId, eventData);
+ }
+ eventData.needsUpdate = true;
+
+ // We set the eventItem property here, the rest will be updated in
+ // relayout().
+ // NOTE: If we already have an event with the given hashId, then the
+ // eventData.element will still refer to the previous display of the event
+ // until we call relayout().
+ eventData.eventItem = eventItem;
+
+ if (this.mEventMapTimeout) {
+ clearTimeout(this.mEventMapTimeout);
+ }
+
+ if (this.newEventNeedsEditing) {
+ this.eventToEdit = eventItem.hashId;
+ this.newEventNeedsEditing = false;
+ }
+
+ this.mEventMapTimeout = setTimeout(() => this.relayout(), 5);
+ }
+
+ /**
+ * Remove the displayed calendar-event-box element for the given event item
+ * from this column
+ *
+ * @param {calItemBase} eventItem - The event item to remove the element of.
+ */
+ deleteEvent(eventItem) {
+ if (this.eventDataMap.delete(eventItem.hashId)) {
+ this.relayout();
+ }
+ }
+
+ _clearElements() {
+ while (this.eventsListElement.hasChildNodes()) {
+ this.eventsListElement.lastChild.remove();
+ }
+ }
+
+ /**
+ * Clear the column of all events.
+ */
+ clear() {
+ this._clearElements();
+ this.eventDataMap.clear();
+ }
+
+ relayout() {
+ if (this.mLayoutBatchCount > 0) {
+ return;
+ }
+ this._clearElements();
+
+ let orient = this.getAttribute("orient");
+
+ let configBox = this.querySelector("calendar-event-box");
+ configBox.removeAttribute("hidden");
+ let minSize = configBox.getOptimalMinSize(orient);
+ configBox.setAttribute("hidden", "true");
+ // The minimum event duration in minutes that would give at least the
+ // desired minSize in the layout.
+ let minDuration = Math.ceil(minSize / this.pixelsPerMinute);
+
+ let dayPx = `${MINUTES_IN_DAY * this.pixelsPerMinute}px`;
+ if (orient == "vertical") {
+ this.hourBoxContainer.style.height = dayPx;
+ this.hourBoxContainer.style.width = null;
+ } else {
+ this.hourBoxContainer.style.width = dayPx;
+ this.hourBoxContainer.style.height = null;
+ }
+
+ // 'fgbox' is used for dragging events.
+ this.fgboxes.box.setAttribute("orient", orient);
+ this.querySelector(".fgdragspacer").setAttribute("orient", orient);
+
+ for (let eventData of this.eventDataMap.values()) {
+ if (!eventData.needsUpdate) {
+ continue;
+ }
+ eventData.needsUpdate = false;
+ // Create a new wrapper.
+ let eventElement = document.createElement("li");
+ eventElement.classList.add("multiday-event-listitem");
+ // Set up the event box.
+ let eventBox = document.createXULElement("calendar-event-box");
+ eventElement.appendChild(eventBox);
+
+ // Trigger connectedCallback
+ this.eventsListElement.appendChild(eventElement);
+
+ eventBox.setAttribute(
+ "context",
+ this.getAttribute("item-context") || this.getAttribute("context")
+ );
+
+ eventBox.calendarView = this.calendarView;
+ eventBox.occurrence = eventData.eventItem;
+ eventBox.parentColumn = this;
+ // An event item can technically be 'selected' between a call to
+ // addEvent and this method (because of the setTimeout). E.g. clicking
+ // the event in the unifinder tree will select the item through
+ // selectEvent. If the element wasn't yet created in that method, we set
+ // the selected status here as well.
+ //
+ // Similarly, if an event has the same hashId, we maintain its
+ // selection.
+ // NOTE: In this latter case we are relying on the fact that
+ // eventData.element.selected is never out of sync with
+ // eventData.selected.
+ eventBox.selected = eventData.selected;
+ eventData.element = eventBox;
+
+ // Remove the element to be added again later.
+ eventElement.remove();
+ }
+
+ let eventLayoutList = this.computeEventLayoutInfo(minDuration);
+
+ for (let eventInfo of eventLayoutList) {
+ // Note that we store the calendar-event-box in the eventInfo, so we
+ // grab its parent to get the wrapper list item.
+ // NOTE: This may be a newly created element or a non-updated element
+ // that was removed from the eventsListElement in _clearElements. We
+ // still hold a reference to it, so we can re-add it in the new ordering
+ // and change its dimensions.
+ let eventElement = eventInfo.element.parentNode;
+ // FIXME: offset and length should be in % of parent's dimension, so we
+ // can avoid pixelsPerMinute.
+ let offset = `${eventInfo.start * this.pixelsPerMinute}px`;
+ let length = `${(eventInfo.end - eventInfo.start) * this.pixelsPerMinute}px`;
+ let secondaryOffset = `${eventInfo.secondaryOffset * 100}%`;
+ let secondaryLength = `${eventInfo.secondaryLength * 100}%`;
+ if (orient == "vertical") {
+ eventElement.style.height = length;
+ eventElement.style.width = secondaryLength;
+ eventElement.style.insetBlockStart = offset;
+ eventElement.style.insetInlineStart = secondaryOffset;
+ } else {
+ eventElement.style.width = length;
+ eventElement.style.height = secondaryLength;
+ eventElement.style.insetInlineStart = offset;
+ eventElement.style.insetBlockStart = secondaryOffset;
+ }
+ this.eventsListElement.appendChild(eventElement);
+ }
+
+ let boxToEdit = this.eventDataMap.get(this.eventToEdit)?.element;
+ if (boxToEdit) {
+ boxToEdit.startEditing();
+ }
+ this.eventToEdit = null;
+ }
+
+ /**
+ * Layout information for displaying an event in the calendar column. The
+ * calendar column has two dimensions: a primary-dimension, in minutes,
+ * that runs from the start of the day to the end of the day; and a
+ * secondary-dimension which runs from 0 to 1. This object describes how
+ * an event can be placed on these axes.
+ *
+ * @typedef {object} EventLayoutInfo
+ * @property {MozCalendarEventBox} element - The displayed event.
+ * @property {number} start - The number of minutes from the start of this
+ * column's day to when the event should start.
+ * @property {number} end - The number of minutes from the start of this
+ * column's day to when the event ends.
+ * @property {number} secondaryOffset - The position of the event on the
+ * secondary axis (between 0 and 1).
+ * @property {number} secondaryLength - The length of the event on the
+ * secondary axis (between 0 and 1).
+ */
+ /**
+ * Get an ordered list of events and their layout information. The list is
+ * ordered relative to the event's layout.
+ *
+ * @param {number} minDuration - The minimum number of minutes that an event
+ * should be *shown* to last. This should be large enough to ensure that
+ * events are readable in the layout.
+ *
+ * @returns {EventLayoutInfo[]} - An ordered list of event layout
+ * information.
+ */
+ computeEventLayoutInfo(minDuration) {
+ if (!this.eventDataMap.size) {
+ return [];
+ }
+
+ function sortByStart(aEventInfo, bEventInfo) {
+ // If you pass in tasks without both entry and due dates, I will
+ // kill you.
+ let startComparison = aEventInfo.startDate.compare(bEventInfo.startDate);
+ if (startComparison == 0) {
+ // If the items start at the same time, return the longer one
+ // first.
+ return bEventInfo.endDate.compare(aEventInfo.endDate);
+ }
+ return startComparison;
+ }
+
+ // Construct the ordered list of EventLayoutInfo objects that we will
+ // eventually return.
+ // To begin, we construct the objects with a 'startDate' and 'endDate'
+ // properties, as opposed to using minutes from the start of the day
+ // because we want to sort the events relative to their absolute start
+ // times.
+ let eventList = Array.from(this.eventDataMap.values(), eventData => {
+ let element = eventData.element;
+ let { startDate, endDate, startMinute, endMinute } = element.updateRelativeStartEndDates(
+ this.date
+ );
+ // If there is no startDate, we use the element's endDate for both the
+ // start and the end times. Similarly if there is no endDate. Such items
+ // will automatically have the minimum duration.
+ if (!startDate) {
+ startDate = endDate;
+ startMinute = endMinute;
+ } else if (!endDate) {
+ endDate = startDate;
+ endMinute = startMinute;
+ }
+ // Any events that start or end on a different day are clipped to the
+ // start/end minutes of this day instead.
+ let start = Math.max(startMinute, 0);
+ // NOTE: The end can overflow the end of the day due to the minDuration.
+ let end = Math.max(start + minDuration, Math.min(endMinute, MINUTES_IN_DAY));
+ return { element, startDate, endDate, start, end };
+ });
+ eventList.sort(sortByStart);
+
+ // Some Events in the calendar column will overlap in time. When they do,
+ // we want them to share the horizontal space (assuming the column is
+ // vertical).
+ //
+ // To do this, we split the events into Blocks, each of which contains a
+ // variable number of Columns, each of which contain non-overlapping
+ // Events.
+ //
+ // Note that the end time of one event is equal to the start time of
+ // another, we consider them non-overlapping.
+ //
+ // We choose each Block to form a continuous block of time in the
+ // calendar column. Specifically, two Events are in the same Block if and
+ // only if there exists some sequence of pairwise overlapping Events that
+ // includes them both. This ensures that no Block will overlap another
+ // Block, and each contains the least number of Events possible.
+ //
+ // Each Column will share the same horizontal width, and will be placed
+ // adjacent to each other.
+ //
+ // Note that each Block may have a different number of Columns, and then
+ // may not share a common factor, so the Columns may not line up in the
+ // view.
+
+ // All the event Blocks in this calendar column, ordered by their start
+ // time. Each Block will be an array of Columns, which will in turn be an
+ // array of Events.
+ let allEventBlocks = [];
+ // The current Block.
+ let blockColumns = [];
+ let blockEnd = eventList[0].end;
+
+ for (let eventInfo of eventList) {
+ let start = eventInfo.start;
+ if (blockColumns.length && start >= blockEnd) {
+ // There is a gap between this Event and the end of the Block. We also
+ // know from the ordering of eventList that all other Events start at
+ // the same time or later. So there are no more Events that can be
+ // added to this Block. So we finish it and start a new one.
+ allEventBlocks.push(blockColumns);
+ blockColumns = [];
+ }
+
+ if (eventInfo.end > blockEnd) {
+ blockEnd = eventInfo.end;
+ }
+
+ // Find the earliest Column that the Event fits in.
+ let foundCol = false;
+ for (let column of blockColumns) {
+ // We know from the ordering of eventList that all Events already in a
+ // Column have a start time that is equal to or earlier than this
+ // Event's start time. Therefore, in order for this Event to not
+ // overlap anything else in this Column, it must have a start time
+ // that is later than or equal to the end time of the last Event in
+ // this column.
+ let colEnd = column[column.length - 1].end;
+ if (start >= colEnd) {
+ // It fits in this Column, so we push it to the end (preserving the
+ // eventList ordering within the Column).
+ column.push(eventInfo);
+ foundCol = true;
+ break;
+ }
+ }
+
+ if (!foundCol) {
+ // This Event doesn't fit in any column, so we create a new one.
+ blockColumns.push([eventInfo]);
+ }
+ }
+ if (blockColumns.length) {
+ allEventBlocks.push(blockColumns);
+ }
+
+ for (let blockColumns of allEventBlocks) {
+ let totalCols = blockColumns.length;
+ for (let colIndex = 0; colIndex < totalCols; colIndex++) {
+ for (let eventInfo of blockColumns[colIndex]) {
+ if (eventInfo.processed) {
+ // Already processed this Event in an earlier Column.
+ continue;
+ }
+ let { start, end } = eventInfo;
+ let colSpan = 1;
+ // Currently, the Event is only contained in one Column. We want to
+ // first try and stretch it across several continuous columns.
+ // For this Event, we go through each later Column one by one and
+ // see if there is a gap in it that it can fit in.
+ // Note, we only look forward in the Columns because we already know
+ // that we did not fit in the previous Columns.
+ for (
+ let neighbourColIndex = colIndex + 1;
+ neighbourColIndex < totalCols;
+ neighbourColIndex++
+ ) {
+ let neighbourColumn = blockColumns[neighbourColIndex];
+ // Test if this Event overlaps any of the other Events in the
+ // neighbouring Column.
+ let overlapsCol = false;
+ let indexInCol;
+ for (indexInCol = 0; indexInCol < neighbourColumn.length; indexInCol++) {
+ let otherEventInfo = neighbourColumn[indexInCol];
+ if (end <= otherEventInfo.start) {
+ // The end of this Event is before or equal to the start of
+ // the other Event, so it cannot overlap.
+ // Moreover, the rest of the Events in this neighbouring
+ // Column have a later or equal start time, so we know that
+ // this Event cannot overlap any of them. So we can break
+ // early.
+ // We also know that indexInCol now points to the *first*
+ // Event in this neighbouring Column that starts after this
+ // Event.
+ break;
+ } else if (start < otherEventInfo.end) {
+ // The end of this Event is after the start of the other
+ // Event, and the start of this Event is before the end of
+ // the other Event. So they must overlap.
+ overlapsCol = true;
+ break;
+ }
+ }
+ if (overlapsCol) {
+ // An Event must span continuously across Columns, so we must
+ // break.
+ break;
+ }
+ colSpan++;
+ // Add this Event to the Column. Note that indexInCol points to
+ // the *first* other Event that is later than this Event, or
+ // points to the end of the Column. So we place ourselves there to
+ // preserve the ordering.
+ neighbourColumn.splice(indexInCol, 0, eventInfo);
+ }
+ eventInfo.processed = true;
+ eventInfo.secondaryOffset = colIndex / totalCols;
+ eventInfo.secondaryLength = colSpan / totalCols;
+ }
+ }
+ }
+ return eventList;
+ }
+
+ /**
+ * Get information about which columns, relative to this column, are
+ * covered by the given time interval.
+ *
+ * @param {number} start - The starting time of the interval, in minutes
+ * from the start of this column's day. Should be negative for times on
+ * previous days. This must be on this column's day or earlier.
+ * @param {number} end - The ending time of the interval, in minutes from
+ * the start of this column's day. This can go beyond the end of this day.
+ * This must be greater than 'start' and on this column's day or later.
+ *
+ * @returns {object} - Data determining which columns are covered by the
+ * interval. Each column that is in the given range is covered from the
+ * start of the day to the end, apart from the first and last columns.
+ * @property {number} shadows - The number of columns that have some cover.
+ * @property {number} offset - The number of columns before this column that
+ * have some cover. For example, if 'start' is the day before, this is 1.
+ * @property {number} startMin - The starting time of the time interval, in
+ * minutes relative to the start of the first column's day.
+ * @property {number} endMin - The ending time of the time interval, in
+ * minutes relative to the start of the last column's day.
+ */
+ getShadowElements(start, end) {
+ let shadows = 1;
+ let offset = 0;
+ let startMin;
+ if (start < 0) {
+ offset = Math.ceil(Math.abs(start) / MINUTES_IN_DAY);
+ shadows += offset;
+ let remainder = Math.abs(start) % MINUTES_IN_DAY;
+ startMin = remainder ? MINUTES_IN_DAY - remainder : 0;
+ } else {
+ startMin = start;
+ }
+ shadows += Math.floor(end / MINUTES_IN_DAY);
+ return { shadows, offset, startMin, endMin: end % MINUTES_IN_DAY };
+ }
+
+ /**
+ * Clear a dragging sequence that is owned by this column.
+ */
+ clearDragging() {
+ for (let col of this.calendarView.getEventColumns()) {
+ col.fgboxes.dragbox.removeAttribute("dragging");
+ col.fgboxes.box.removeAttribute("dragging");
+ // We remove the height and width attributes as well.
+ // In particular, this means we won't accidentally preserve the height
+ // attribute if we switch to the rotated view, or the width if we
+ // switch back.
+ col.fgboxes.dragbox.removeAttribute("width");
+ col.fgboxes.dragbox.removeAttribute("height");
+ col.fgboxes.dragspacer.removeAttribute("width");
+ col.fgboxes.dragspacer.removeAttribute("height");
+ }
+
+ window.removeEventListener("mousemove", this.onEventSweepMouseMove);
+ window.removeEventListener("mouseup", this.onEventSweepMouseUp);
+ window.removeEventListener("keypress", this.onEventSweepKeypress);
+ document.calendarEventColumnDragging = null;
+ this.mDragState = null;
+ }
+
+ /**
+ * Update the shown drag state of all event columns in the same view using
+ * the mDragState of the current column.
+ */
+ updateColumnShadows() {
+ let startStr;
+ // Tasks without Entry or Due date have a string as first label
+ // instead of the time.
+ let item = this.mDragState.dragOccurrence;
+ if (item?.isTodo()) {
+ if (!item.dueDate) {
+ startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyEntryDate");
+ } else if (!item.entryDate) {
+ startStr = cal.l10n.getCalString("dragLabelTasksWithOnlyDueDate");
+ }
+ }
+
+ let { startMin, endMin, offset, shadows } = this.mDragState;
+ let jsTime = new Date();
+ let formatter = cal.dtz.formatter;
+ if (!startStr) {
+ jsTime.setHours(0, startMin, 0);
+ startStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating));
+ }
+ jsTime.setHours(0, endMin, 0);
+ let endStr = formatter.formatTime(cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating));
+
+ let allColumns = this.calendarView.getEventColumns();
+ let thisIndex = allColumns.indexOf(this);
+ // NOTE: startIndex and endIndex be before or after the start and end of
+ // the week, respectively, if the event spans multiple days.
+ let startIndex = thisIndex - offset;
+ let endIndex = startIndex + shadows - 1;
+
+ // All columns have the same orient and pixels per minutes.
+ let sizeProp = this.getAttribute("orient") == "vertical" ? "height" : "width";
+ let pixPerMin = this.pixelsPerMinute;
+
+ for (let i = 0; i < allColumns.length; i++) {
+ let fgboxes = allColumns[i].fgboxes;
+ if (i == startIndex) {
+ fgboxes.dragbox.setAttribute("dragging", "true");
+ fgboxes.box.setAttribute("dragging", "true");
+ fgboxes.dragspacer.style[sizeProp] = `${startMin * pixPerMin}px`;
+ fgboxes.dragbox.style[sizeProp] = `${
+ ((i == endIndex ? endMin : MINUTES_IN_DAY) - startMin) * pixPerMin
+ }px`;
+ fgboxes.startlabel.value = startStr;
+ fgboxes.endlabel.value = i == endIndex ? endStr : "";
+ } else if (i == endIndex) {
+ fgboxes.dragbox.setAttribute("dragging", "true");
+ fgboxes.box.setAttribute("dragging", "true");
+ fgboxes.dragspacer.style[sizeProp] = "0";
+ fgboxes.dragbox.style[sizeProp] = `${endMin * pixPerMin}px`;
+ fgboxes.startlabel.value = "";
+ fgboxes.endlabel.value = endStr;
+ } else if (i > startIndex && i < endIndex) {
+ fgboxes.dragbox.setAttribute("dragging", "true");
+ fgboxes.box.setAttribute("dragging", "true");
+ fgboxes.dragspacer.style[sizeProp] = "0";
+ fgboxes.dragbox.style[sizeProp] = `${MINUTES_IN_DAY * pixPerMin}px`;
+ fgboxes.startlabel.value = "";
+ fgboxes.endlabel.value = "";
+ } else {
+ fgboxes.dragbox.removeAttribute("dragging");
+ fgboxes.box.removeAttribute("dragging");
+ }
+ }
+ }
+
+ onEventSweepKeypress(event) {
+ let col = document.calendarEventColumnDragging;
+ if (col && event.key == "Escape") {
+ col.clearDragging();
+ }
+ }
+
+ // Event sweep handlers.
+ onEventSweepMouseMove(event) {
+ let col = document.calendarEventColumnDragging;
+ if (!col) {
+ return;
+ }
+
+ let dragState = col.mDragState;
+
+ // FIXME: Use mouseenter and mouseleave to detect column changes since
+ // they fire when scrolling changes the mouse target, but mousemove does
+ // not.
+ let newcol = col.calendarView.findEventColumnThatContains(event.target);
+ // If we leave the view, then stop our internal sweeping and start a
+ // real drag session. Someday we need to fix the sweep to soely be a
+ // drag session, no sweeping.
+ if (dragState.dragType == "move" && !newcol) {
+ // Remove the drag state.
+ col.clearDragging();
+
+ let item = dragState.dragOccurrence;
+
+ // The multiday view currently exhibits a less than optimal strategy
+ // in terms of item selection. items don't get automatically selected
+ // when clicked and dragged, as to differentiate inline editing from
+ // the act of selecting an event. but the application internal drop
+ // targets will ask for selected items in order to pull the data from
+ // the packets. that's why we need to make sure at least the currently
+ // dragged event is contained in the set of selected items.
+ let selectedItems = this.getSelectedItems();
+ if (!selectedItems.some(aItem => aItem.hashId == item.hashId)) {
+ col.calendarView.setSelectedItems([event.ctrlKey ? item.parentItem : item]);
+ }
+ // NOTE: Dragging to the allday header will fail (bug 1675056).
+ invokeEventDragSession(dragState.dragOccurrence, col);
+ return;
+ }
+
+ // Snap interval: 15 minutes or 1 minute if modifier key is pressed.
+ dragState.snapIntMin =
+ event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15;
+
+ // Check if we need to jump a column.
+ if (newcol && newcol != col) {
+ // Find how many columns we are jumping by subtracting the dates.
+ let dur = newcol.date.subtractDate(col.date);
+ let jumpedColumns = dur.isNegative ? -dur.days : dur.days;
+ if (dragState.dragType == "modify-start") {
+ // Prevent dragging the start date after the end date in a new column.
+ let limitEndMin = dragState.limitEndMin - MINUTES_IN_DAY * jumpedColumns;
+ if (limitEndMin < 0) {
+ return;
+ }
+ dragState.limitEndMin = limitEndMin;
+ } else if (dragState.dragType == "modify-end") {
+ let limitStartMin = dragState.limitStartMin - MINUTES_IN_DAY * jumpedColumns;
+ // Prevent dragging the end date before the start date in a new column.
+ if (limitStartMin > MINUTES_IN_DAY) {
+ return;
+ }
+ dragState.limitStartMin = limitStartMin;
+ } else if (dragState.dragType == "new") {
+ dragState.limitEndMin -= MINUTES_IN_DAY * jumpedColumns;
+ dragState.limitStartMin -= MINUTES_IN_DAY * jumpedColumns;
+ dragState.jumpedColumns += jumpedColumns;
+ }
+
+ // Move drag state to the new column.
+ col.mDragState = null;
+ newcol.mDragState = dragState;
+ document.calendarEventColumnDragging = newcol;
+ // The same event handlers are still valid,
+ // because they use document.calendarEventColumnDragging.
+ }
+
+ col.updateDragPosition(event.clientX, event.clientY);
+ }
+
+ /**
+ * Update the drag position to point to the given client position.
+ *
+ * Note, this method will not switch the drag state between columns.
+ *
+ * @param {number} clientX - The x position.
+ * @param {number} clientY - The y position.
+ */
+ updateDragPosition(clientX, clientY) {
+ let col = document.calendarEventColumnDragging;
+ if (!col) {
+ return;
+ }
+ // If we scroll, we call this method again using the same mouse positions.
+ // NOTE: if the magic scroll makes the mouse move over a different column,
+ // this won't be updated until another mousemove.
+ this.calendarView.setupMagicScroll(clientX, clientY, () =>
+ this.updateDragPosition(clientX, clientY)
+ );
+
+ let dragState = col.mDragState;
+
+ let mouseMinute = this.getMouseMinute({ clientX, clientY });
+ if (mouseMinute < 0) {
+ mouseMinute = 0;
+ } else if (mouseMinute > MINUTES_IN_DAY) {
+ mouseMinute = MINUTES_IN_DAY;
+ }
+ let snappedMouseMinute = snapMinute(
+ mouseMinute - dragState.mouseMinuteOffset,
+ dragState.snapIntMin
+ );
+
+ let deltamin = snappedMouseMinute - dragState.origMin;
+
+ let shadowElements;
+ if (dragState.dragType == "new") {
+ // Extend deltamin in a linear way over the columns.
+ deltamin += MINUTES_IN_DAY * dragState.jumpedColumns;
+ if (deltamin < 0) {
+ // Create a new event modifying the start. End time is fixed.
+ shadowElements = {
+ shadows: 1 - dragState.jumpedColumns,
+ offset: 0,
+ startMin: snappedMouseMinute,
+ endMin: dragState.origMin,
+ };
+ } else {
+ // Create a new event modifying the end. Start time is fixed.
+ shadowElements = {
+ shadows: dragState.jumpedColumns + 1,
+ offset: dragState.jumpedColumns,
+ startMin: dragState.origMin,
+ endMin: snappedMouseMinute,
+ };
+ }
+ dragState.startMin = shadowElements.startMin;
+ dragState.endMin = shadowElements.endMin;
+ } else if (dragState.dragType == "move") {
+ // If we're moving, we modify startMin and endMin of the shadow.
+ shadowElements = col.getShadowElements(
+ dragState.origMinStart + deltamin,
+ dragState.origMinEnd + deltamin
+ );
+ dragState.startMin = shadowElements.startMin;
+ dragState.endMin = shadowElements.endMin;
+ // Keep track of the last start position because it will help to
+ // build the event at the end of the drag session.
+ dragState.lastStart = dragState.origMinStart + deltamin;
+ } else if (dragState.dragType == "modify-start") {
+ // If we're modifying the start, the end time is fixed.
+ shadowElements = col.getShadowElements(dragState.origMin + deltamin, dragState.limitEndMin);
+ dragState.startMin = shadowElements.startMin;
+ dragState.endMin = shadowElements.endMin;
+
+ // But we need to not go past the end; if we hit
+ // the end, then we'll clamp to the previous snap interval minute.
+ if (dragState.startMin >= dragState.limitEndMin) {
+ dragState.startMin = snapMinute(dragState.limitEndMin, dragState.snapIntMin, "backward");
+ }
+ } else if (dragState.dragType == "modify-end") {
+ // If we're modifying the end, the start time is fixed.
+ shadowElements = col.getShadowElements(
+ dragState.limitStartMin,
+ dragState.origMin + deltamin
+ );
+ dragState.startMin = shadowElements.startMin;
+ dragState.endMin = shadowElements.endMin;
+
+ // But we need to not go past the start; if we hit
+ // the start, then we'll clamp to the next snap interval minute.
+ if (dragState.endMin <= dragState.limitStartMin) {
+ dragState.endMin = snapMinute(dragState.limitStartMin, dragState.snapIntMin, "forward");
+ }
+ }
+ dragState.offset = shadowElements.offset;
+ dragState.shadows = shadowElements.shadows;
+
+ // Now we can update the shadow boxes position and size.
+ col.updateColumnShadows();
+ }
+
+ onEventSweepMouseUp(event) {
+ let col = document.calendarEventColumnDragging;
+ if (!col) {
+ return;
+ }
+
+ let dragState = col.mDragState;
+
+ col.clearDragging();
+ col.calendarView.clearMagicScroll();
+
+ // If the user didn't sweep out at least a few pixels, ignore
+ // unless we're in a different column.
+ if (dragState.origColumn == col) {
+ let position = col.getAttribute("orient") == "vertical" ? event.clientY : event.clientX;
+ if (Math.abs(position - dragState.origLoc) < 3) {
+ return;
+ }
+ }
+
+ let newStart;
+ let newEnd;
+ let startTZ;
+ let endTZ;
+ let dragDay = col.date;
+ if (dragState.dragType != "new") {
+ let oldStart =
+ dragState.dragOccurrence.startDate ||
+ dragState.dragOccurrence.entryDate ||
+ dragState.dragOccurrence.dueDate;
+ let oldEnd =
+ dragState.dragOccurrence.endDate ||
+ dragState.dragOccurrence.dueDate ||
+ dragState.dragOccurrence.entryDate;
+ newStart = oldStart.clone();
+ newEnd = oldEnd.clone();
+
+ // Our views are pegged to the default timezone. If the event
+ // isn't also in the timezone, we're going to need to do some
+ // tweaking. We could just do this for every event but
+ // getInTimezone is slow, so it's much better to only do this
+ // when the timezones actually differ from the view's.
+ if (col.date.timezone != newStart.timezone || col.date.timezone != newEnd.timezone) {
+ startTZ = newStart.timezone;
+ endTZ = newEnd.timezone;
+ newStart = newStart.getInTimezone(col.date.timezone);
+ newEnd = newEnd.getInTimezone(col.date.timezone);
+ }
+ }
+
+ if (dragState.dragType == "modify-start") {
+ newStart.resetTo(
+ dragDay.year,
+ dragDay.month,
+ dragDay.day,
+ 0,
+ dragState.startMin,
+ 0,
+ newStart.timezone
+ );
+ } else if (dragState.dragType == "modify-end") {
+ newEnd.resetTo(
+ dragDay.year,
+ dragDay.month,
+ dragDay.day,
+ 0,
+ dragState.endMin,
+ 0,
+ newEnd.timezone
+ );
+ } else if (dragState.dragType == "new") {
+ let startDay = dragState.origColumn.date;
+ let draggedForward = dragDay.compare(startDay) > 0;
+ newStart = draggedForward ? startDay.clone() : dragDay.clone();
+ newEnd = draggedForward ? dragDay.clone() : startDay.clone();
+ newStart.isDate = false;
+ newEnd.isDate = false;
+ newStart.resetTo(
+ newStart.year,
+ newStart.month,
+ newStart.day,
+ 0,
+ dragState.startMin,
+ 0,
+ newStart.timezone
+ );
+ newEnd.resetTo(
+ newEnd.year,
+ newEnd.month,
+ newEnd.day,
+ 0,
+ dragState.endMin,
+ 0,
+ newEnd.timezone
+ );
+
+ // Edit the event title on the first of the new event's occurrences
+ // FIXME: This newEventNeedsEditing flag is read and unset in addEvent,
+ // but this is only called after some delay: after the event creation
+ // transaction completes. So there is a race between this creation and
+ // other actions that call addEvent.
+ // Bug 1710985 would be a way to address this: i.e. at this point we
+ // immediately create an element that the user can type a title into
+ // without creating a calendar item until they submit the title. Then
+ // we won't need any special flag for addEvent.
+ if (draggedForward) {
+ dragState.origColumn.newEventNeedsEditing = true;
+ } else {
+ col.newEventNeedsEditing = true;
+ }
+ } else if (dragState.dragType == "move") {
+ // Figure out the new date-times of the event by adding the duration
+ // of the total movement (days and minutes) to the old dates.
+ let duration = dragDay.subtractDate(dragState.origColumn.date);
+ let minutes = dragState.lastStart - dragState.realStart;
+
+ // Since both boxDate and beginMove are dates (note datetimes),
+ // subtractDate will only give us a non-zero number of hours on
+ // DST changes. While strictly speaking, subtractDate's behavior
+ // is correct, we need to move the event a discrete number of
+ // days here. There is no need for normalization here, since
+ // addDuration does the job for us. Also note, the duration used
+ // here is only used to move over multiple days. Moving on the
+ // same day uses the minutes from the dragState.
+ if (duration.hours == 23) {
+ // Entering DST.
+ duration.hours++;
+ } else if (duration.hours == 1) {
+ // Leaving DST.
+ duration.hours--;
+ }
+
+ if (duration.isNegative) {
+ // Adding negative minutes to a negative duration makes the
+ // duration more positive, but we want more negative, and
+ // vice versa.
+ minutes *= -1;
+ }
+ duration.minutes = minutes;
+ duration.normalize();
+
+ newStart.addDuration(duration);
+ newEnd.addDuration(duration);
+ }
+
+ // If we tweaked tzs, put times back in their original ones.
+ if (startTZ) {
+ newStart = newStart.getInTimezone(startTZ);
+ }
+ if (endTZ) {
+ newEnd = newEnd.getInTimezone(endTZ);
+ }
+
+ if (dragState.dragType == "new") {
+ // We won't pass a calendar, since the display calendar is the
+ // composite anyway. createNewEvent() will use the selected
+ // calendar.
+ col.calendarView.controller.createNewEvent(null, newStart, newEnd);
+ } else if (
+ dragState.dragType == "move" ||
+ dragState.dragType == "modify-start" ||
+ dragState.dragType == "modify-end"
+ ) {
+ col.calendarView.controller.modifyOccurrence(dragState.dragOccurrence, newStart, newEnd);
+ }
+ }
+
+ /**
+ * Start modifying an item through a mouse motion.
+ *
+ * @param {calItemBase} eventItem - The event item to start modifying.
+ * @param {"start"|"end"|"middle"} where - Whether to modify the starting
+ * time, ending time, or moving the entire event (modify the start and
+ * end, but preserve the duration).
+ * @param {object} position - The mouse position of the event that
+ * initialized* the motion.
+ * @param {number} position.clientX - The client x position.
+ * @param {number} position.clientY - The client y position.
+ * @param {number} position.offsetStartMinute - The minute offset of the
+ * mouse relative to the event item's starting time edge.
+ * @param {number} [snapIntMin=15] - The snapping interval to apply to the
+ * mouse position, in minutes.
+ */
+ startSweepingToModifyEvent(eventItem, where, position, snapIntMin = 15) {
+ if (!canEditEventItem(eventItem)) {
+ return;
+ }
+
+ this.mDragState = {
+ origColumn: this,
+ dragOccurrence: eventItem,
+ mouseMinuteOffset: 0,
+ offset: null,
+ shadows: null,
+ limitStartMin: null,
+ lastStart: 0,
+ jumpedColumns: 0,
+ };
+
+ if (this.getAttribute("orient") == "vertical") {
+ this.mDragState.origLoc = position.clientY;
+ } else {
+ this.mDragState.origLoc = position.clientX;
+ }
+
+ let stdate = eventItem.startDate || eventItem.entryDate || eventItem.dueDate;
+ let enddate = eventItem.endDate || eventItem.dueDate || eventItem.entryDate;
+
+ // Get the start and end times in minutes, relative to the start of the
+ // day. This may be negative or exceed the length of the day if the event
+ // spans more than one day.
+ let realStart = Math.floor(stdate.subtractDate(this.date).inSeconds / 60);
+ let realEnd = Math.floor(enddate.subtractDate(this.date).inSeconds / 60);
+
+ if (where == "start") {
+ this.mDragState.dragType = "modify-start";
+ // We have to use "realEnd" as fixed end value.
+ this.mDragState.limitEndMin = realEnd;
+
+ // Snap start.
+ // Since we are modifying the start, we know the event starts on this
+ // day, so realStart is not negative.
+ this.mDragState.origMin = snapMinute(realStart, snapIntMin);
+
+ // Show the shadows and drag labels when clicking on gripbars.
+ let shadowElements = this.getShadowElements(
+ this.mDragState.origMin,
+ this.mDragState.limitEndMin
+ );
+ this.mDragState.startMin = shadowElements.startMin;
+ this.mDragState.endMin = shadowElements.endMin;
+ this.mDragState.shadows = shadowElements.shadows;
+ this.mDragState.offset = shadowElements.offset;
+ this.updateColumnShadows();
+ } else if (where == "end") {
+ this.mDragState.dragType = "modify-end";
+ // We have to use "realStart" as fixed end value.
+ this.mDragState.limitStartMin = realStart;
+
+ // Snap end.
+ // Since we are modifying the end, we know the event end on this day,
+ // so realEnd is before midnight on this day.
+ this.mDragState.origMin = snapMinute(realEnd, snapIntMin);
+
+ // Show the shadows and drag labels when clicking on gripbars.
+ let shadowElements = this.getShadowElements(
+ this.mDragState.limitStartMin,
+ this.mDragState.origMin
+ );
+ this.mDragState.startMin = shadowElements.startMin;
+ this.mDragState.endMin = shadowElements.endMin;
+ this.mDragState.shadows = shadowElements.shadows;
+ this.mDragState.offset = shadowElements.offset;
+ this.updateColumnShadows();
+ } else if (where == "middle") {
+ this.mDragState.dragType = "move";
+ // In a move, origMin will be the start minute of the element where
+ // the drag occurs. Along with mouseMinuteOffset, it allows to track the
+ // shadow position. origMinStart and origMinEnd allow to figure out
+ // the real shadow size.
+ this.mDragState.mouseMinuteOffset = position.offsetStartMinute;
+ // We use origMin to get the number of minutes since the start of *this*
+ // day, which is 0 if realStart is negative.
+ this.mDragState.origMin = Math.max(0, snapMinute(realStart, snapIntMin));
+ // We snap to the start and add the real duration to find the end.
+ this.mDragState.origMinStart = snapMinute(realStart, snapIntMin);
+ this.mDragState.origMinEnd = realEnd + this.mDragState.origMinStart - realStart;
+ // Keep also track of the real Start, it will be used at the end
+ // of the drag session to calculate the new start and end datetimes.
+ this.mDragState.realStart = realStart;
+
+ let shadowElements = this.getShadowElements(
+ this.mDragState.origMinStart,
+ this.mDragState.origMinEnd
+ );
+ this.mDragState.shadows = shadowElements.shadows;
+ this.mDragState.offset = shadowElements.offset;
+ // Do not show the shadow yet.
+ } else {
+ // Invalid grabbed element.
+ }
+
+ document.calendarEventColumnDragging = this;
+
+ window.addEventListener("mousemove", this.onEventSweepMouseMove);
+ window.addEventListener("mouseup", this.onEventSweepMouseUp);
+ window.addEventListener("keypress", this.onEventSweepKeypress);
+ }
+
+ /**
+ * Set the hours when the day starts and ends.
+ *
+ * @param {number} dayStartHour - Hour at which the day starts.
+ * @param {number} dayEndHour - Hour at which the day ends.
+ */
+ setDayStartEndHours(dayStartHour, dayEndHour) {
+ if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ for (let [hour, hourBox] of this.hourBoxes.entries()) {
+ hourBox.classList.toggle(
+ "multiday-hour-box-off-time",
+ hour < dayStartHour || hour >= dayEndHour
+ );
+ }
+ }
+
+ /**
+ * Get the minute since the starting edge of the given element that a mouse
+ * event points to.
+ *
+ * @param {{clientX: number, clientY: number}} mouseEvent - The pointer
+ * position in the viewport.
+ * @param {Element} [element] - The element to use the starting edge of as
+ * reference. Defaults to using the starting edge of the column itself,
+ * such that the returned minute is the number of minutes since the start
+ * of the day.
+ *
+ * @returns {number} - The number of minutes since the starting edge of
+ * 'element' that this event points to.
+ */
+ getMouseMinute(mouseEvent, element = this) {
+ let rect = element.getBoundingClientRect();
+ let pos;
+ if (this.getAttribute("orient") == "vertical") {
+ pos = mouseEvent.clientY - rect.top;
+ } else if (document.dir == "rtl") {
+ pos = rect.right - mouseEvent.clientX;
+ } else {
+ pos = mouseEvent.clientX - rect.left;
+ }
+ return pos / this.pixelsPerMinute;
+ }
+
+ /**
+ * Get the datetime that the mouse event points to, snapped to the nearest
+ * 15 minutes.
+ *
+ * @param {MouseEvent} mouseEvent - The pointer event.
+ *
+ * @returns {calDateTime} - A new datetime that the mouseEvent points to.
+ */
+ getMouseDateTime(mouseEvent) {
+ let clickMinute = this.getMouseMinute(mouseEvent);
+ let newStart = this.date.clone();
+ newStart.isDate = false;
+ newStart.hour = 0;
+ // Round to nearest 15 minutes.
+ newStart.minute = snapMinute(clickMinute, 15);
+ return newStart;
+ }
+ }
+
+ customElements.define("calendar-event-column", MozCalendarEventColumn);
+
+ /**
+ * Implements the Drag and Drop class for the Calendar Header Container.
+ *
+ * @augments {MozElements.CalendarDnDContainer}
+ */
+ class CalendarHeaderContainer extends MozElements.CalendarDnDContainer {
+ /**
+ * The date of the day this header represents.
+ *
+ * @type {calIDateTime}
+ */
+ date;
+
+ constructor() {
+ super();
+ this.addEventListener("dblclick", this.onDblClick);
+ this.addEventListener("mousedown", this.onMouseDown);
+ this.addEventListener("click", this.onClick);
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true in super.connectedCallback.
+ super.connectedCallback();
+
+ // Map from an event item's hashId to its calendar-editable-item.
+ this.eventElements = new Map();
+
+ this.eventsListElement = document.createElement("ol");
+ this.eventsListElement.classList.add("allday-events-list");
+ this.appendChild(this.eventsListElement);
+ }
+
+ /**
+ * Return the displayed calendar-editable-item element for the given event
+ * item.
+ *
+ * @param {calItemBase} eventItem - The event item.
+ *
+ * @returns {Element} - The corresponding element, or undefined if none.
+ */
+ findElementForEventItem(eventItem) {
+ return this.eventElements.get(eventItem.hashId);
+ }
+
+ /**
+ * Return all the event items that are displayed in this columns.
+ *
+ * @returns {calItemBase[]} - An array of all the displayed event items.
+ */
+ getAllEventItems() {
+ return Array.from(this.eventElements.values(), element => element.occurrence);
+ }
+
+ /**
+ * Create or update a displayed calendar-editable-item element for the given
+ * event item.
+ *
+ * @param {calItemBase} eventItem - The event item to create or update an
+ * element for.
+ */
+ addEvent(eventItem) {
+ let existing = this.eventElements.get(eventItem.hashId);
+ if (existing) {
+ // Remove the wrapper list item. We'll insert a replacement below.
+ existing.parentNode.remove();
+ }
+
+ let itemBox = document.createXULElement("calendar-editable-item");
+ let listItemWrapper = document.createElement("li");
+ listItemWrapper.classList.add("allday-event-listitem");
+ listItemWrapper.appendChild(itemBox);
+ cal.data.binaryInsertNode(
+ this.eventsListElement,
+ listItemWrapper,
+ eventItem,
+ cal.view.compareItems,
+ false,
+ wrapper => wrapper.firstChild.occurrence
+ );
+
+ itemBox.calendarView = this.calendarView;
+ itemBox.occurrence = eventItem;
+ itemBox.setAttribute(
+ "context",
+ this.calendarView.getAttribute("item-context") || this.calendarView.getAttribute("context")
+ );
+
+ if (eventItem.hashId in this.calendarView.mFlashingEvents) {
+ itemBox.setAttribute("flashing", "true");
+ }
+
+ this.eventElements.set(eventItem.hashId, itemBox);
+
+ itemBox.parentBox = this;
+ }
+
+ /**
+ * Remove the displayed calendar-editable-item element for the given event
+ * item from this column
+ *
+ * @param {calItemBase} eventItem - The event item to remove the element of.
+ */
+ deleteEvent(eventItem) {
+ let current = this.eventElements.get(eventItem.hashId);
+ if (current) {
+ // Need to remove the wrapper list item.
+ current.parentNode.remove();
+ this.eventElements.delete(eventItem.hashId);
+ }
+ }
+
+ /**
+ * Clear the header of all events.
+ */
+ clear() {
+ this.eventElements.clear();
+ while (this.eventsListElement.hasChildNodes()) {
+ this.eventsListElement.lastChild.remove();
+ }
+ }
+
+ /**
+ * Set whether to show a drop shadow in the event list.
+ *
+ * @param {boolean} on - True to show the drop shadow, otherwise hides the
+ * drop shadow.
+ */
+ setDropShadow(on) {
+ // NOTE: Adding or removing drop shadows may change our size, but we won't
+ // let the calendar view know about these since they are temporary and we
+ // don't want the view to be re-adjusting on every hover.
+ let existing = this.eventsListElement.querySelector(".dropshadow");
+ if (on) {
+ if (!existing) {
+ // Insert an empty list item.
+ let dropshadow = document.createElement("li");
+ dropshadow.classList.add("dropshadow", "allday-event-listitem");
+ this.eventsListElement.insertBefore(dropshadow, this.eventsListElement.firstElementChild);
+ }
+ } else if (existing) {
+ existing.remove();
+ }
+ }
+
+ onDropItem(aItem) {
+ let newItem = cal.item.moveToDate(aItem, this.date);
+ newItem = cal.item.setToAllDay(newItem, true);
+ return newItem;
+ }
+
+ /**
+ * Set whether the calendar-editable-item element for the given event item
+ * should be displayed as selected or unselected.
+ *
+ * @param {calItemBase} eventItem - The event item.
+ * @param {boolean} select - Whether to show the corresponding event element
+ * as selected.
+ */
+ selectEvent(eventItem, select) {
+ let element = this.eventElements.get(eventItem.hashId);
+ if (!element) {
+ return;
+ }
+ element.selected = select;
+ }
+
+ onDblClick(event) {
+ if (event.button == 0) {
+ this.calendarView.controller.createNewEvent(null, this.date, null, true);
+ }
+ }
+
+ onMouseDown(event) {
+ this.calendarView.selectedDay = this.date;
+ }
+
+ onClick(event) {
+ if (event.button == 0) {
+ if (!(event.ctrlKey || event.metaKey)) {
+ this.calendarView.setSelectedItems([]);
+ }
+ }
+ if (event.button == 2) {
+ let newStart = this.calendarView.selectedDay.clone();
+ newStart.isDate = true;
+ this.calendarView.selectedDateTime = newStart;
+ event.stopPropagation();
+ }
+ }
+
+ /**
+ * Determine whether the given wheel event is above a scrollable area and
+ * matches the scroll direction.
+ *
+ * @param {WheelEvent} - The wheel event.
+ *
+ * @returns {boolean} - True if this event is above a scrollable area and
+ * matches its scroll direction.
+ */
+ wheelOnScrollableArea(event) {
+ let scrollArea = this.eventsListElement;
+ return (
+ event.deltaY &&
+ scrollArea.contains(event.target) &&
+ scrollArea.scrollHeight != scrollArea.clientHeight
+ );
+ }
+ }
+ customElements.define("calendar-header-container", CalendarHeaderContainer);
+
+ /**
+ * The MozCalendarMonthDayBoxItem widget is used as event item in the
+ * Day and Week views of the calendar. It displays the event name,
+ * alarm icon and the category type color. It also displays the gripbar
+ * components on hovering over the event. It is used to change the event
+ * timings.
+ *
+ * @augments {MozElements.MozCalendarEditableItem}
+ */
+ class MozCalendarEventBox extends MozElements.MozCalendarEditableItem {
+ static get inheritedAttributes() {
+ return {
+ ".alarm-icons-box": "flashing",
+ };
+ }
+ constructor() {
+ super();
+ this.addEventListener("mousedown", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ if (this.mEditing) {
+ return;
+ }
+
+ this.parentColumn.calendarView.selectedDay = this.parentColumn.date;
+
+ this.mouseDownPosition = {
+ clientX: event.clientX,
+ clientY: event.clientY,
+ // We calculate the offsetStartMinute here because the clientX and
+ // clientY coordinates might become 'stale' by the time we actually
+ // call startItemDrag. E.g. if we scroll the view.
+ offsetStartMinute: this.parentColumn.getMouseMinute(
+ event,
+ // We use the listitem wrapper, since that is positioned relative to
+ // the event's start time.
+ this.closest(".multiday-event-listitem")
+ ),
+ };
+
+ let side;
+ if (this.startGripbar.contains(event.target)) {
+ side = "start";
+ } else if (this.endGripbar.contains(event.target)) {
+ side = "end";
+ }
+
+ if (side) {
+ this.calendarView.setSelectedItems([
+ event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence,
+ ]);
+
+ // Start edge resize drag
+ this.parentColumn.startSweepingToModifyEvent(
+ this.mOccurrence,
+ side,
+ this.mouseDownPosition,
+ event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey ? 1 : 15
+ );
+ } else {
+ // May be click or drag,
+ // So wait for mousemove (or mouseout if fast) to start item move drag.
+ this.mInMouseDown = true;
+ }
+ });
+
+ this.addEventListener("mousemove", event => {
+ if (!this.mInMouseDown) {
+ return;
+ }
+
+ let deltaX = Math.abs(event.clientX - this.mouseDownPosition.clientX);
+ let deltaY = Math.abs(event.clientY - this.mouseDownPosition.clientY);
+ // More than a 3 pixel move?
+ const movedMoreThan3Pixels = deltaX * deltaX + deltaY * deltaY > 9;
+ if (movedMoreThan3Pixels && this.parentColumn) {
+ this.startItemDrag();
+ }
+ });
+
+ this.addEventListener("mouseout", event => {
+ if (!this.mEditing && this.mInMouseDown && this.parentColumn) {
+ this.startItemDrag();
+ }
+ });
+
+ this.addEventListener("mouseup", event => {
+ if (!this.mEditing) {
+ this.mInMouseDown = false;
+ }
+ });
+
+ this.addEventListener("mouseover", event => {
+ if (this.calendarView && this.calendarView.controller) {
+ event.stopPropagation();
+ onMouseOverItem(event);
+ }
+ });
+
+ this.addEventListener("mouseenter", event => {
+ // Update the event-readonly class to determine whether to show the
+ // gripbars, which are otherwise shown on hover.
+ this.classList.toggle("event-readonly", !canEditEventItem(this.occurrence));
+ });
+
+ // We have two event listeners for dragstart. This event listener is for the capturing phase
+ // where we are setting up the document.monthDragEvent which will be used in the event listener
+ // in the bubbling phase which is set up in the calendar-editable-item.
+ this.addEventListener(
+ "dragstart",
+ event => {
+ document.monthDragEvent = this;
+ },
+ true
+ );
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <!-- NOTE: The following div is the same markup as EditableItem. -->
+ <html:div class="calendar-item-container">
+ <html:div class="calendar-item-flex">
+ <html:img class="item-type-icon" alt="" />
+ <html:div class="event-name-label"></html:div>
+ <html:input class="plain event-name-input"
+ hidden="hidden"
+ placeholder='${cal.l10n.getCalString("newEvent")}'/>
+ <html:div class="alarm-icons-box"></html:div>
+ <html:img class="item-classification-icon" />
+ <html:img class="item-recurrence-icon" />
+ </html:div>
+ <html:div class="location-desc"></html:div>
+ <html:div class="calendar-category-box"></html:div>
+ </html:div>
+ `)
+ );
+
+ this.startGripbar = this.createGripbar("start");
+ this.endGripbar = this.createGripbar("end");
+ this.appendChild(this.startGripbar);
+ this.appendChild(this.endGripbar);
+
+ this.classList.add("calendar-color-box");
+
+ this.style.pointerEvents = "auto";
+ this.setAttribute("tooltip", "itemTooltip");
+
+ this.addEventNameTextboxListener();
+ this.initializeAttributeInheritance();
+ }
+
+ /**
+ * Create one of the box's gripbars that can be dragged to resize the event.
+ *
+ * @param {"start"|"end"} side - The side the gripbar controls.
+ *
+ * @returns {Element} - A newly created gripbar.
+ */
+ createGripbar(side) {
+ let gripbar = document.createElement("div");
+ gripbar.classList.add(side == "start" ? "gripbar-start" : "gripbar-end");
+ let img = document.createElement("img");
+ img.setAttribute("src", "chrome://calendar/skin/shared/event-grippy.png");
+ /* Make sure the img doesn't interfere with dragging the gripbar to
+ * resize. */
+ img.setAttribute("draggable", "false");
+ img.setAttribute("alt", "");
+ gripbar.appendChild(img);
+ return gripbar;
+ }
+
+ /**
+ * Update and retrieve the event's start and end dates relative to the given
+ * day. This updates the gripbars.
+ *
+ * @param {calIDateTime} day - The day that this event is shown on.
+ *
+ * @returns {object} - The start and end time information.
+ * @property {calIDateTime|undefined} startDate - The start date-time of the
+ * event in the timezone of the given day. Or the entry date-time for
+ * tasks, if they have one.
+ * @property {calIDateTime|undefined} endDate - The end date-time of the
+ * event in the timezone of the given day. Or the due date-time for
+ * tasks, if they have one.
+ * @property {number} startMinute - The number of minutes since the start of
+ * the given day that the event starts.
+ * @property {number} endMinute - The number of minutes since the end of the
+ * given day that the event ends.
+ */
+ updateRelativeStartEndDates(day) {
+ let item = this.occurrence;
+
+ // Get closed bounds for the day. I.e. inclusive of midnight the next day.
+ let closedDayStart = day.clone();
+ closedDayStart.isDate = false;
+ let closedDayEnd = day.clone();
+ closedDayEnd.day++;
+ closedDayEnd.isDate = false;
+
+ function relativeTime(date) {
+ if (!date) {
+ return null;
+ }
+ date = date.getInTimezone(day.timezone);
+ return {
+ date,
+ minute: date.subtractDate(closedDayStart).inSeconds / 60,
+ withinClosedDay: date.compare(closedDayStart) >= 0 && date.compare(closedDayEnd) <= 0,
+ };
+ }
+
+ let start;
+ let end;
+ if (item.isEvent()) {
+ start = relativeTime(item.startDate);
+ end = relativeTime(item.endDate);
+ } else {
+ start = relativeTime(item.entryDate);
+ end = relativeTime(item.dueDate);
+ }
+
+ this.startGripbar.hidden = !(end && start?.withinClosedDay);
+ this.endGripbar.hidden = !(start && end?.withinClosedDay);
+
+ return {
+ startDate: start?.date,
+ endDate: end?.date,
+ startMinute: start?.minute,
+ endMinute: end?.minute,
+ };
+ }
+
+ getOptimalMinSize(orient) {
+ let label = this.querySelector(".event-name-label");
+ if (orient == "vertical") {
+ let minHeight =
+ getOptimalMinimumHeight(label) +
+ getSummarizedStyleValues(label.parentNode, ["padding-bottom", "padding-top"]) +
+ getSummarizedStyleValues(this, ["border-bottom-width", "border-top-width"]);
+ this.style.minHeight = minHeight + "px";
+ this.style.minWidth = "1px";
+ return minHeight;
+ }
+ label.style.minWidth = "2em";
+ let minWidth = getOptimalMinimumWidth(this.eventNameLabel);
+ this.style.minWidth = minWidth + "px";
+ this.style.minHeight = "1px";
+ return minWidth;
+ }
+
+ startItemDrag() {
+ if (this.editingTimer) {
+ clearTimeout(this.editingTimer);
+ this.editingTimer = null;
+ }
+
+ this.calendarView.setSelectedItems([this.mOccurrence]);
+
+ this.mEditing = false;
+
+ this.parentColumn.startSweepingToModifyEvent(
+ this.mOccurrence,
+ "middle",
+ this.mouseDownPosition
+ );
+ this.mInMouseDown = false;
+ }
+ }
+
+ customElements.define("calendar-event-box", MozCalendarEventBox);
+
+ /**
+ * Abstract class used for the day and week calendar view elements. (Not month or multiweek.)
+ *
+ * @implements {calICalendarView}
+ * @augments {MozElements.CalendarBaseView}
+ * @abstract
+ */
+ class CalendarMultidayBaseView extends MozElements.CalendarBaseView {
+ // mDateList will always be sorted before being set.
+ mDateList = null;
+
+ /**
+ * A column in the view representing a particular date.
+ *
+ * @typedef {object} DayColumn
+ * @property {calIDateTime} date - The day's date.
+ * @property {Element} container - The container that holds the other
+ * elements.
+ * @property {Element} headingContainer - The day heading. This holds both
+ * the short and long headings, with only one being visible at any given
+ * time.
+ * @property {Element} longHeading - The day heading that uses the full
+ * day of the week. For example, "Monday".
+ * @property {Element} shortHeading - The day heading that uses an
+ * abbreviation for the day of the week. For example, "Mon".
+ * @property {number} longHeadingContentAreaWidth - The content area width
+ * of the headingContainer when the long heading is shown.
+ * @property {Element} column - A calendar-event-column where regular
+ * (not "all day") events appear.
+ * @property {Element} header - A calendar-header-container where allday
+ * events appear.
+ */
+ /**
+ * An ordered list of the shown day columns.
+ *
+ * @type {DayColumn[]}
+ */
+ dayColumns = [];
+
+ /**
+ * Whether the number of headings, or the heading dates have changed, and
+ * the view still needs to be adjusted accordingly.
+ *
+ * @type {boolean}
+ */
+ headingDatesChanged = true;
+ /**
+ * Whether the view has been rotated and the view still needs to be fully
+ * adjusted.
+ *
+ * @type {boolean}
+ */
+ rotationChanged = true;
+
+ mSelectedDayCol = null;
+ mSelectedDay = null;
+
+ /**
+ * The hour that a 'day' starts. Any time before this is considered
+ * off-time.
+ *
+ * @type {number}
+ */
+ dayStartHour = 0;
+ /**
+ * The hour that a 'day' ends. Any time equal to or after this is
+ * considered off-time.
+ *
+ * @type {number}
+ */
+ dayEndHour = 0;
+
+ /**
+ * How many hours to show in the scrollable area.
+ *
+ * @type {number}
+ */
+ visibleHours = 9;
+
+ /**
+ * The number of pixels that a one minute duration should occupy in the
+ * view.
+ *
+ * @type {number}
+ */
+ pixelsPerMinute;
+
+ /**
+ * The timebar hour box elements in this view, ordered and indexed by their
+ * starting hour.
+ *
+ * @type {Element[]}
+ */
+ hourBoxes = [];
+
+ mClickedTime = null;
+
+ mTimeIndicatorInterval = 15;
+ mTimeIndicatorMinutes = 0;
+
+ mModeHandler = null;
+ scrollMinute = 0;
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ super.connectedCallback();
+
+ // Get day start/end hour from prefs and set on the view.
+ // This happens here to keep tests happy.
+ this.setDayStartEndHours(
+ Services.prefs.getIntPref("calendar.view.daystarthour", 8),
+ Services.prefs.getIntPref("calendar.view.dayendhour", 17)
+ );
+
+ // We set the scrollMinute, so that when onResize is eventually triggered
+ // by refresh, we will scroll to this.
+ // FIXME: Find a cleaner solution.
+ this.scrollMinute = this.dayStartHour * 60;
+ }
+
+ ensureInitialized() {
+ if (this.isInitialized) {
+ return;
+ }
+
+ this.grid = document.createElement("div");
+ this.grid.classList.add("multiday-grid");
+ this.appendChild(this.grid);
+
+ this.headerCorner = document.createElement("div");
+ this.headerCorner.classList.add("multiday-header-corner");
+
+ this.grid.appendChild(this.headerCorner);
+
+ this.timebar = document.createElement("div");
+ this.timebar.classList.add("multiday-timebar", "multiday-hour-box-container");
+ this.nowIndicator = document.createElement("div");
+ this.nowIndicator.classList.add("multiday-timebar-now-indicator");
+ this.nowIndicator.hidden = true;
+ this.timebar.appendChild(this.nowIndicator);
+
+ let formatter = cal.dtz.formatter;
+ let jsTime = new Date();
+ for (let hour = 0; hour < 24; hour++) {
+ let hourBox = document.createElement("div");
+ hourBox.classList.add("multiday-hour-box", "multiday-timebar-time");
+ // Set the time label.
+ jsTime.setHours(hour, 0, 0);
+ hourBox.textContent = formatter.formatTime(
+ cal.dtz.jsDateToDateTime(jsTime, cal.dtz.floating)
+ );
+ this.timebar.appendChild(hourBox);
+ this.hourBoxes.push(hourBox);
+ }
+ this.grid.appendChild(this.timebar);
+
+ this.endBorder = document.createElement("div");
+ this.endBorder.classList.add("multiday-end-border");
+ this.grid.appendChild(this.endBorder);
+
+ this.initializeAttributeInheritance();
+
+ // super.connectedCallback has to be called after the time bar is added to the DOM.
+ super.ensureInitialized();
+
+ this.addEventListener("click", event => {
+ if (event.button != 2) {
+ return;
+ }
+ this.selectedDateTime = null;
+ });
+
+ this.addEventListener("wheel", event => {
+ // Only shift hours if no modifier is pressed.
+ if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
+ return;
+ }
+ let deltaTime = this.getAttribute("orient") == "horizontal" ? event.deltaX : event.deltaY;
+ if (!deltaTime) {
+ // Scroll is not in the same direction as the time axis, so just do
+ // the default scroll (if any).
+ return;
+ }
+ if (
+ this.headerCorner.contains(event.target) ||
+ this.dayColumns.some(col => col.headingContainer.contains(event.target))
+ ) {
+ // Prevent any scrolling in these sticky headers.
+ event.preventDefault();
+ return;
+ }
+ let header = this.dayColumns.find(col => col.header.contains(event.target))?.header;
+ if (header) {
+ if (!header.wheelOnScrollableArea(event)) {
+ // Prevent any scrolling in this header.
+ event.preventDefault();
+ // Otherwise, we let the default wheel handler scroll the header.
+ // NOTE: We have the CSS overscroll-behavior set to "none", to stop
+ // the default wheel handler from scrolling the parent if the header
+ // is already at its scrolling edge.
+ }
+ return;
+ }
+ let minute = this.scrollMinute;
+ if (event.deltaMode == event.DOM_DELTA_LINE) {
+ // We snap from the current hour to the next one.
+ let scrollHour = deltaTime < 0 ? Math.floor(minute / 60) : Math.ceil(minute / 60);
+ if (Math.abs(scrollHour * 60 - minute) < 10) {
+ // If the change in minutes would be less than 10 minutes, go to the
+ // next hour. This means that anything in the close neighbourhood of
+ // the hour line will scroll to the same hour.
+ scrollHour += Math.sign(deltaTime);
+ }
+ minute = scrollHour * 60;
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ let minDiff = deltaTime / this.pixelsPerMinute;
+ minute += minDiff < 0 ? Math.floor(minDiff) : Math.ceil(minDiff);
+ } else {
+ return;
+ }
+ event.preventDefault();
+ this.scrollToMinute(minute);
+ });
+
+ this.grid.addEventListener("scroll", event => {
+ if (!this.clientHeight) {
+ // Hidden, so don't store the scroll position.
+ // FIXME: We don't expect scrolling whilst we are hidden, so we should
+ // try and remove. This is only seems to happen in mochitests.
+ return;
+ }
+ let scrollPx;
+ if (this.getAttribute("orient") == "horizontal") {
+ scrollPx = document.dir == "rtl" ? -this.grid.scrollLeft : this.grid.scrollLeft;
+ } else {
+ scrollPx = this.grid.scrollTop;
+ }
+ this.scrollMinute = Math.round(scrollPx / this.pixelsPerMinute);
+ });
+
+ // Get visible hours from prefs and set on the view.
+ this.setVisibleHours(Services.prefs.getIntPref("calendar.view.visiblehours", 9));
+ }
+
+ // calICalendarView Properties
+
+ get supportsZoom() {
+ return true;
+ }
+
+ get supportsRotation() {
+ return true;
+ }
+
+ get supportsDisjointDates() {
+ return true;
+ }
+
+ get hasDisjointDates() {
+ return this.mDateList != null;
+ }
+
+ set selectedDay(day) {
+ // Ignore if just 1 visible, it's always selected, but we don't indicate it.
+ if (this.numVisibleDates == 1) {
+ this.fireEvent("dayselect", day);
+ return;
+ }
+
+ if (this.mSelectedDayCol) {
+ this.mSelectedDayCol.container.classList.remove("day-column-selected");
+ }
+
+ if (day) {
+ this.mSelectedDayCol = this.findColumnForDate(day);
+ if (this.mSelectedDayCol) {
+ this.mSelectedDay = this.mSelectedDayCol.date;
+ this.mSelectedDayCol.container.classList.add("day-column-selected");
+ } else {
+ this.mSelectedDay = day;
+ }
+ }
+ this.fireEvent("dayselect", day);
+ }
+
+ get selectedDay() {
+ let selected;
+ if (this.numVisibleDates == 1) {
+ selected = this.dayColumns[0].date;
+ } else if (this.mSelectedDay) {
+ selected = this.mSelectedDay;
+ } else if (this.mSelectedDayCol) {
+ selected = this.mSelectedDayCol.date;
+ }
+
+ // TODO Make sure the selected day is valid.
+ // TODO Select now if it is in the range?
+ return selected;
+ }
+
+ // End calICalendarView Properties
+
+ set selectedDateTime(dateTime) {
+ this.mClickedTime = dateTime;
+ }
+
+ get selectedDateTime() {
+ return this.mClickedTime;
+ }
+
+ // Private
+
+ get numVisibleDates() {
+ if (this.mDateList) {
+ return this.mDateList.length;
+ }
+
+ let count = 0;
+
+ if (!this.mStartDate || !this.mEndDate) {
+ // The view has not been initialized, so there are 0 visible dates.
+ return count;
+ }
+
+ const date = this.mStartDate.clone();
+ while (date.compare(this.mEndDate) <= 0) {
+ count++;
+ date.day += 1;
+ }
+
+ return count;
+ }
+
+ /**
+ * Update the position of the time indicator.
+ */
+ updateTimeIndicatorPosition() {
+ // Calculate the position of the indicator based on how far into the day
+ // it is and the size of the current view.
+ const now = cal.dtz.now();
+ const nowMinutes = now.hour * 60 + now.minute;
+
+ let position = `${this.pixelsPerMinute * nowMinutes - 1}px`;
+ let isVertical = this.getAttribute("orient") == "vertical";
+
+ // Control the position of the dot in the time bar, which is present even
+ // when the view does not show the current day. Inline start controls
+ // horizontal position of the dot, block controls vertical.
+ this.nowIndicator.style.insetInlineStart = isVertical ? null : position;
+ this.nowIndicator.style.insetBlockStart = isVertical ? position : null;
+
+ // Control the position of the bar, which should be visible only for the
+ // current day.
+ const todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox;
+ if (todayIndicator) {
+ todayIndicator.style.marginInlineStart = isVertical ? null : position;
+ todayIndicator.style.marginBlockStart = isVertical ? position : null;
+ }
+ }
+
+ /**
+ * Handle preference changes. Typically called by a preference observer.
+ *
+ * @param {object} subject - The subject, a prefs object.
+ * @param {string} topic - The notification topic.
+ * @param {string} preference - The preference to handle.
+ */
+ handlePreference(subject, topic, preference) {
+ subject.QueryInterface(Ci.nsIPrefBranch);
+ switch (preference) {
+ case "calendar.view.daystarthour":
+ this.setDayStartEndHours(subject.getIntPref(preference), this.dayEndHour);
+ break;
+
+ case "calendar.view.dayendhour":
+ this.setDayStartEndHours(this.dayStartHour, subject.getIntPref(preference));
+ break;
+
+ case "calendar.view.visiblehours":
+ this.setVisibleHours(subject.getIntPref(preference));
+ this.readjustView(true, true, this.scrollMinute);
+ break;
+
+ default:
+ this.handleCommonPreference(subject, topic, preference);
+ break;
+ }
+ }
+
+ /**
+ * Handle resizing by adjusting the view to the new size.
+ */
+ onResize() {
+ // Assume resize in both directions.
+ this.readjustView(true, true, this.scrollMinute);
+ }
+
+ /**
+ * Perform an operation on the header that may cause it to resize, such that
+ * the view can adjust itself accordingly.
+ *
+ * @param {Element} header - The header that may resize.
+ * @param {Function} operation - An operation to run.
+ */
+ doResizingHeaderOperation(header, operation) {
+ // Capture scrollMinute before we potentially change the size of the view.
+ let scrollMinute = this.scrollMinute;
+ let beforeRect = header.getBoundingClientRect();
+
+ operation();
+
+ let afterRect = header.getBoundingClientRect();
+ this.readjustView(
+ beforeRect.height != afterRect.height,
+ beforeRect.width != afterRect.width,
+ scrollMinute
+ );
+ }
+
+ /**
+ * Adjust the view based an a change in rotation, layout, view size, or
+ * header size.
+ *
+ * Note, this method will do nothing whilst the view is hidden, so must be
+ * called again once it is shown.
+ *
+ * @param {boolean} verticalResize - There may have been a change in the
+ * vertical direction.
+ * @param {boolean} horizontalResize - There may have been a change in the
+ * horizontal direction.
+ * @param {number} scrollMinute - The minute we should scroll after
+ * adjusting the view in the time-direction.
+ */
+ readjustView(verticalResize, horizontalResize, scrollMinute) {
+ if (!this.clientHeight || !this.clientWidth) {
+ // Do nothing if we have zero width or height since we cannot measure
+ // elements. Should be called again once we can.
+ return;
+ }
+
+ let isHorizontal = this.getAttribute("orient") == "horizontal";
+
+ // Adjust the headings. We do this before measuring the pixels per minute
+ // because this may adjust the size of the headings.
+ if (this.headingDatesChanged) {
+ this.shortHeadingContentWidth = 0;
+ for (let dayCol of this.dayColumns) {
+ // Make sure both headings are visible for measuring.
+ // We will hide one of them again further below.
+ dayCol.shortHeading.hidden = false;
+ dayCol.longHeading.hidden = false;
+
+ // We can safely measure the widths of the short and long headings
+ // because their headingContainer does not grow or shrink them.
+ let longHeadingRect = dayCol.longHeading.getBoundingClientRect();
+ if (!this.headingContentHeight) {
+ // We assume this is constant and the same for each heading.
+ this.headingContentHeight = longHeadingRect.height;
+ }
+
+ dayCol.longHeadingContentAreaWidth = longHeadingRect.width;
+ this.shortHeadingContentWidth = Math.max(
+ this.shortHeadingContentWidth,
+ dayCol.shortHeading.getBoundingClientRect().width
+ );
+ }
+ // Unset the other properties that use these values.
+ // NOTE: We do not calculate new values for these properties here
+ // because they can only be measured in one of the rotated or
+ // non-rotated states. So we will calculate them as needed.
+ delete this.rotatedHeadingWidth;
+ delete this.minHeadingWidth;
+ }
+
+ // Whether the headings need readjusting.
+ let adjustHeadingPositioning = this.headingDatesChanged || this.rotationChanged;
+ // Position headers.
+ if (isHorizontal) {
+ // We're in the rotated state, so we can measure the corresponding
+ // header dimensions.
+ // NOTE: we always use short headings in the rotated view.
+ if (!this.rotatedHeadingWidth) {
+ // Width is shared by all headings in the rotated view, so we set it
+ // so that its large enough to fit the text of each heading.
+ if (!this.rotatedHeadingContentToBorderWidthOffset) {
+ // We cache the value since we assume it is constant within the
+ // rotated view.
+ this.rotatedHeadingContentToBorderOffset = this.measureHeadingContentToBorderOffset();
+ }
+ this.rotatedHeadingWidth =
+ this.shortHeadingContentWidth + this.rotatedHeadingContentToBorderOffset.inline;
+ adjustHeadingPositioning = true;
+ }
+ if (adjustHeadingPositioning) {
+ for (let dayCol of this.dayColumns) {
+ // The header is sticky, so we need to position it. We want a constant
+ // position, so we offset the header by the heading width.
+ // NOTE: We assume there is no margin between the two.
+ dayCol.header.style.insetBlockStart = null;
+ dayCol.header.style.insetInlineStart = `${this.rotatedHeadingWidth}px`;
+ // NOTE: The heading must have its box-sizing set to border-box for
+ // this to work properly.
+ dayCol.headingContainer.style.width = `${this.rotatedHeadingWidth}px`;
+ dayCol.headingContainer.style.minWidth = null;
+ }
+ }
+ } else {
+ // We're in the non-rotated state, so we can measure the corresponding
+ // header dimensions.
+ if (!this.headingContentToBorderOffset) {
+ // We cache the value since we assume it is constant within the
+ // non-rotated view.
+ this.headingContentToBorderOffset = this.measureHeadingContentToBorderOffset();
+ }
+ if (!this.headingHeight) {
+ this.headingHeight = this.headingContentHeight + this.headingContentToBorderOffset.block;
+ }
+ if (!this.minHeadingWidth) {
+ // Make the minimum width large enough to fit the short heading.
+ this.minHeadingWidth =
+ this.shortHeadingContentWidth + this.headingContentToBorderOffset.inline;
+ adjustHeadingPositioning = true;
+ }
+ if (adjustHeadingPositioning) {
+ for (let dayCol of this.dayColumns) {
+ // We offset the header by the heading height.
+ dayCol.header.style.insetBlockStart = `${this.headingHeight}px`;
+ dayCol.header.style.insetInlineStart = null;
+ dayCol.headingContainer.style.minWidth = `${this.minHeadingWidth}px`;
+ dayCol.headingContainer.style.width = null;
+ }
+ }
+ }
+
+ // If the view is horizontal, we always use the short headings.
+ // We do this before calculating the pixelsPerMinute since the width of
+ // the heading is important to determining the size of the scroll area.
+ // We only need to do this when the view has been rotated, or when new
+ // headings have been added. adjustHeadingPosition covers both of these.
+ if (isHorizontal && adjustHeadingPositioning) {
+ for (let dayCol of this.dayColumns) {
+ dayCol.shortHeading.hidden = false;
+ dayCol.longHeading.hidden = true;
+ }
+ }
+ // Otherwise, if the view is vertical, we determine whether to use short
+ // or long headings after changing the pixelsPerMinute, which can change
+ // the amount of horizontal space.
+ // NOTE: when the view is vertical, both the short and long headings
+ // should take up the same vertical space, so this shouldn't effect the
+ // pixelsPerMinute calculation.
+
+ if (this.rotationChanged) {
+ // Clear the set widths/heights or positions before calculating the
+ // scroll area. Otherwise they will remain extended in the wrong
+ // direction, and keep the grid content larger than necessary, which can
+ // cause the grid content to overflow, which in turn shrinks the
+ // calculated scroll area due to extra scrollbars.
+ // The timebar will be corrected when the pixelsPerMinute is calculated.
+ this.timebar.style.width = null;
+ this.timebar.style.height = null;
+ // The time indicators will be corrected in updateTimeIndicatorPosition.
+ this.nowIndicator.style.insetInlineStart = null;
+ this.nowIndicator.style.insetBlockStart = null;
+ let todayIndicator = this.findColumnForDate(this.today())?.column.timeIndicatorBox;
+ if (todayIndicator) {
+ todayIndicator.style.marginInlineStart = null;
+ todayIndicator.style.marginBlockStart = null;
+ }
+ }
+
+ // Adjust pixels per minute.
+ let ppmHasChanged = false;
+ if (
+ adjustHeadingPositioning ||
+ (isHorizontal && horizontalResize) ||
+ (!isHorizontal && verticalResize)
+ ) {
+ if (isHorizontal && !this.timebarMinWidth) {
+ // Measure the minimum width such that the time labels do not overflow
+ // and are equal width.
+ this.timebar.style.height = null;
+ this.timebar.style.width = "min-content";
+ let maxWidth = 0;
+ for (let hourBox of this.hourBoxes) {
+ maxWidth = Math.max(maxWidth, hourBox.getBoundingClientRect().width);
+ }
+ // NOTE: We assume no margin between the boxes.
+ this.timebarMinWidth = maxWidth * this.hourBoxes.length;
+ // width should be set to the correct value below when the
+ // pixelsPerMinute changes.
+ } else if (!isHorizontal && !this.timebarMinHeight) {
+ // Measure the minimum height such that the time labels do not
+ // overflow and are equal height.
+ this.timebar.style.width = null;
+ this.timebar.style.height = "min-content";
+ let maxHeight = 0;
+ for (let hourBox of this.hourBoxes) {
+ maxHeight = Math.max(maxHeight, hourBox.getBoundingClientRect().height);
+ }
+ // NOTE: We assume no margin between the boxes.
+ this.timebarMinHeight = maxHeight * this.hourBoxes.length;
+ // height should be set to the correct value below when the
+ // pixelsPerMinute changes.
+ }
+
+ // We want to know how much visible space is available in the
+ // "time-direction" of this view's scrollable area, which will be used
+ // to show 'this.visibleHour' hours in the timebar.
+ // NOTE: The area returned by getScrollAreaRect is the *current*
+ // scrollable area. We are working with the assumption that the length
+ // in the time-direction will not change when we change the pixels per
+ // minute. This assumption is broken if the changes cause the
+ // non-time-direction to switch from overflowing to not, or vis versa,
+ // which adds or removes a scrollbar. Since we are only changing the
+ // content length in the time-direction, this should only happen in edge
+ // cases (e.g. scrollbar being added from a time-direction overflow also
+ // causes the non-time-direction to overflow).
+ let scrollArea = this.getScrollAreaRect();
+ let dayScale = 24 / this.visibleHours;
+ let dayPixels = isHorizontal
+ ? Math.max((scrollArea.right - scrollArea.left) * dayScale, this.timebarMinWidth)
+ : Math.max((scrollArea.bottom - scrollArea.top) * dayScale, this.timebarMinHeight);
+ let pixelsPerMinute = dayPixels / MINUTES_IN_DAY;
+ if (this.rotationChanged || pixelsPerMinute != this.pixelsPerMinute) {
+ ppmHasChanged = true;
+ this.pixelsPerMinute = pixelsPerMinute;
+
+ // Use the same calculation as in the event columns.
+ let dayPx = `${MINUTES_IN_DAY * pixelsPerMinute}px`;
+ if (isHorizontal) {
+ this.timebar.style.width = dayPx;
+ this.timebar.style.height = null;
+ } else {
+ this.timebar.style.height = dayPx;
+ this.timebar.style.width = null;
+ }
+
+ for (const col of this.dayColumns) {
+ col.column.pixelsPerMinute = pixelsPerMinute;
+ }
+ }
+
+ // Scroll to the given minute.
+ this.scrollToMinute(scrollMinute);
+ // A change in pixels per minute can cause a scrollbar to appear or
+ // disappear, which can change the available space for headers.
+ if (ppmHasChanged) {
+ verticalResize = true;
+ horizontalResize = true;
+ }
+ }
+
+ // Decide whether to use short headings.
+ if (!isHorizontal && (horizontalResize || adjustHeadingPositioning)) {
+ // Use short headings if *any* heading would horizontally overflow with
+ // a long heading.
+ let widthOffset = this.headingContentToBorderOffset.inline;
+ let useShortHeadings = this.dayColumns.some(
+ col =>
+ col.headingContainer.getBoundingClientRect().width <
+ col.longHeadingContentAreaWidth + widthOffset
+ );
+ for (let dayCol of this.dayColumns) {
+ dayCol.shortHeading.hidden = !useShortHeadings;
+ dayCol.longHeading.hidden = useShortHeadings;
+ }
+ }
+
+ this.updateTimeIndicatorPosition();
+
+ // The changes have now been handled.
+ this.headingDatesChanged = false;
+ this.rotationChanged = false;
+ }
+
+ /**
+ * Measure the total offset between the content width and border width of
+ * the day headings.
+ *
+ * @returns {{inline: number, block: number}} - The offsets in their
+ * respective directions.
+ */
+ measureHeadingContentToBorderOffset() {
+ if (!this.dayColumns.length) {
+ // undefined properties.
+ return {};
+ }
+ // We cache the offset. We expect these styles to differ between the
+ // rotated and non-rotated views, but to otherwise be constant.
+ let style = getComputedStyle(this.dayColumns[0].headingContainer);
+ return {
+ inline:
+ parseFloat(style.paddingInlineStart) +
+ parseFloat(style.paddingInlineEnd) +
+ parseFloat(style.borderInlineStartWidth) +
+ parseFloat(style.borderInlineEndWidth),
+ block:
+ parseFloat(style.paddingBlockStart) +
+ parseFloat(style.paddingBlockEnd) +
+ parseFloat(style.borderBlockStartWidth) +
+ parseFloat(style.borderBlockEndWidth),
+ };
+ }
+
+ /**
+ * Make a calendar item flash or stop flashing. Called when the item's alarm fires.
+ *
+ * @param {calIItemBase} item - The calendar item.
+ * @param {boolean} stop - Whether to stop the item from flashing.
+ */
+ flashAlarm(item, stop) {
+ function setFlashingAttribute(box) {
+ if (stop) {
+ box.removeAttribute("flashing");
+ } else {
+ box.setAttribute("flashing", "true");
+ }
+ }
+
+ const showIndicator = Services.prefs.getBoolPref("calendar.alarms.indicator.show", true);
+ const totaltime = Services.prefs.getIntPref("calendar.alarms.indicator.totaltime", 3600);
+
+ if (!stop && (!showIndicator || totaltime < 1)) {
+ // No need to animate if the indicator should not be shown.
+ return;
+ }
+
+ // Make sure the flashing attribute is set or reset on all visible boxes.
+ const columns = this.findColumnsForItem(item);
+ for (const col of columns) {
+ const colBox = col.column.findElementForEventItem(item);
+ const headerBox = col.header.findElementForEventItem(item);
+
+ if (colBox) {
+ setFlashingAttribute(colBox);
+ }
+ if (headerBox) {
+ setFlashingAttribute(headerBox);
+ }
+ }
+
+ if (stop) {
+ // We are done flashing, prevent newly created event boxes from flashing.
+ delete this.mFlashingEvents[item.hashId];
+ } else {
+ // Set up a timer to stop the flashing after the total time.
+ this.mFlashingEvents[item.hashId] = item;
+ setTimeout(() => this.flashAlarm(item, true), totaltime);
+ }
+ }
+
+ // calICalendarView Methods
+
+ showDate(date) {
+ const targetDate = date.getInTimezone(this.mTimezone);
+ targetDate.isDate = true;
+
+ if (this.mStartDate.timezone.tzid == date.timezone.tzid) {
+ if (this.mStartDate && this.mEndDate) {
+ if (this.mStartDate.compare(targetDate) <= 0 && this.mEndDate.compare(targetDate) >= 0) {
+ return;
+ }
+ } else if (this.mDateList) {
+ for (const listDate of this.mDateList) {
+ // If date is already visible, nothing to do.
+ if (listDate.compare(targetDate) == 0) {
+ return;
+ }
+ }
+ }
+ }
+
+ // If we're only showing one date, then continue
+ // to only show one date; otherwise, show the week.
+ if (this.numVisibleDates == 1) {
+ this.setDateRange(date, date);
+ } else {
+ this.setDateRange(date.startOfWeek, date.endOfWeek);
+ }
+
+ this.selectedDay = targetDate;
+ }
+
+ setDateRange(startDate, endDate) {
+ this.rangeStartDate = startDate;
+ this.rangeEndDate = endDate;
+
+ const viewStart = startDate.getInTimezone(this.mTimezone);
+ const viewEnd = endDate.getInTimezone(this.mTimezone);
+
+ viewStart.isDate = true;
+ viewStart.makeImmutable();
+ viewEnd.isDate = true;
+ viewEnd.makeImmutable();
+
+ this.mStartDate = viewStart;
+ this.mEndDate = viewEnd;
+
+ // The start and end dates to query calendars with (in CalendarFilteredViewMixin).
+ this.startDate = viewStart;
+ let viewEndPlusOne = viewEnd.clone();
+ viewEndPlusOne.day++;
+ this.endDate = viewEndPlusOne;
+
+ // First, check values of tasksInView, workdaysOnly, showCompleted.
+ // Their status will determine the value of toggleStatus, which is
+ // saved to this.mToggleStatus during last call to relayout()
+ let toggleStatus = 0;
+
+ if (this.mTasksInView) {
+ toggleStatus |= this.mToggleStatusFlag.TasksInView;
+ }
+ if (this.mWorkdaysOnly) {
+ toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly;
+ }
+ if (this.mShowCompleted) {
+ toggleStatus |= this.mToggleStatusFlag.ShowCompleted;
+ }
+
+ // Update the navigation bar only when changes are related to the current view.
+ if (this.isVisible()) {
+ calendarNavigationBar.setDateRange(viewStart, viewEnd);
+ }
+
+ // Check whether view range has been changed since last call to relayout().
+ if (
+ !this.mViewStart ||
+ !this.mViewEnd ||
+ this.mViewStart.timezone.tzid != viewStart.timezone.tzid ||
+ this.mViewEnd.compare(viewEnd) != 0 ||
+ this.mViewStart.compare(viewStart) != 0 ||
+ this.mToggleStatus != toggleStatus
+ ) {
+ this.relayout({ dates: true });
+ }
+ }
+
+ getDateList() {
+ const dates = [];
+ if (this.mStartDate && this.mEndDate) {
+ const date = this.mStartDate.clone();
+ while (date.compare(this.mEndDate) <= 0) {
+ dates.push(date.clone());
+ date.day += 1;
+ }
+ } else if (this.mDateList) {
+ for (const date of this.mDateList) {
+ dates.push(date.clone());
+ }
+ }
+
+ return dates;
+ }
+
+ setSelectedItems(items, suppressEvent) {
+ if (this.mSelectedItems) {
+ for (const item of this.mSelectedItems) {
+ for (const occ of this.getItemOccurrencesInView(item)) {
+ const cols = this.findColumnsForItem(occ);
+ for (const col of cols) {
+ col.header.selectEvent(occ, false);
+ col.column.selectEvent(occ, false);
+ }
+ }
+ }
+ }
+ this.mSelectedItems = items || [];
+
+ for (const item of this.mSelectedItems) {
+ for (const occ of this.getItemOccurrencesInView(item)) {
+ const cols = this.findColumnsForItem(occ);
+ if (cols.length == 0) {
+ continue;
+ }
+ const start = item.startDate || item.entryDate || item.dueDate;
+ for (const col of cols) {
+ if (start.isDate) {
+ col.header.selectEvent(occ, true);
+ } else {
+ col.column.selectEvent(occ, true);
+ }
+ }
+ }
+ }
+
+ if (!suppressEvent) {
+ this.fireEvent("itemselect", this.mSelectedItems);
+ }
+ }
+
+ centerSelectedItems() {
+ const displayTZ = cal.dtz.defaultTimezone;
+ let lowMinute = MINUTES_IN_DAY;
+ let highMinute = 0;
+
+ for (const item of this.mSelectedItems) {
+ const startDateProperty = cal.dtz.startDateProp(item);
+ const endDateProperty = cal.dtz.endDateProp(item);
+
+ let occs = [];
+ if (item.recurrenceInfo) {
+ // If selected a parent item, show occurrence(s) in view range.
+ occs = item.getOccurrencesBetween(this.startDate, this.queryEndDate);
+ } else {
+ occs = [item];
+ }
+
+ for (const occ of occs) {
+ let occStart = occ[startDateProperty];
+ let occEnd = occ[endDateProperty];
+ // Must have at least one of start or end.
+ if (!occStart && !occEnd) {
+ // Task with no dates.
+ continue;
+ }
+
+ // If just has single datetime, treat as zero duration item
+ // (such as task with due datetime or start datetime only).
+ occStart = occStart || occEnd;
+ occEnd = occEnd || occStart;
+ // Now both occStart and occEnd are datetimes.
+
+ // Skip occurrence if all-day: it won't show in time view.
+ if (occStart.isDate || occEnd.isDate) {
+ continue;
+ }
+
+ // Trim dates to view. (Not mutated so just reuse view dates.)
+ if (this.startDate.compare(occStart) > 0) {
+ occStart = this.startDate;
+ }
+ if (this.queryEndDate.compare(occEnd) < 0) {
+ occEnd = this.queryEndDate;
+ }
+
+ // Convert to display timezone if different.
+ if (occStart.timezone != displayTZ) {
+ occStart = occStart.getInTimezone(displayTZ);
+ }
+ if (occEnd.timezone != displayTZ) {
+ occEnd = occEnd.getInTimezone(displayTZ);
+ }
+ // If crosses midnight in current TZ, set end just
+ // before midnight after start so start/title usually visible.
+ if (!cal.dtz.sameDay(occStart, occEnd)) {
+ occEnd = occStart.clone();
+ occEnd.day = occStart.day;
+ occEnd.hour = 23;
+ occEnd.minute = 59;
+ }
+
+ // Ensure range shows occ.
+ lowMinute = Math.min(occStart.hour * 60 + occStart.minute, lowMinute);
+ highMinute = Math.max(occEnd.hour * 60 + occEnd.minute, highMinute);
+ }
+ }
+
+ let halfDurationMinutes = (highMinute - lowMinute) / 2;
+ if (this.mSelectedItems.length && halfDurationMinutes >= 0) {
+ let halfVisibleMinutes = this.visibleHours * 30;
+ if (halfDurationMinutes <= halfVisibleMinutes) {
+ // If the full duration fits in the view, then center the middle of
+ // the region.
+ this.scrollToMinute(lowMinute + halfDurationMinutes - halfVisibleMinutes);
+ } else if (this.mSelectedItems.length == 1) {
+ // Else, if only one event is selected, then center the start.
+ this.scrollToMinute(lowMinute - halfVisibleMinutes);
+ }
+ // Else, don't scroll.
+ }
+ }
+
+ zoomIn(level) {
+ let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9);
+ visibleHours += level || 1;
+
+ Services.prefs.setIntPref("calendar.view.visiblehours", Math.min(visibleHours, 24));
+ }
+
+ zoomOut(level) {
+ let visibleHours = Services.prefs.getIntPref("calendar.view.visiblehours", 9);
+ visibleHours -= level || 1;
+
+ Services.prefs.setIntPref("calendar.view.visiblehours", Math.max(1, visibleHours));
+ }
+
+ zoomReset() {
+ Services.prefs.setIntPref("calendar.view.visiblehours", 9);
+ }
+
+ // End calICalendarView Methods
+
+ /**
+ * Return all the occurrences of a given item that are currently displayed in the view.
+ *
+ * @param {calIItemBase} item - A calendar item.
+ * @returns {calIItemBase[]} An array of occurrences.
+ */
+ getItemOccurrencesInView(item) {
+ if (item.recurrenceInfo && item.recurrenceStartDate) {
+ // If a parent item is selected, show occurrence(s) in view range.
+ return item.getOccurrencesBetween(this.startDate, this.queryEndDate);
+ } else if (item.recurrenceStartDate) {
+ return [item];
+ }
+ // Undated todo.
+ return [];
+ }
+
+ /**
+ * Set an attribute on the view element, and do re-orientation and re-layout if needed.
+ *
+ * @param {string} attr - The attribute to set.
+ * @param {string} value - The value to set.
+ */
+ setAttribute(attr, value) {
+ let rotated = attr == "orient" && this.getAttribute("orient") != value;
+ let context = attr == "context" || attr == "item-context";
+
+ // This should be done using lookupMethod(), see bug 286629.
+ const ret = XULElement.prototype.setAttribute.call(this, attr, value);
+
+ if (rotated || context) {
+ this.relayout({ rotated, context });
+ }
+
+ return ret;
+ }
+
+ /**
+ * Re-render the view based on the given changes.
+ *
+ * Note, changing the dates will wipe the columns of all events, otherwise
+ * the current events are kept in place.
+ *
+ * @param {object} [changes] - The relevant changes to the view. Defaults to
+ * all changes.
+ * @property {boolean} dates - A change in the column dates.
+ * @property {boolean} rotated - A change in the rotation.
+ * @property {boolean} context - A change in the context menu.
+ */
+ relayout(changes) {
+ if (!this.mStartDate || !this.mEndDate) {
+ return;
+ }
+ if (!changes) {
+ changes = { dates: true, rotated: true, context: true };
+ }
+ let scrollMinute = this.scrollMinute;
+
+ const orient = this.getAttribute("orient") || "vertical";
+ this.grid.classList.toggle("multiday-grid-rotated", orient == "horizontal");
+
+ let context = this.getAttribute("context");
+ let itemContext = this.getAttribute("item-context") || context;
+
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.startLayoutBatchChange();
+ }
+
+ if (changes.dates) {
+ const computedDateList = [];
+ const startDate = this.mStartDate.clone();
+ while (startDate.compare(this.mEndDate) <= 0) {
+ const workday = startDate.clone();
+ workday.makeImmutable();
+
+ if (this.mDisplayDaysOff || !this.mDaysOffArray.includes(startDate.weekday)) {
+ computedDateList.push(workday);
+ }
+ startDate.day += 1;
+ }
+ this.mDateList = computedDateList;
+
+ this.grid.style.setProperty("--multiday-num-days", computedDateList.length);
+
+ // Deselect the previously selected event upon switching views,
+ // otherwise those events will stay selected forever, if other events
+ // are selected after changing the view.
+ this.setSelectedItems([], true);
+
+ // Get today's date.
+ let today = this.today();
+
+ let dateFormatter = cal.dtz.formatter;
+
+ // Assume the heading widths are no longer valid because the displayed
+ // dates are likely to change.
+ // We do not measure them here since we may be hidden. Instead we do so
+ // in readjustView.
+ this.headingDatesChanged = true;
+ let colIndex;
+ for (colIndex = 0; colIndex < computedDateList.length; colIndex++) {
+ let dayDate = computedDateList[colIndex];
+ let dayCol = this.dayColumns[colIndex];
+ if (dayCol) {
+ dayCol.column.clear();
+ dayCol.header.clear();
+ } else {
+ dayCol = {};
+ dayCol.container = document.createElement("article");
+ dayCol.container.classList.add("day-column-container");
+ this.grid.insertBefore(dayCol.container, this.endBorder);
+
+ dayCol.headingContainer = document.createElement("h2");
+ dayCol.headingContainer.classList.add("day-column-heading");
+ dayCol.longHeading = document.createElement("span");
+ dayCol.shortHeading = document.createElement("span");
+ dayCol.headingContainer.appendChild(dayCol.longHeading);
+ dayCol.headingContainer.appendChild(dayCol.shortHeading);
+ dayCol.container.appendChild(dayCol.headingContainer);
+
+ dayCol.header = document.createXULElement("calendar-header-container");
+ dayCol.header.setAttribute("orient", "vertical");
+ dayCol.container.appendChild(dayCol.header);
+ dayCol.header.calendarView = this;
+
+ dayCol.column = document.createXULElement("calendar-event-column");
+ dayCol.container.appendChild(dayCol.column);
+ dayCol.column.calendarView = this;
+ dayCol.column.startLayoutBatchChange();
+ dayCol.column.pixelsPerMinute = this.pixelsPerMinute;
+ dayCol.column.setDayStartEndHours(this.dayStartHour, this.dayEndHour);
+ dayCol.column.setAttribute("orient", orient);
+ dayCol.column.setAttribute("context", context);
+ dayCol.column.setAttribute("item-context", itemContext);
+
+ this.dayColumns[colIndex] = dayCol;
+ }
+ dayCol.date = dayDate.clone();
+ dayCol.date.isDate = true;
+ dayCol.date.makeImmutable();
+
+ /* Set up day of the week headings. */
+ dayCol.shortHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [
+ dateFormatter.shortDayName(dayDate.weekday),
+ dateFormatter.formatDateWithoutYear(dayDate),
+ ]);
+ dayCol.longHeading.textContent = cal.l10n.getCalString("dayHeaderLabel", [
+ dateFormatter.dayName(dayDate.weekday),
+ dateFormatter.formatDateWithoutYear(dayDate),
+ ]);
+
+ /* Set up all-day header. */
+ dayCol.header.date = dayDate;
+
+ /* Set up event column. */
+ dayCol.column.date = dayDate;
+
+ /* Set up styling classes for day-off and today. */
+ dayCol.container.classList.toggle(
+ "day-column-weekend",
+ this.mDaysOffArray.includes(dayDate.weekday)
+ );
+
+ let isToday = dayDate.compare(today) == 0;
+ dayCol.column.timeIndicatorBox.hidden = !isToday;
+ dayCol.container.classList.toggle("day-column-today", isToday);
+ }
+ // Remove excess columns.
+ for (let dayCol of this.dayColumns.splice(colIndex)) {
+ dayCol.column.endLayoutBatchChange();
+ dayCol.container.remove();
+ }
+ }
+
+ if (changes.rotated) {
+ this.rotationChanged = true;
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.setAttribute("orient", orient);
+ }
+ }
+
+ if (changes.context) {
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.setAttribute("context", context);
+ dayCol.column.setAttribute("item-context", itemContext);
+ }
+ }
+
+ // Let the columns relayout themselves before we readjust the view.
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.endLayoutBatchChange();
+ }
+
+ if (changes.dates || changes.rotated) {
+ // Fix pixels-per-minute and headers, now or when next visible.
+ this.readjustView(false, false, scrollMinute);
+ }
+
+ // Store the start and end of current view. Next time when
+ // setDateRange is called, it will use mViewStart and mViewEnd to
+ // check if view range has been changed.
+ this.mViewStart = this.mStartDate;
+ this.mViewEnd = this.mEndDate;
+
+ let toggleStatus = 0;
+
+ if (this.mTasksInView) {
+ toggleStatus |= this.mToggleStatusFlag.TasksInView;
+ }
+ if (this.mWorkdaysOnly) {
+ toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly;
+ }
+ if (this.mShowCompleted) {
+ toggleStatus |= this.mToggleStatusFlag.ShowCompleted;
+ }
+
+ this.mToggleStatus = toggleStatus;
+ if (changes.dates) {
+ // Fetch new items for the new dates.
+ this.refreshItems(true);
+ }
+ }
+
+ /**
+ * Return the column object for a given date.
+ *
+ * @param {calIDateTime} date - A date.
+ * @returns {?DateColumn} A column object.
+ */
+ findColumnForDate(date) {
+ for (const col of this.dayColumns) {
+ if (col.date.compare(date) == 0) {
+ return col;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return the day box (column header) for a given date.
+ *
+ * @param {calIDateTime} date - A date.
+ * @returns {Element} A `calendar-header-container` where "all day" events appear.
+ */
+ findDayBoxForDate(date) {
+ const col = this.findColumnForDate(date);
+ return col && col.header;
+ }
+
+ /**
+ * Return the column objects for a given calendar item.
+ *
+ * @param {calIItemBase} item - A calendar item.
+ * @returns {DateColumn[]} An array of column objects.
+ */
+ findColumnsForItem(item) {
+ const columns = [];
+
+ if (!this.dayColumns.length) {
+ return columns;
+ }
+
+ // Note that these may be dates or datetimes.
+ const startDate = item.startDate || item.entryDate || item.dueDate;
+ if (!startDate) {
+ return columns;
+ }
+ const timezone = this.dayColumns[0].date.timezone;
+ let targetDate = startDate.getInTimezone(timezone);
+ let finishDate = (item.endDate || item.dueDate || item.entryDate || startDate).getInTimezone(
+ timezone
+ );
+
+ if (targetDate.compare(this.mStartDate) < 0) {
+ targetDate = this.mStartDate.clone();
+ }
+
+ if (finishDate.compare(this.mEndDate) > 0) {
+ finishDate = this.mEndDate.clone();
+ finishDate.day++;
+ }
+
+ // Set the time to 00:00 so that we get all the boxes.
+ targetDate.isDate = false;
+ targetDate.hour = 0;
+ targetDate.minute = 0;
+ targetDate.second = 0;
+
+ if (targetDate.compare(finishDate) == 0) {
+ // We have also to handle zero length events in particular for
+ // tasks without entry or due date.
+ const col = this.findColumnForDate(targetDate);
+ if (col) {
+ columns.push(col);
+ }
+ }
+
+ while (targetDate.compare(finishDate) == -1) {
+ const col = this.findColumnForDate(targetDate);
+
+ // This might not exist if the event spans the view start or end.
+ if (col) {
+ columns.push(col);
+ }
+ targetDate.day += 1;
+ }
+
+ return columns;
+ }
+
+ /**
+ * Get an ordered list of all the calendar-event-column elements in this
+ * view.
+ *
+ * @returns {MozCalendarEventColumn[]} - The columns in this view.
+ */
+ getEventColumns() {
+ return Array.from(this.dayColumns, col => col.column);
+ }
+
+ /**
+ * Find the calendar-event-column that contains the given node.
+ *
+ * @param {Node} node - The node to search for.
+ *
+ * @returns {?MozCalendarEventColumn} - The column that contains the node, or
+ * null if none do.
+ */
+ findEventColumnThatContains(node) {
+ return this.dayColumns.find(col => col.column.contains(node))?.column;
+ }
+
+ /**
+ * Display a calendar item.
+ *
+ * @param {calIItemBase} event - A calendar item.
+ */
+ doAddItem(event) {
+ const cols = this.findColumnsForItem(event);
+ if (!cols.length) {
+ return;
+ }
+
+ for (const col of cols) {
+ const estart = event.startDate || event.entryDate || event.dueDate;
+
+ if (estart.isDate) {
+ this.doResizingHeaderOperation(col.header, () => col.header.addEvent(event));
+ } else {
+ col.column.addEvent(event);
+ }
+ }
+ }
+
+ /**
+ * Remove a calendar item so it is no longer displayed.
+ *
+ * @param {calIItemBase} event - A calendar item.
+ */
+ doRemoveItem(event) {
+ const cols = this.findColumnsForItem(event);
+ if (!cols.length) {
+ return;
+ }
+
+ const oldLength = this.mSelectedItems.length;
+ this.mSelectedItems = this.mSelectedItems.filter(item => {
+ return item.hashId != event.hashId;
+ });
+
+ for (const col of cols) {
+ const estart = event.startDate || event.entryDate || event.dueDate;
+
+ if (estart.isDate) {
+ this.doResizingHeaderOperation(col.header, () => col.header.deleteEvent(event));
+ } else {
+ col.column.deleteEvent(event);
+ }
+ }
+
+ // If a deleted event was selected, we need to announce that the selection changed.
+ if (oldLength != this.mSelectedItems.length) {
+ this.fireEvent("itemselect", this.mSelectedItems);
+ }
+ }
+
+ // CalendarFilteredViewMixin implementation.
+
+ /**
+ * Removes all items so they are no longer displayed.
+ */
+ clearItems() {
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.clear();
+ dayCol.header.clear();
+ }
+ }
+
+ /**
+ * Remove all items for a given calendar so they are no longer displayed.
+ *
+ * @param {string} calendarId - The ID of the calendar to remove items from.
+ */
+ removeItemsFromCalendar(calendarId) {
+ for (const col of this.dayColumns) {
+ // Get all-day events in column header and events within the column.
+ const colEvents = col.header.getAllEventItems().concat(col.column.getAllEventItems());
+
+ for (const event of colEvents) {
+ if (event.calendar.id == calendarId) {
+ this.doRemoveItem(event);
+ }
+ }
+ }
+ }
+
+ // End of CalendarFilteredViewMixin implementation.
+
+ /**
+ * Clear the pending magic scroll update method.
+ */
+ clearMagicScroll() {
+ if (this.magicScrollTimer) {
+ clearTimeout(this.magicScrollTimer);
+ this.magicScrollTimer = null;
+ }
+ }
+
+ /**
+ * Get the amount to scroll the view by.
+ *
+ * @param {number} startDiff - The number of pixels the mouse is from the
+ * starting edge.
+ * @param {number} endDiff - The number of pixels the mouse is from the
+ * ending edge.
+ * @param {number} scrollzone - The number of pixels from the edge at which
+ * point scrolling is triggered.
+ * @param {number} factor - The number of pixels to scroll by if touching
+ * the edge.
+ *
+ * @returns {number} - The number of pixels to scroll by scaled by the depth
+ * within the scrollzone. Zero if outside the scrollzone, negative if
+ * we're closer to the starting edge and positive if we're closer to the
+ * ending edge.
+ */
+ getScrollBy(startDiff, endDiff, scrollzone, factor) {
+ if (startDiff >= scrollzone && endDiff >= scrollzone) {
+ return 0;
+ } else if (startDiff < endDiff) {
+ return Math.floor((-1 + startDiff / scrollzone) * factor);
+ }
+ return Math.ceil((1 - endDiff / scrollzone) * factor);
+ }
+
+ /**
+ * Start scrolling the view if the given positions are close to or beyond
+ * its edge.
+ *
+ * Note, any pending updater sent to this method previously will be
+ * cancelled.
+ *
+ * @param {number} clientX - The horizontal viewport position.
+ * @param {number} clientY - The vertical viewport position.
+ * @param {Function} updater - A method to call, with some delay, if we
+ * scroll successfully.
+ */
+ setupMagicScroll(clientX, clientY, updater) {
+ this.clearMagicScroll();
+
+ // If we are at the bottom or top of the view (or left/right when
+ // rotated), calculate the difference and start accelerating the
+ // scrollbar.
+ let scrollArea = this.getScrollAreaRect();
+
+ // Distance the mouse is from the edge.
+ let diffTop = Math.max(clientY - scrollArea.top, 0);
+ let diffBottom = Math.max(scrollArea.bottom - clientY, 0);
+ let diffLeft = Math.max(clientX - scrollArea.left, 0);
+ let diffRight = Math.max(scrollArea.right - clientX, 0);
+
+ // How close to the edge we need to be to trigger scrolling.
+ let primaryZone = 50;
+ let secondaryZone = 20;
+ // How many pixels to scroll by.
+ let primaryFactor = Math.max(4 * this.pixelsPerMinute, 8);
+ let secondaryFactor = 4;
+
+ let left;
+ let top;
+ if (this.getAttribute("orient") == "horizontal") {
+ left = this.getScrollBy(diffLeft, diffRight, primaryZone, primaryFactor);
+ top = this.getScrollBy(diffTop, diffBottom, secondaryZone, secondaryFactor);
+ } else {
+ top = this.getScrollBy(diffTop, diffBottom, primaryZone, primaryFactor);
+ left = this.getScrollBy(diffLeft, diffRight, secondaryZone, secondaryFactor);
+ }
+
+ if (top || left) {
+ this.grid.scrollBy({ top, left, behaviour: "smooth" });
+ this.magicScrollTimer = setTimeout(updater, 20);
+ }
+ }
+
+ /**
+ * Get the position of the view's scrollable area (the padding area minus
+ * sticky headers and scrollbars) in the viewport.
+ *
+ * @returns {{top: number, bottom: number, left: number, right: number}} -
+ * The viewport positions of the respective scrollable area edges.
+ */
+ getScrollAreaRect() {
+ // We want the viewport coordinates of the view's scrollable area. This is
+ // the same as the padding area minus the sticky headers and scrollbars.
+ let scrollTop;
+ let scrollBottom;
+ let scrollLeft;
+ let scrollRight;
+ let view = this.grid;
+ let viewRect = view.getBoundingClientRect();
+ let headerRect = this.headerCorner.getBoundingClientRect();
+
+ // paddingTop is the top of the view's padding area. We translate from
+ // the border area of the view to the padding area by adding clientTop,
+ // which is the view's top border width.
+ let paddingTop = viewRect.top + view.clientTop;
+ // The top of the scroll area is the bottom of the sticky header.
+ scrollTop = headerRect.bottom;
+ // To get the bottom we add the clientHeight, which is the height of the
+ // padding area minus the scrollbar.
+ scrollBottom = paddingTop + view.clientHeight;
+
+ // paddingLeft is the left of the view's padding area. We translate from
+ // the border area to the padding area by adding clientLeft, which is the
+ // left border width (plus the scrollbar in right-to-left).
+ let paddingLeft = viewRect.left + view.clientLeft;
+ if (document.dir == "rtl") {
+ scrollLeft = paddingLeft;
+ // The right of the scroll area is the left of the sticky header.
+ scrollRight = headerRect.left;
+ } else {
+ // The left of the scroll area is the right of the sticky header.
+ scrollLeft = headerRect.right;
+ // To get the right we add the clientWidth, which is the width of the
+ // padding area minus the scrollbar.
+ scrollRight = paddingLeft + view.clientWidth;
+ }
+ return { top: scrollTop, bottom: scrollBottom, left: scrollLeft, right: scrollRight };
+ }
+
+ /**
+ * Scroll the view to a given minute.
+ *
+ * @param {number} minute - The minute to scroll to.
+ */
+ scrollToMinute(minute) {
+ let pos = Math.round(Math.max(0, minute) * this.pixelsPerMinute);
+ if (this.getAttribute("orient") == "horizontal") {
+ this.grid.scrollLeft = document.dir == "rtl" ? -pos : pos;
+ } else {
+ this.grid.scrollTop = pos;
+ }
+ // NOTE: this.scrollMinute is set by the "scroll" callback.
+ // This means that if we tried to scroll further than possible, the
+ // scrollMinute will be capped.
+ // Also, if pixelsPerMinute < 1, then scrollMinute may differ from the
+ // given 'minute' due to rounding errors.
+ }
+
+ /**
+ * Set the hours when the day starts and ends.
+ *
+ * @param {number} dayStartHour - Hour at which the day starts.
+ * @param {number} dayEndHour - Hour at which the day ends.
+ */
+ setDayStartEndHours(dayStartHour, dayEndHour) {
+ if (dayStartHour < 0 || dayStartHour > dayEndHour || dayEndHour > 24) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ this.dayStartHour = dayStartHour;
+ this.dayEndHour = dayEndHour;
+ // Also update on the timebar.
+ for (let [hour, hourBox] of this.hourBoxes.entries()) {
+ hourBox.classList.toggle(
+ "multiday-hour-box-off-time",
+ hour < dayStartHour || hour >= dayEndHour
+ );
+ }
+ for (let dayCol of this.dayColumns) {
+ dayCol.column.setDayStartEndHours(dayStartHour, dayEndHour);
+ }
+ }
+
+ /**
+ * Set how many hours are visible in the scrollable area.
+ *
+ * @param {number} hours - The number of visible hours.
+ */
+ setVisibleHours(hours) {
+ if (hours <= 0 || hours > 24) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ this.visibleHours = hours;
+ }
+ }
+
+ MozElements.CalendarMultidayBaseView = CalendarMultidayBaseView;
+}
diff --git a/comm/calendar/base/content/calendar-print.js b/comm/calendar/base/content/calendar-print.js
new file mode 100644
index 0000000000..17021a0cd9
--- /dev/null
+++ b/comm/calendar/base/content/calendar-print.js
@@ -0,0 +1,311 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file is loaded into the printing options page by calPrintUtils.jsm if
+ * we are printing the calendar. It injects a new form (from
+ * calendar-tab-panels.inc.xhtml) for choosing the print output. It also
+ * contains the javascript for the form.
+ */
+
+/* import-globals-from ../../../../toolkit/components/printing/content/print.js */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+// In a block to avoid polluting the global scope.
+{
+ let ownerWindow = window.browsingContext.topChromeWindow;
+ let ownerDocument = ownerWindow.document;
+
+ for (let href of [
+ "chrome://messenger/skin/icons.css",
+ "chrome://messenger/skin/variables.css",
+ "chrome://messenger/skin/widgets.css",
+ "chrome://calendar/skin/shared/widgets/minimonth.css",
+ ]) {
+ let link = document.head.appendChild(document.createElement("link"));
+ link.rel = "stylesheet";
+ link.href = href;
+ }
+
+ let otherForm = document.querySelector("form");
+ otherForm.hidden = true;
+
+ let form = document.importNode(
+ ownerDocument.getElementById("calendarPrintForm").content.firstElementChild,
+ true
+ );
+ if (AppConstants.platform != "win") {
+ // Move the Next button to the end if this isn't Windows.
+ let nextButton = form.querySelector("#next-button");
+ nextButton.parentElement.append(nextButton);
+ }
+ form.addEventListener("submit", event => {
+ event.preventDefault();
+ form.hidden = true;
+ otherForm.hidden = false;
+ });
+ otherForm.parentNode.insertBefore(form, otherForm);
+
+ let backButton = form.querySelector("#back-button");
+ backButton.addEventListener("click", () => {
+ otherForm.hidden = true;
+ form.hidden = false;
+ });
+ let backButtonContainer = form.querySelector("#back-button-container");
+ let printButtonContainer = otherForm.querySelector("#button-container");
+ printButtonContainer.parentNode.insertBefore(backButtonContainer, printButtonContainer);
+
+ let eventsCheckbox = form.querySelector("input#events");
+ let tasksCheckbox = form.querySelector("input#tasks");
+ let tasksNotDueCheckbox = form.querySelector("input#tasks-with-no-due-date");
+ let tasksCompletedCheckbox = form.querySelector("input#completed-tasks");
+
+ let layout = form.querySelector("select#layout");
+
+ let fromMinimonth = form.querySelector("calendar-minimonth#from-minimonth");
+ let fromMonth = form.querySelector("select#from-month");
+ let fromYear = form.querySelector("input#from-year");
+ let fromDate = form.querySelector("select#from-date");
+
+ let toMinimonth = form.querySelector("calendar-minimonth#to-minimonth");
+ let toMonth = form.querySelector("select#to-month");
+ let toYear = form.querySelector("input#to-year");
+ let toDate = form.querySelector("select#to-date");
+
+ for (let i = 0; i < 12; i++) {
+ let option = document.createElement("option");
+ option.value = i;
+ option.label = cal.l10n.formatMonth(i + 1, "calendar", "monthInYear");
+ fromMonth.appendChild(option.cloneNode(false));
+ toMonth.appendChild(option);
+ }
+
+ eventsCheckbox.addEventListener("change", updatePreview);
+ tasksCheckbox.addEventListener("change", function () {
+ tasksNotDueCheckbox.disabled = !this.checked;
+ tasksCompletedCheckbox.disabled = !this.checked;
+ updatePreview();
+ });
+ tasksNotDueCheckbox.addEventListener("change", updatePreview);
+ tasksCompletedCheckbox.addEventListener("change", updatePreview);
+
+ layout.addEventListener("change", onLayoutChange);
+
+ fromMinimonth.addEventListener("change", function () {
+ if (toMinimonth.value < fromMinimonth.value) {
+ toMinimonth.value = fromMinimonth.value;
+ }
+
+ updatePreview();
+ });
+ toMinimonth.addEventListener("change", updatePreview);
+
+ fromMonth.addEventListener("keydown", function (event) {
+ if (event.key == "ArrowDown" && fromMonth.selectedIndex == 11) {
+ fromMonth.selectedIndex = 0;
+ fromYear.value++;
+ onMonthChange();
+ event.preventDefault();
+ } else if (event.key == "ArrowUp" && fromMonth.selectedIndex == 0) {
+ fromMonth.selectedIndex = 11;
+ fromYear.value--;
+ onMonthChange();
+ event.preventDefault();
+ }
+ });
+ fromMonth.addEventListener("change", onMonthChange);
+ fromYear.addEventListener("change", onMonthChange);
+ toMonth.addEventListener("keydown", function (event) {
+ if (event.key == "ArrowDown" && toMonth.selectedIndex == 11) {
+ toMonth.selectedIndex = 0;
+ toYear.value++;
+ onMonthChange();
+ event.preventDefault();
+ } else if (event.key == "ArrowUp" && toMonth.selectedIndex == 0) {
+ toMonth.selectedIndex = 11;
+ toYear.value--;
+ onMonthChange();
+ event.preventDefault();
+ }
+ });
+ toMonth.addEventListener("change", onMonthChange);
+ toYear.addEventListener("change", onMonthChange);
+
+ fromDate.addEventListener("change", function () {
+ let fromValue = parseInt(fromDate.value, 10);
+ for (let option of toDate.options) {
+ option.hidden = option.value < fromValue;
+ }
+ if (toDate.value < fromValue) {
+ toDate.value = fromValue;
+ }
+
+ updatePreview();
+ });
+ toDate.addEventListener("change", updatePreview);
+
+ // Ensure the layout selector is focused and has a focus ring to make it
+ // more obvious. The ring won't be added if already focused, so blur first.
+ requestAnimationFrame(() => {
+ layout.blur();
+ Services.focus.setFocus(layout, Services.focus.FLAG_SHOWRING);
+ });
+
+ /** Show something in the preview as soon as it is ready. */
+ function updateWhenReady() {
+ document.removeEventListener("page-count", updateWhenReady);
+ onLayoutChange();
+ }
+ document.addEventListener("page-count", updateWhenReady);
+
+ /**
+ * Update the available date options to sensible ones for the selected layout.
+ * It would be nice to use HTML date inputs here but the browser this form is
+ * loaded into won't allow it. Instead use lists of the most likely values,
+ * which actually fits better for some print layouts.
+ */
+ function onLayoutChange() {
+ if (layout.value == "list") {
+ fromMinimonth.hidden = toMinimonth.hidden = false;
+ fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = true;
+ fromDate.hidden = toDate.hidden = true;
+ } else if (layout.value == "monthGrid") {
+ let today = new Date();
+ fromMonth.value = toMonth.value = today.getMonth();
+ fromYear.value = toYear.value = today.getFullYear();
+
+ fromMinimonth.hidden = toMinimonth.hidden = true;
+ fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = false;
+ fromDate.hidden = toDate.hidden = true;
+ } else {
+ const FIRST_WEEK = -53;
+ const LAST_WEEK = 53;
+
+ while (fromDate.lastChild) {
+ fromDate.lastChild.remove();
+ }
+ while (toDate.lastChild) {
+ toDate.lastChild.remove();
+ }
+
+ // Always use Monday - Sunday week, regardless of prefs, because the layout requires it.
+ let monday = cal.dtz.now();
+ monday.isDate = true;
+ monday.day = monday.day - monday.weekday + 1 + FIRST_WEEK * 7;
+
+ for (let i = FIRST_WEEK; i < LAST_WEEK; i++) {
+ let option = document.createElement("option");
+ option.value = i;
+ option.label = cal.dtz.formatter.formatDateLong(monday);
+ fromDate.appendChild(option.cloneNode(false));
+
+ let sunday = monday.clone();
+ sunday.day += 6;
+ option.label = cal.dtz.formatter.formatDateLong(sunday);
+ option.hidden = i < 0;
+ toDate.appendChild(option);
+
+ monday.day += 7;
+ }
+
+ fromDate.value = toDate.value = 0;
+
+ fromMinimonth.hidden = toMinimonth.hidden = true;
+ fromMonth.hidden = fromYear.hidden = toMonth.hidden = toYear.hidden = true;
+ fromDate.hidden = toDate.hidden = false;
+ }
+
+ updatePreview();
+ }
+
+ function onMonthChange() {
+ if (parseInt(toYear.value, 10) < fromYear.value) {
+ toYear.value = fromYear.value;
+ toMonth.value = fromMonth.value;
+ } else if (toYear.value == fromYear.value && parseInt(toMonth.value, 10) < fromMonth.value) {
+ toMonth.value = fromMonth.value;
+ }
+ updatePreview();
+ }
+
+ /**
+ * Read the selected options and update the preview document.
+ */
+ async function updatePreview() {
+ let startDate = cal.dtz.now();
+ startDate.isDate = true;
+ let endDate = cal.dtz.now();
+ endDate.isDate = true;
+
+ if (layout.value == "list") {
+ let fromValue = fromMinimonth.value;
+ let toValue = toMinimonth.value;
+
+ startDate.resetTo(
+ fromValue.getFullYear(),
+ fromValue.getMonth(),
+ fromValue.getDate(),
+ 0,
+ 0,
+ 0,
+ cal.dtz.floating
+ );
+ startDate.isDate = true;
+ if (toValue > fromValue) {
+ endDate.resetTo(
+ toValue.getFullYear(),
+ toValue.getMonth(),
+ toValue.getDate(),
+ 0,
+ 0,
+ 0,
+ cal.dtz.floating
+ );
+ endDate.isDate = true;
+ } else {
+ endDate = startDate.clone();
+ }
+ endDate.day++;
+ } else if (layout.value == "monthGrid") {
+ startDate.day = 1;
+ startDate.month = parseInt(fromMonth.value, 10);
+ startDate.year = parseInt(fromYear.value, 10);
+ endDate.day = 1;
+ endDate.month = parseInt(toMonth.value, 10);
+ endDate.year = parseInt(toYear.value, 10);
+ endDate.month++;
+ } else {
+ startDate.day = startDate.day - startDate.weekday + 1;
+ startDate.day += parseInt(fromDate.value, 10) * 7;
+ endDate.day = endDate.day - endDate.weekday + 1;
+ endDate.day += parseInt(toDate.value, 10) * 7 + 7;
+ }
+
+ let filter = 0;
+ if (tasksCheckbox.checked) {
+ filter |= Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ if (tasksCompletedCheckbox.checked) {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ } else {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ }
+ }
+
+ if (eventsCheckbox.checked) {
+ filter |=
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT | Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ }
+
+ await cal.print.draw(
+ PrintEventHandler.printPreviewEl.querySelector("browser").contentDocument,
+ layout.value,
+ startDate,
+ endDate,
+ filter,
+ tasksNotDueCheckbox.checked
+ );
+ PrintEventHandler._updatePrintPreview();
+ }
+}
diff --git a/comm/calendar/base/content/calendar-status-bar.inc.xhtml b/comm/calendar/base/content/calendar-status-bar.inc.xhtml
new file mode 100644
index 0000000000..e99078a27b
--- /dev/null
+++ b/comm/calendar/base/content/calendar-status-bar.inc.xhtml
@@ -0,0 +1,75 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- event/task in tab statusbarpanels -->
+<hbox id="status-privacy"
+ class="statusbarpanel event-dialog"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.statusbarpanel.privacy.label;"/>
+ <hbox id="status-privacy-public-box" privacy="PUBLIC">
+ <label value="&event.menu.options.privacy.public.label;"/>
+ </hbox>
+ <hbox id="status-privacy-confidential-box" privacy="CONFIDENTIAL">
+ <label value="&event.menu.options.privacy.confidential.label;"/>
+ </hbox>
+ <hbox id="status-privacy-private-box" privacy="PRIVATE">
+ <label value="&event.menu.options.privacy.private.label;"/>
+ </hbox>
+</hbox>
+<hbox id="status-priority"
+ class="statusbarpanel event-dialog"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.priority2.label;"/>
+ <html:img class="cal-statusbar-1" />
+</hbox>
+<hbox id="status-status"
+ class="statusbarpanel event-dialog"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&task.status.label;"/>
+ <label id="status-status-tentative-label"
+ value="&newevent.status.tentative.label;"
+ hidden="true"/>
+ <label id="status-status-confirmed-label"
+ value="&newevent.status.confirmed.label;"
+ hidden="true"/>
+ <label id="status-status-cancelled-label"
+ value="&newevent.eventStatus.cancelled.label;"
+ hidden="true"/>
+</hbox>
+<hbox id="status-freebusy"
+ class="statusbarpanel event-only event-dialog"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.statusbarpanel.freebusy.label;"/>
+ <label id="status-freebusy-free-label"
+ value="&event.freebusy.legend.free;"
+ hidden="true"/>
+ <label id="status-freebusy-busy-label"
+ value="&event.freebusy.legend.busy;"
+ hidden="true"/>
+</hbox>
+<!-- end event/task in tab statusbarpanels -->
+
+<calendar-modebox id="calendar-show-todaypane-panel"
+ class="statusbarpanel themeable-brighttext hide-when-calendar-deactivated"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ collapsedinmodes="special"
+ pack="center">
+ <toolbarbutton id="calendar-status-todaypane-button"
+ type="checkbox"
+ label="&todaypane.statusButton.label;"
+ tooltiptext="&calendar.todaypane.button.tooltip;"
+ command="calendar_toggle_todaypane_command"/>
+</calendar-modebox>
diff --git a/comm/calendar/base/content/calendar-statusbar.js b/comm/calendar/base/content/calendar-statusbar.js
new file mode 100644
index 0000000000..8d463ffc67
--- /dev/null
+++ b/comm/calendar/base/content/calendar-statusbar.js
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/* exported gCalendarStatusFeedback */
+
+/**
+ * This code might change soon if we support Thunderbird's activity manager.
+ * NOTE: The naming "Meteors" is historical.
+ */
+var gCalendarStatusFeedback = {
+ mCalendarStep: 0,
+ mCalendarCount: 0,
+ mWindow: null,
+ mStatusText: null,
+ mStatusBar: null,
+ mStatusProgressPanel: null,
+ mThrobber: null,
+ mProgressMode: Ci.calIStatusObserver.NO_PROGRESS,
+ mCurIndex: 0,
+ mInitialized: false,
+ mCalendars: {},
+
+ QueryInterface: ChromeUtils.generateQI(["calIStatusObserver"]),
+
+ initialize(aWindow) {
+ if (!this.mInitialized) {
+ this.mWindow = aWindow;
+ this.mStatusText = this.mWindow.document.getElementById("statusText");
+ this.mStatusBar = this.mWindow.document.getElementById("statusbar-icon");
+ this.mStatusProgressPanel = this.mWindow.document.getElementById("statusbar-progresspanel");
+ this.mThrobber = this.mWindow.document.getElementById("navigator-throbber");
+ this.mInitialized = true;
+ }
+ },
+
+ showStatusString(status) {
+ this.mStatusText.setAttribute("label", status);
+ },
+
+ get spinning() {
+ return this.mProgressMode;
+ },
+
+ startMeteors(aProgressMode, aCalendarCount) {
+ if (aProgressMode != Ci.calIStatusObserver.NO_PROGRESS) {
+ if (!this.mInitialized) {
+ console.error("StatusObserver has not been initialized!");
+ return;
+ }
+ this.mCalendars = {};
+ this.mCurIndex = 0;
+ if (aCalendarCount) {
+ this.mCalendarCount = this.mCalendarCount + aCalendarCount;
+ this.mCalendarStep = Math.trunc(100 / this.mCalendarCount);
+ }
+ this.mProgressMode = aProgressMode;
+ this.mStatusProgressPanel.removeAttribute("collapsed");
+ if (this.mProgressMode == Ci.calIStatusObserver.DETERMINED_PROGRESS) {
+ this.mStatusBar.value = 0;
+ let commonStatus = cal.l10n.getCalString("gettingCalendarInfoCommon");
+ this.showStatusString(commonStatus);
+ }
+ if (this.mThrobber) {
+ this.mThrobber.setAttribute("busy", true);
+ }
+ }
+ },
+
+ stopMeteors() {
+ if (!this.mInitialized) {
+ return;
+ }
+ if (this.spinning != Ci.calIStatusObserver.NO_PROGRESS) {
+ this.mProgressMode = Ci.calIStatusObserver.NO_PROGRESS;
+ this.mStatusProgressPanel.collapsed = true;
+ this.mStatusBar.value = 0;
+ this.mCalendarCount = 0;
+ this.showStatusString("");
+ if (this.mThrobber) {
+ this.mThrobber.setAttribute("busy", false);
+ }
+ }
+ },
+
+ calendarCompleted(aCalendar) {
+ if (!this.mInitialized) {
+ return;
+ }
+ if (this.spinning != Ci.calIStatusObserver.NO_PROGRESS) {
+ if (this.spinning == Ci.calIStatusObserver.DETERMINED_PROGRESS) {
+ if (!this.mCalendars[aCalendar.id] || this.mCalendars[aCalendar.id] === undefined) {
+ this.mCalendars[aCalendar.id] = true;
+ this.mStatusBar.value = parseInt(this.mStatusBar.value, 10) + this.mCalendarStep;
+ this.mCurIndex++;
+ let curStatus = cal.l10n.getCalString("gettingCalendarInfoDetail", [
+ this.mCurIndex,
+ this.mCalendarCount,
+ ]);
+ this.showStatusString(curStatus);
+ }
+ }
+ if (this.mThrobber) {
+ this.mThrobber.setAttribute("busy", true);
+ }
+ }
+ },
+};
diff --git a/comm/calendar/base/content/calendar-tab-panels.inc.xhtml b/comm/calendar/base/content/calendar-tab-panels.inc.xhtml
new file mode 100644
index 0000000000..d1eb617ef2
--- /dev/null
+++ b/comm/calendar/base/content/calendar-tab-panels.inc.xhtml
@@ -0,0 +1,661 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<vbox id="calendarTabPanel">
+ <hbox id="calendarContent" flex="1">
+ <vbox id="calSidebar"
+ persist="collapsed width">
+ <html:div id="primaryButtonSidePanel"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <button id="sidePanelNewEvent"
+ data-l10n-id="calendar-new-event-primary-button"
+ class="button button-primary icon-button"
+ onclick="goDoCommand('calendar_new_event_command')"
+ hidden="hidden"
+ type="button">
+ </button>
+ <button id="sidePanelNewTask"
+ data-l10n-id="calendar-new-task-primary-button"
+ class="button button-primary icon-button"
+ onclick="goDoCommand('calendar_new_todo_command')"
+ hidden="hidden"
+ type="button">
+ </button>
+ </html:div>
+ <calendar-modevbox id="minimonth-pane"
+ mode="calendar,task"
+ refcontrol="calendar_toggle_minimonthpane_command">
+ <vbox align="center">
+ <hbox id="calMinimonthBox" pack="center">
+ <calendar-minimonth id="calMinimonth" onchange="minimonthPick(this.value);"/>
+ </hbox>
+ </vbox>
+ </calendar-modevbox>
+ <separator id="minimonth-splitter" style="min-width:100px;"/>
+ <vbox id="calendar-panel" flex="1">
+ <calendar-modevbox id="task-filter-pane"
+ mode="task"
+ refcontrol="calendar_toggle_filter_command">
+ <checkbox id="task-tree-filter-header"
+ checked="true"
+ class="treenode-checkbox"
+ label="&calendar.task.filter.title.label;"/>
+ <calendar-modevbox id="task-filtertree-pane"
+ flex="1"
+ mode="task"
+ refcontrol="task-tree-filter-header">
+ <radiogroup id="task-tree-filtergroup" class="task-tree-subpane"
+ persist="value">
+ <radio id="opt_throughcurrent_filter" label="&calendar.task.filter.current.label;" value="throughcurrent" command="calendar_task_filter_command"/>
+ <radio id="opt_today_filter" label="&calendar.task.filter.today.label;" value="throughtoday" command="calendar_task_filter_command"/>
+ <radio id="opt_next7days_filter" label="&calendar.task.filter.next7days.label;" value="throughsevendays" command="calendar_task_filter_command"/>
+ <radio id="opt_notstarted_filter" label="&calendar.task.filter.notstarted.label;" value="notstarted" command="calendar_task_filter_command"/>
+ <radio id="opt_overdue_filter" label="&calendar.task.filter.overdue.label;" value="overdue" command="calendar_task_filter_command"/>
+ <radio id="opt_completed_filter" label="&calendar.task.filter.completed.label;" value="completed" command="calendar_task_filter_command"/>
+ <radio id="opt_open_filter" label="&calendar.task.filter.open.label;" value="open" command="calendar_task_filter_command"/>
+ <radio id="opt_all_filter" label="&calendar.task.filter.all.label;" value="all" command="calendar_task_filter_command"/>
+ </radiogroup>
+ </calendar-modevbox>
+ </calendar-modevbox>
+ <calendar-modevbox id="calendar-list-pane"
+ flex="1"
+ mode="calendar,task"
+ refcontrol="calendar_toggle_calendarlist_command">
+ <html:button id="calendarListHeader"
+ class="calendar-list-header button-flat"
+ onclick="toggleVisibilityCalendarsList(event);">
+ <html:span data-l10n-id="calendar-list-header"></html:span>
+ <html:img id="toggleCalendarIcon"
+ src="chrome://messenger/skin/icons/new/nav-down-sm.svg"
+ alt="" />
+ </html:button>
+ <calendar-modevbox id="calendar-list-inner-pane"
+ flex="1"
+ mode="calendar,task"
+ refcontrol="calendarListHeader"
+ oncontextmenu="openCalendarListItemContext(event);">
+ <html:ol id="calendar-list" is="orderable-tree-listbox"
+ role="listbox"></html:ol>
+ <template id="calendar-list-item" xmlns="http://www.w3.org/1999/xhtml">
+ <li draggable="true" role="option">
+ <div class="calendar-color"></div>
+ <div class="calendar-name"></div>
+ <img class="calendar-readstatus calendar-list-icon"
+ src="chrome://messenger/skin/icons/new/compact/lock.svg"
+ alt="" />
+ <img class="calendar-mute-status calendar-list-icon"
+ src="chrome://messenger/skin/icons/new/bell-disabled.svg"
+ alt="" />
+ <button class="calendar-enable-button"></button>
+ <input type="checkbox"
+ class="calendar-displayed" />
+ <button class="calendar-more-button button icon-button icon-only"
+ onclick="openCalendarListItemContext(event);"
+ type="button">
+ </button>
+ </li>
+ </template>
+ </calendar-modevbox>
+ <html:div id="sideButtonsBottom" xmlns="http://www.w3.org/1999/xhtml">
+ <button id="newCalendarSidebarButton"
+ class="button button-flat icon-button"
+ data-l10n-id="calendar-import-new-calendar"
+ onclick="goDoCommand('calendar_new_calendar_command')"
+ type="button">
+ </button>
+ <button id="refreshCalendar"
+ class="button button-flat icon-button icon-only"
+ data-l10n-id="calendar-refresh-calendars"
+ onclick="goDoCommand('calendar_reload_remote_calendars')"
+ type="button">
+ </button>
+ </html:div>
+ </calendar-modevbox>
+ </vbox>
+ </vbox>
+
+ <splitter id="calsidebar_splitter"
+ collapse="before"
+ persist="state"
+ class="calendar-sidebar-splitter"/>
+
+ <hbox id="calendarDisplayBox" flex="1">
+ <!-- Events View ("Unifinder") -->
+ <vbox id="calendar-view-box"
+ flex="1"
+ context="calendar-view-context-menu"
+ collapsed="true">
+ <vbox id="calendar-deactivated-notification-location-events">
+ <!-- Calendar deactivated notificationbox for events will be added here lazily. -->
+ </vbox>
+ <vbox id="bottom-events-box" persist="height">
+ <hbox id="unifinder-searchBox" class="themeable-brighttext" persist="collapsed">
+ <box align="center">
+ <menulist id="event-filter-menulist" value="P7D" persist="value">
+ <menupopup id="event-filter-menupopup" oncommand="refreshEventTree()">
+ <menuitem id="event-filter-all"
+ label="&calendar.events.filter.all.label;"
+ value="all"/>
+ <menuitem id="event-filter-today"
+ label="&calendar.events.filter.today.label;"
+ value="today"/>
+ <menuitem id="event-filter-next7days"
+ label="&calendar.events.filter.next7Days.label;"
+ value="P7D"/>
+ <menuitem id="event-filter-next14Days"
+ label="&calendar.events.filter.next14Days.label;"
+ value="P14D"/>
+ <menuitem id="event-filter-next31Days"
+ label="&calendar.events.filter.next31Days.label;"
+ value="P31D"/>
+ <menuitem id="event-filter-thisCalendarMonth"
+ label="&calendar.events.filter.thisCalendarMonth.label;"
+ value="thisCalendarMonth"/>
+ <menuitem id="event-filter-future"
+ label="&calendar.events.filter.future.label;"
+ value="future"/>
+ <menuitem id="event-filter-current"
+ label="&calendar.events.filter.current.label;"
+ value="current"/>
+ <menuitem id="event-filter-currentview"
+ label="&calendar.events.filter.currentview.label;"
+ value="currentview"/>
+ </menupopup>
+ </menulist>
+ </box>
+ <box align="center" flex="1">
+ <label control="unifinder-search-field" value="&calendar.search.options.searchfor;"/>
+ <search-textbox id="unifinder-search-field"
+ class="themeableSearchBox"
+ oncommand="refreshEventTree();"
+ flex="1"/>
+ </box>
+ <toolbarbutton id="unifinder-closer"
+ class="unifinder-closebutton close-icon"
+ command="calendar_show_unifinder_command"
+ tooltiptext="&calendar.unifinder.close.tooltip;"/>
+ </hbox>
+ <tree id="unifinder-search-results-tree" flex="1"
+ onselect="unifinderSelect(event); calendarController.onSelectionChanged()"
+ onkeypress="unifinderKeyPress(event)"
+ _selectDelay="500"
+ persist="sort-active sort-direction"
+ enableColumnDrag="true">
+ <treecols id="unifinder-search-results-tree-cols">
+ <treecol id="unifinder-search-results-tree-col-title"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ itemproperty="title"
+ label="&calendar.unifinder.tree.title.label;"
+ tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unifinder-search-results-tree-col-startdate"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ itemproperty="startDate"
+ label="&calendar.unifinder.tree.startdate.label;"
+ tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unifinder-search-results-tree-col-enddate"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ itemproperty="endDate"
+ label="&calendar.unifinder.tree.enddate.label;"
+ tooltiptext="&calendar.unifinder.tree.enddate.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unifinder-search-results-tree-col-categories"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ itemproperty="categories"
+ label="&calendar.unifinder.tree.categories.label;"
+ tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unifinder-search-results-tree-col-location"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ hidden="true"
+ itemproperty="location"
+ label="&calendar.unifinder.tree.location.label;"
+ tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="unifinder-search-results-tree-col-status"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ hidden="true"
+ itemproperty="status"
+ label="&calendar.unifinder.tree.status.label;"
+ tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/>
+ <treecol id="unifinder-search-results-tree-col-calendarname"
+ persist="hidden ordinal width"
+ style="flex: 1 auto"
+ closemenu="none"
+ hidden="true"
+ itemproperty="calendar"
+ label="&calendar.unifinder.tree.calendarname.label;"
+ tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/>
+ </treecols>
+
+ <!-- on mousedown here happens before onclick above -->
+ <treechildren tooltip="eventTreeTooltip"
+ context="calendar-item-context-menu"
+ onkeypress="if (event.key == 'Enter') { unifinderEditCommand(); }"
+ ondragenter="return false;"
+ ondblclick="unifinderDoubleClick(event)"
+ onfocus="focusFirstItemIfNoSelection();"/>
+ </tree>
+ </vbox>
+ <splitter id="calendar-view-splitter"
+ resizebefore="closest"
+ resizeafter="farthest"
+ persist="state"
+ class="chromeclass-extrachrome sidebar-splitter calendar-splitter"
+ orient="vertical"
+ onmouseup="setTimeout(refreshEventTree, 10);"/>
+
+ <!-- Calendar Navigation Control Bar -->
+ <html:div id="calendarViewHeader"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <div class="navigation-inner-box" >
+ <!-- If you are extending a view, add attributes to these
+ nodes for your view. i.e if your view has the id
+ "foobar-view", then you need to add the attribute
+ tooltiptext-foobar="..." -->
+ <div id="calendarControls">
+ <div class="button-group">
+ <button id="previousViewButton"
+ class="button icon-button icon-only"
+ onclick="goDoCommand('calendar_view_prev_command')"
+ type="button">
+ </button>
+ <button id="todayViewButton"
+ class="button icon-button icon-only"
+ data-l10n-id="calendar-today-button-tooltip"
+ onclick="currentView().moveView()"
+ type="button">
+ </button>
+ <button id="nextViewButton"
+ class="button icon-button icon-only"
+ onclick="goDoCommand('calendar_view_next_command')"
+ type="button">
+ </button>
+ </div>
+ </div>
+ <span id="intervalDescription" class="view-header"/>
+ </div>
+ <div class="navigation-inner-box">
+ <span id="calendarWeek" class="view-header"/>
+ <div id="viewToggle" role="tablist" class="calview-toggle">
+ <button id="calTabDay"
+ class="calview-toggle-item"
+ onclick="goDoCommand('calendar_day-view_command')"
+ role="tab"
+ aria-controls="day-view"
+ aria-selected="false"
+ data-l10n-id="calendar-view-toggle-day"></button>
+ <button id="calTabWeek"
+ class="calview-toggle-item"
+ onclick="goDoCommand('calendar_week-view_command')"
+ role="tab"
+ aria-controls="week-view"
+ aria-selected="false"
+ data-l10n-id="calendar-view-toggle-week"></button>
+ <button id="calTabMultiweek"
+ class="calview-toggle-item"
+ onclick="goDoCommand('calendar_multiweek-view_command')"
+ role="tab"
+ aria-controls="multiweek-view"
+ aria-selected="false"
+ data-l10n-id="calendar-view-toggle-multiweek"></button>
+ <button id="calTabMonth"
+ class="calview-toggle-item"
+ onclick="goDoCommand('calendar_month-view_command')"
+ role="tab"
+ aria-controls="month-view"
+ aria-selected="false"
+ data-l10n-id="calendar-view-toggle-month"></button>
+ </div>
+ <button id="calendarControlBarMenu"
+ class="button button-flat icon-button icon-only"
+ onclick="showCalControlBarMenuPopup(event)"
+ data-l10n-id="calendar-control-bar-menu-button"
+ type="button">
+ </button>
+ </div>
+ </html:div>
+ <vbox flex="1"
+ id="view-box"
+ persist="selectedIndex">
+ <!-- Note: the "id" attributes of the calendar panes **must** follow the
+ notation 'type + "-" + "view"', where "type" should refer to the
+ displayed time period as described in base/public/calICalendarView.idl -->
+ <calendar-day-view id="day-view"
+ context="calendar-view-context-menu"
+ item-context="calendar-item-context-menu"/>
+ <calendar-week-view id="week-view"
+ context="calendar-view-context-menu"
+ item-context="calendar-item-context-menu"/>
+ <calendar-multiweek-view id="multiweek-view" flex="1"
+ context="calendar-view-context-menu"
+ item-context="calendar-item-context-menu"/>
+ <calendar-month-view id="month-view" flex="1"
+ context="calendar-view-context-menu"
+ item-context="calendar-item-context-menu"/>
+ </vbox>
+ </vbox>
+ <!-- Tasks View -->
+ <vbox id="calendar-task-box" flex="1"
+ collapsed="true">
+ <vbox id="calendar-deactivated-notification-location-tasks">
+ <!-- Calendar deactivated notificationbox for tasks will be added here lazily. -->
+ </vbox>
+ <hbox id="task-addition-box" class="themeable-brighttext" align="center">
+ <box align="center" flex="1">
+ <toolbarbutton id="calendar-add-task-button"
+ label="&calendar.newtask.button.label;"
+ tooltiptext="&calendar.newtask.button.tooltip;"
+ command="calendar_new_todo_command"/>
+ <hbox align="center" flex="1" class="input-container">
+ <html:input id="view-task-edit-field"
+ class="task-edit-field themeableSearchBox"
+ onfocus="taskEdit.onFocus(event)"
+ onblur="taskEdit.onBlur(event)"
+ onkeypress="taskEdit.onKeyPress(event)"/>
+ </hbox>
+ </box>
+ <box align="center" flex="1">
+ <search-textbox id="task-text-filter-field"
+ class="themeableSearchBox"
+ flex="1"
+ placeholder=""
+ emptytextbase="&calendar.task.text-filter.textbox.emptytext.base1;"
+ keyLabelNonMac="&calendar.task.text-filter.textbox.emptytext.keylabel.nonmac;"
+ keyLabelMac="&calendar.task.text-filter.textbox.emptytext.keylabel.mac;"
+ oncommand="taskViewUpdate();"/>
+ </box>
+ </hbox>
+ <vbox flex="1">
+ <tree is="calendar-task-tree" id="calendar-task-tree"
+ flex="1"
+ visible-columns="completed priority title entryDate dueDate"
+ persist="visible-columns ordinals widths sort-active sort-direction height"
+ context="taskitem-context-menu"
+ onselect="taskDetailsView.onSelect(event);"/>
+ <splitter id="calendar-task-view-splitter" collapse="none" persist="state" class="calendar-splitter"/>
+ <vbox id="calendar-task-details-container"
+ flex="1"
+ persist="height"
+ hidden="true">
+ <hbox id="calendar-task-details">
+ <hbox id="other-actions-box">
+ <vbox id="task-actions-toolbox" class="inline-toolbox">
+ <hbox id="task-actions-toolbar" class="themeable-brighttext">
+ <toolbarbutton id="task-actions-category"
+ type="menu"
+ wantdropmarker="true"
+ label="&calendar.unifinder.tree.categories.label;"
+ tooltiptext="&calendar.task.category.button.tooltip;"
+ command="calendar_task_category_command"
+ class="toolbarbutton-1 message-header-view-button">
+ <menupopup id="task-actions-category-popup"
+ onpopupshowing="taskDetailsView.loadCategories(event);"
+ onpopuphiding="return taskDetailsView.saveCategories(event);">
+ <html:input id="task-actions-category-textbox"
+ placeholder="&event.categories.textbox.label;"
+ onblur="this.parentNode.removeAttribute(&quot;ignorekeys&quot;);"
+ onfocus="this.parentNode.setAttribute(&quot;ignorekeys&quot;, &quot;true&quot;);"
+ onkeypress="taskDetailsView.categoryTextboxKeypress(event);"/>
+ <menuseparator/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton is="toolbarbutton-menu-button" id="task-actions-markcompleted"
+ type="menu"
+ label="&calendar.context.markcompleted.label;"
+ tooltiptext="&calendar.task.complete.button.tooltip;"
+ command="calendar_toggle_completed_command"
+ class="toolbarbutton-1 message-header-view-button">
+ <menupopup is="calendar-task-progress-menupopup" id="task-actions-markcompleted-menupopup"/>
+ </toolbarbutton>
+ <toolbarbutton id="task-actions-priority"
+ type="menu"
+ wantdropmarker="true"
+ label="&calendar.context.priority.label;"
+ tooltiptext="&calendar.task.priority.button.tooltip;"
+ command="calendar_general-priority_command"
+ class="toolbarbutton-1 message-header-view-button">
+ <menupopup is="calendar-task-priority-menupopup" id="task-actions-priority-menupopup"/>
+ </toolbarbutton>
+ <toolbarbutton id="calendar-delete-task-button"
+ class="toolbarbutton-1 message-header-view-button"
+ label="&calendar.taskview.delete.label;"
+ tooltiptext="&calendar.context.deletetask.label;"
+ command="calendar_delete_todo_command"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <hbox id ="calendar-task-details-box">
+ <html:table id="calendar-task-details-grid">
+ <html:tr id="calendar-task-details-title-row"
+ hidden="hidden">
+ <html:th class="task-details-name">
+ &calendar.task-details.title.label;
+ </html:th>
+ <html:td id="calendar-task-details-title"
+ class="task-details-value">
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-task-details-priority-row"
+ hidden="hidden">
+ <html:th id="calendar-task-details-priority-label"
+ class="task-details-name">
+ &calendar.task-details.priority.label;
+ </html:th>
+ <html:td id="calendar-task-details-priority-td">
+ <label id="calendar-task-details-priority-low"
+ value="&calendar.task-details.priority.low.label;"
+ class="task-details-value"
+ hidden="true"/>
+ <label id="calendar-task-details-priority-normal"
+ value="&calendar.task-details.priority.normal.label;"
+ class="task-details-value"
+ hidden="true"/>
+ <label id="calendar-task-details-priority-high"
+ value="&calendar.task-details.priority.high.label;"
+ class="task-details-value"
+ hidden="true"/>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-task-details-organizer-row"
+ hidden="hidden">
+ <html:th class="task-details-name">
+ &calendar.task-details.organizer.label;
+ </html:th>
+ <html:td id="calendar-task-details-organizer"
+ class="task-details-value text-link"
+ onclick="sendMailToOrganizer()">
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-task-details-status-row"
+ hidden="hidden">
+ <html:th class="task-details-name">
+ &calendar.task-details.status.label;
+ </html:th>
+ <html:td id="calendar-task-details-status"
+ class="task-details-value">
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-task-details-category-row"
+ hidden="hidden">
+ <html:th class="task-details-name">
+ &calendar.task-details.category.label;
+ </html:th>
+ <html:td id="calendar-task-details-category"
+ class="task-details-value">
+ </html:td>
+ </html:tr>
+ <html:tr id="task-start-row"
+ class="item-date-row"
+ hidden="hidden">
+ <html:th class="headline">
+ &calendar.task-details.start.label;
+ </html:th>
+ <html:td id="task-start-date">
+ </html:td>
+ </html:tr>
+ <html:tr id="task-due-row"
+ class="item-date-row"
+ hidden="hidden">
+ <html:th class="headline">
+ &calendar.task-details.due.label;
+ </html:th>
+ <html:td id="task-due-date">
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-task-details-repeat-row"
+ hidden="hidden">
+ <html:th class="task-details-name">
+ &calendar.task-details.repeat.label;
+ </html:th>
+ <html:td id="calendar-task-details-repeat"
+ class="task-details-value">
+ </html:td>
+ </html:tr>
+ </html:table>
+ </hbox>
+ </hbox>
+ <hbox id="calendar-task-details-description-wrapper" flex="1">
+ <iframe id="calendar-task-details-description" type="content"/>
+ </hbox>
+ <hbox id="calendar-task-details-attachment-row"
+ align="start"
+ hidden="true">
+ <hbox pack="end">
+ <label value="&calendar.task-details.attachments.label;"
+ class="task-details-name"/>
+ </hbox>
+ <vbox id="calendar-task-details-attachment-rows"
+ align="start"
+ flex="1"
+ style="overflow: auto;"
+ hidden="true">
+ </vbox>
+ </hbox>
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+ </hbox>
+
+ <!-- This form is injected into the printing options page (by calendar-print.js)
+ if we are printing the calendar. The script for the form is also in
+ calendar-print.js and CSS in calendar-print.css. -->
+ <template xmlns="http://www.w3.org/1999/xhtml" id="calendarPrintForm">
+ <form id="calendar-print">
+ <link rel="localization" href="calendar/calendar-print.ftl"/>
+ <link rel="stylesheet" href="chrome://calendar/skin/shared/calendar-print.css"/>
+
+ <section class="body-container">
+ <section class="section-block">
+ <label for="layout" class="block-label" data-l10n-id="calendar-print-layout-label"></label>
+ <div class="layout-wrapper">
+ <select id="layout" autocomplete="off">
+ <option value="list" data-l10n-id="calendar-print-layout-list"></option>
+ <option value="monthGrid" data-l10n-id="calendar-print-layout-month-grid"></option>
+ <option value="weekPlanner" data-l10n-id="calendar-print-layout-week-planner"></option>
+ </select>
+ </div>
+ </section>
+
+ <section class="section-block">
+ <label class="block-label" data-l10n-id="calendar-print-filter-label"></label>
+ <label class="row cols-2">
+ <input type="checkbox" id="events" checked="checked" autocomplete="off" />
+ <span data-l10n-id="calendar-print-filter-events"></span>
+ </label>
+ <label class="row cols-2">
+ <input type="checkbox" id="tasks" checked="checked" autocomplete="off" />
+ <span data-l10n-id="calendar-print-filter-tasks"></span>
+ </label>
+ <label class="row cols-2 indent">
+ <input type="checkbox" id="completed-tasks" checked="checked" autocomplete="off" />
+ <span data-l10n-id="calendar-print-filter-completedtasks"></span>
+ </label>
+ <label class="row cols-2 indent">
+ <input type="checkbox" id="tasks-with-no-due-date" checked="checked" autocomplete="off" />
+ <span data-l10n-id="calendar-print-filter-taskswithnoduedate"></span>
+ </label>
+ </section>
+
+ <fieldset class="section-block">
+ <legend class="block-label" data-l10n-id="calendar-print-range-from"></legend>
+ <xul:calendar-minimonth id="from-minimonth"></xul:calendar-minimonth>
+ <select id="from-month"></select>
+ <input id="from-year" type="number" size="1"/>
+ <select id="from-date"></select>
+ </fieldset>
+ <fieldset class="section-block">
+ <legend class="block-label" data-l10n-id="calendar-print-range-to"></legend>
+ <xul:calendar-minimonth id="to-minimonth"></xul:calendar-minimonth>
+ <select id="to-month"></select>
+ <input id="to-year" type="number" size="1"/>
+ <select id="to-date"></select>
+ </fieldset>
+ </section>
+
+ <hr />
+
+ <footer class="footer-container" role="none">
+ <section id="next-button-container" class="section-block">
+ <button id="next-button"
+ class="primary"
+ type="submit"
+ showfocus=""
+ autocomplete="off"
+ data-l10n-id="calendar-print-next-button"></button>
+ <button is="cancel-button"
+ type="button"
+ data-l10n-id="printui-cancel-button"
+ data-close-l10n-id="printui-close-button"
+ data-cancel-l10n-id="printui-cancel-button"></button>
+ </section>
+ </footer>
+
+ <!-- This section will be added to the footer of the original form. -->
+ <section id="back-button-container" class="section-block">
+ <button id="back-button"
+ type="button"
+ autocomplete="off"
+ data-l10n-id="calendar-print-back-button"></button>
+ </section>
+ </form>
+ </template>
+</vbox>
+
+<!-- Menus -->
+
+<menupopup id="calControlBarMenuPopup" position="bottomleft topleft">
+ <menuitem id="findEventsButton"
+ data-l10n-id="calendar-find-events-menu-option"
+ type="checkbox"
+ checked="true"
+ command="calendar_show_unifinder_command"/>
+ <menuseparator id="separatorBeforeHideWeekends"/>
+ <menuitem id="hideWeekendsButton"
+ data-l10n-id="calendar-hide-weekends-option"
+ type="checkbox"
+ command="calendar_toggle_workdays_only_command"/>
+ <menuitem id="defineWorkweekButton"
+ data-l10n-id="calendar-define-workweek-option"
+ oncommand="showCalendarWeekPreferences();"/>
+ <menuseparator id="separatorBeforeTasks"/>
+ <menuitem id="showTasksInCalendarButton"
+ data-l10n-id="calendar-show-tasks-calendar-option"
+ type="checkbox"
+ command="calendar_toggle_tasks_in_view_command"/>
+</menupopup>
diff --git a/comm/calendar/base/content/calendar-tabs.js b/comm/calendar/base/content/calendar-tabs.js
new file mode 100644
index 0000000000..4681c5f6f9
--- /dev/null
+++ b/comm/calendar/base/content/calendar-tabs.js
@@ -0,0 +1,419 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from item-editing/calendar-item-editing.js */
+/* import-globals-from item-editing/calendar-item-panel.js */
+/* import-globals-from calendar-command-controller.js */
+/* import-globals-from calendar-modes.js */
+/* import-globals-from calendar-views-utils.js */
+
+/* globals MozElements */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var calendarTabMonitor = {
+ monitorName: "calendarTabMonitor",
+
+ // Unused, but needed functions
+ onTabTitleChanged() {},
+ onTabOpened() {},
+ onTabClosing() {},
+ onTabPersist() {},
+ onTabRestored() {},
+
+ onTabSwitched(aNewTab, aOldTab) {
+ // Unfortunately, tabmail doesn't provide a hideTab function on the tab
+ // type definitions. To make sure the commands are correctly disabled,
+ // we want to update calendar/task commands when switching away from
+ // those tabs.
+ if (aOldTab?.mode.name == "calendar" || aOldTab?.mode.name == "task") {
+ calendarController.updateCommands();
+ calendarController2.updateCommands();
+ }
+ // we reset the save menu controls when moving away (includes closing)
+ // from an event or task editor tab
+ if (aNewTab.mode.name == "calendarEvent" || aNewTab.mode.name == "calendarTask") {
+ sendMessage({ command: "triggerUpdateSaveControls" });
+ } else if (window.calItemSaveControls) {
+ // we need to reset the labels of the menu controls for saving if we
+ // are not switching to an item tab and displayed an item tab before
+ let saveMenu = document.getElementById("calendar-save-menuitem");
+ let saveandcloseMenu = document.getElementById("calendar-save-and-close-menuitem");
+ saveMenu.label = window.calItemSaveControls.saveMenu.label;
+ saveandcloseMenu.label = window.calItemSaveControls.saveandcloseMenu.label;
+ }
+
+ // Change the mode (gCurrentMode) to match the new tab.
+ switch (aNewTab.mode.name) {
+ case "calendar":
+ calSwitchToCalendarMode();
+ break;
+ case "tasks":
+ calSwitchToTaskMode();
+ break;
+ case "chat":
+ case "calendarEvent":
+ case "calendarTask":
+ calSwitchToMode(aNewTab.mode.name);
+ break;
+ case "addressBookTab":
+ case "preferencesTab":
+ case "contentTab":
+ calSwitchToMode("special");
+ break;
+ default:
+ calSwitchToMode("mail");
+ break;
+ }
+ },
+};
+
+var calendarTabType = {
+ name: "calendar",
+ panelId: "calendarTabPanel",
+ modes: {
+ calendar: {
+ type: "calendar",
+ maxTabs: 1,
+ openTab(tab) {
+ tab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/calendar.svg");
+ gLastShownCalendarView.get();
+ tab.title = cal.l10n.getLtnString("tabTitleCalendar");
+ },
+ showTab(tab) {},
+ closeTab(tab) {},
+
+ persistTab(tab) {
+ let tabmail = document.getElementById("tabmail");
+ return {
+ // Since we do strange tab switching logic in calSwitchToCalendarMode,
+ // we should store the current tab state ourselves.
+ background: tab != tabmail.currentTabInfo,
+ };
+ },
+
+ restoreTab(tabmail, state) {
+ tabmail.openTab("calendar", state);
+ },
+
+ onTitleChanged(tab) {
+ tab.title = cal.l10n.getLtnString("tabTitleCalendar");
+ },
+
+ supportsCommand: (aCommand, aTab) => calendarController2.supportsCommand(aCommand),
+ isCommandEnabled: (aCommand, aTab) => calendarController2.isCommandEnabled(aCommand),
+ doCommand: (aCommand, aTab) => calendarController2.doCommand(aCommand),
+ onEvent: (aEvent, aTab) => calendarController2.onEvent(aEvent),
+ },
+
+ tasks: {
+ type: "tasks",
+ maxTabs: 1,
+ openTab(tab) {
+ tab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/tasks.svg");
+ tab.title = cal.l10n.getLtnString("tabTitleTasks");
+ },
+ showTab(tab) {},
+ closeTab(tab) {},
+
+ persistTab(tab) {
+ let tabmail = document.getElementById("tabmail");
+ return {
+ // Since we do strange tab switching logic in calSwitchToTaskMode,
+ // we should store the current tab state ourselves.
+ background: tab != tabmail.currentTabInfo,
+ };
+ },
+
+ restoreTab(tabmail, state) {
+ tabmail.openTab("tasks", state);
+ },
+
+ onTitleChanged(tab) {
+ tab.title = cal.l10n.getLtnString("tabTitleTasks");
+ },
+
+ supportsCommand: (aCommand, aTab) => calendarController2.supportsCommand(aCommand),
+ isCommandEnabled: (aCommand, aTab) => calendarController2.isCommandEnabled(aCommand),
+ doCommand: (aCommand, aTab) => calendarController2.doCommand(aCommand),
+ onEvent: (aEvent, aTab) => calendarController2.onEvent(aEvent),
+ },
+ },
+
+ saveTabState(tab) {},
+};
+
+XPCOMUtils.defineLazyGetter(calendarTabType.modes.calendar, "notificationbox", () => {
+ return new MozElements.NotificationBox(element => {
+ document.getElementById("calendar-deactivated-notification-location-events").append(element);
+ });
+});
+
+XPCOMUtils.defineLazyGetter(calendarTabType.modes.tasks, "notificationbox", () => {
+ return new MozElements.NotificationBox(element => {
+ document.getElementById("calendar-deactivated-notification-location-tasks").append(element);
+ });
+});
+
+/**
+ * For details about tab info objects and the tabmail interface see:
+ * comm/mail/base/content/mailTabs.js
+ * comm/mail/base/content/tabmail.js
+ */
+var calendarItemTabType = {
+ name: "calendarItem",
+ perTabPanel: "vbox",
+ idNumber: 0,
+ modes: {
+ calendarEvent: { type: "calendarEvent" },
+ calendarTask: { type: "calendarTask" },
+ },
+ /**
+ * Opens an event tab or a task tab.
+ *
+ * @param {object} aTab - A tab info object
+ * @param {object} aArgs - Contains data about the event/task
+ */
+ openTab(aTab, aArgs) {
+ // Create a clone to use for this tab. Remove the cloned toolbox
+ // and move the original toolbox into its place. There is only
+ // one toolbox/toolbar so its settings are the same for all item tabs.
+ let original = document.getElementById("calendarItemPanel").firstElementChild;
+ let clone = original.cloneNode(true);
+
+ clone.querySelector("toolbox").remove();
+ moveEventToolbox(clone);
+ clone.setAttribute("id", "calendarItemTab" + this.idNumber);
+
+ if (aTab.mode.type == "calendarTask") {
+ // For task tabs, css class hides event-specific toolbar buttons.
+ clone.setAttribute("class", "calendar-task-dialog-tab");
+ }
+
+ aTab.panel.setAttribute("id", "calendarItemTabWrapper" + this.idNumber);
+ aTab.panel.appendChild(clone);
+
+ // Set up the iframe and store the iframe's id. The iframe's
+ // src is set in onLoadCalendarItemPanel() that is called below.
+ aTab.iframe = aTab.panel.querySelector("iframe");
+ let iframeId = "calendarItemTabIframe" + this.idNumber;
+ aTab.iframe.setAttribute("id", iframeId);
+ gItemTabIds.push(iframeId);
+
+ // Generate and set the tab title.
+ let strName;
+ if (aTab.mode.type == "calendarEvent") {
+ strName = aArgs.calendarEvent.title ? "editEventDialog" : "newEventDialog";
+ aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/calendar.svg");
+ } else if (aTab.mode.type == "calendarTask") {
+ strName = aArgs.calendarEvent.title ? "editTaskDialog" : "newTaskDialog";
+ aTab.tabNode.setIcon("chrome://messenger/skin/icons/new/compact/tasks.svg");
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ // name is "New Event", "Edit Task", etc.
+ let name = cal.l10n.getCalString(strName);
+ aTab.title = name + ": " + (aArgs.calendarEvent.title || name);
+
+ // allowTabClose prevents the tab from being closed until we ask
+ // the user if they want to save any unsaved changes.
+ aTab.allowTabClose = false;
+
+ // Put the arguments where they can be accessed easily
+ // from the iframe. (window.arguments[0])
+ aTab.iframe.contentWindow.arguments = [aArgs];
+
+ // activate or de-activate 'Events and Tasks' menu items
+ document.commandDispatcher.updateCommands("calendar_commands");
+
+ onLoadCalendarItemPanel(iframeId, aArgs.url);
+
+ this.idNumber += 1;
+ },
+ /**
+ * Saves a tab's state when it is deactivated / hidden. The opposite of showTab.
+ *
+ * @param {object} aTab - A tab info object
+ */
+ saveTabState(aTab) {
+ // save state
+ aTab.itemTabConfig = {};
+ Object.assign(aTab.itemTabConfig, gConfig);
+
+ // clear statusbar
+ let statusbar = document.getElementById("status-bar");
+ let items = statusbar.getElementsByClassName("event-dialog");
+ for (let item of items) {
+ item.setAttribute("collapsed", true);
+ }
+ // move toolbox to the place where it can be accessed later
+ let to = document.getElementById("calendarItemPanel").firstElementChild;
+ moveEventToolbox(to);
+ },
+ /**
+ * Called when a tab is activated / shown. The opposite of saveTabState.
+ *
+ * @param {object} aTab - A tab info object
+ */
+ showTab(aTab) {
+ // move toolbox into place then load state
+ moveEventToolbox(aTab.panel.firstElementChild);
+ Object.assign(gConfig, aTab.itemTabConfig);
+ updateItemTabState(gConfig);
+
+ // activate or de-activate 'Events and Tasks' menu items
+ document.commandDispatcher.updateCommands("calendar_commands");
+ },
+ /**
+ * Called when there is a request to close a tab. Using aTab.allowTabClose
+ * we first prevent the tab from closing so we can prompt the user
+ * about saving changes, then we allow the tab to close.
+ *
+ * @param {object} aTab - A tab info object
+ */
+ tryCloseTab(aTab) {
+ if (aTab.allowTabClose) {
+ return true;
+ }
+ onCancel(aTab.iframe.id);
+ return false;
+ },
+ /**
+ * Closes a tab.
+ *
+ * @param {object} aTab - A tab info object
+ */
+ closeTab(aTab) {
+ // Remove the iframe id from the array where they are stored.
+ let index = gItemTabIds.indexOf(aTab.iframe.id);
+ if (index != -1) {
+ gItemTabIds.splice(index, 1);
+ }
+ aTab.itemTabConfig = null;
+
+ // If this is the last item tab that is closing, then delete
+ // window.calItemSaveControls, so mochitests won't complain.
+ let tabmail = document.getElementById("tabmail");
+ let calendarItemTabCount =
+ tabmail.tabModes.calendarEvent.tabs.length + tabmail.tabModes.calendarTask.tabs.length;
+ if (calendarItemTabCount == 1) {
+ delete window.calItemSaveControls;
+ }
+ },
+ /**
+ * Called when quitting the application (and/or closing the window).
+ * Saves an open tab's state to be able to restore it later.
+ *
+ * @param {object} aTab - A tab info object
+ */
+ persistTab(aTab) {
+ let args = aTab.iframe.contentWindow.arguments[0];
+ // Serialize args, with manual handling of some properties.
+ // persistTab is called even for new events/tasks in tabs that
+ // were closed and never saved (for 'undo close tab'
+ // functionality), thus we confirm we have the expected values.
+ if (
+ !args ||
+ !args.calendar ||
+ !args.calendar.id ||
+ !args.calendarEvent ||
+ !args.calendarEvent.id
+ ) {
+ return {};
+ }
+
+ let calendarId = args.calendar.id;
+ let itemId = args.calendarEvent.id;
+ // Handle null args.initialStartDateValue, just for good measure.
+ // Note that this is not the start date for the event or task.
+ let hasDateValue = args.initialStartDateValue && args.initialStartDateValue.icalString;
+ let initialStartDate = hasDateValue ? args.initialStartDateValue.icalString : null;
+
+ args.calendar = null;
+ args.calendarEvent = null;
+ args.initialStartDateValue = null;
+
+ return {
+ calendarId,
+ itemId,
+ initialStartDate,
+ args,
+ tabType: aTab.mode.type,
+ };
+ },
+ /**
+ * Called when starting the application (and/or opening the window).
+ * Restores a tab that was open when the application was quit previously.
+ *
+ * @param {object} aTabmail - The tabmail interface
+ * @param {object} aState - The state of the tab to restore
+ */
+ restoreTab(aTabmail, aState) {
+ // Sometimes restoreTab is called for tabs that were never saved
+ // and never meant to be persisted or restored. See persistTab.
+ if (aState.args && aState.calendarId && aState.itemId) {
+ aState.args.initialStartDateValue = aState.initialStartDate
+ ? cal.createDateTime(aState.initialStartDate)
+ : cal.dtz.getDefaultStartDate();
+
+ aState.args.onOk = doTransaction.bind(null, "modify");
+
+ aState.args.calendar = cal.manager.getCalendarById(aState.calendarId);
+ if (aState.args.calendar) {
+ aState.args.calendar.getItem(aState.itemId).then(item => {
+ if (item) {
+ aState.args.calendarEvent = item;
+ aTabmail.openTab(aState.tabType, aState.args);
+ }
+ });
+ }
+ }
+ },
+};
+
+window.addEventListener("load", e => {
+ let tabmail = document.getElementById("tabmail");
+ tabmail.registerTabType(calendarTabType);
+ tabmail.registerTabType(calendarItemTabType);
+ tabmail.registerTabMonitor(calendarTabMonitor);
+});
+
+/**
+ * Switch the calendar view, and optionally switch to calendar mode.
+ *
+ * @param aType The type of view to select.
+ * @param aShow If true, the mode will be switched to calendar if not
+ * already there.
+ */
+function switchCalendarView(aType, aShow) {
+ gLastShownCalendarView.set(aType);
+
+ if (aShow && gCurrentMode != "calendar") {
+ // This function in turn calls switchToView(), so return afterwards.
+ calSwitchToCalendarMode();
+ return;
+ }
+ document
+ .querySelector(`.calview-toggle-item[aria-selected="true"]`)
+ ?.setAttribute("aria-selected", false);
+ document
+ .querySelector(`.calview-toggle-item[aria-controls="${aType}-view"]`)
+ ?.setAttribute("aria-selected", true);
+ switchToView(aType);
+}
+
+/**
+ * Move the event toolbox, containing the toolbar, into view for a tab
+ * or back to its hiding place where it is accessed again for other tabs.
+ *
+ * @param {Node} aDestination - Destination where the toolbox will be moved
+ */
+function moveEventToolbox(aDestination) {
+ let toolbox = document.getElementById("event-toolbox");
+ // the <toolbarpalette> has to be copied manually
+ let palette = toolbox.palette;
+ let iframe = aDestination.querySelector("iframe");
+ aDestination.insertBefore(toolbox, iframe);
+ toolbox.palette = palette;
+}
diff --git a/comm/calendar/base/content/calendar-task-tree-utils.js b/comm/calendar/base/content/calendar-task-tree-utils.js
new file mode 100644
index 0000000000..92dcb8513c
--- /dev/null
+++ b/comm/calendar/base/content/calendar-task-tree-utils.js
@@ -0,0 +1,341 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported addCalendarNames, calendars, changeContextMenuForTask,
+ * contextChangeTaskCalendar, contextChangeTaskPriority,
+ * contextPostponeTask, modifyTaskFromContext, deleteToDoCommand,
+ * tasksToMail, tasksToEvents, toggleCompleted,
+ */
+
+/* import-globals-from ../../../mail/base/content/globalOverlay.js */
+/* import-globals-from item-editing/calendar-item-editing.js */
+/* import-globals-from item-editing/calendar-item-panel.js */
+/* import-globals-from calendar-command-controller.js */
+/* import-globals-from calendar-dnd-listener.js */
+/* import-globals-from calendar-ui-utils.js */
+/* import-globals-from calendar-views-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Add registered calendars to the given menupopup. Removes all previous
+ * children.
+ *
+ * @param aEvent The popupshowing event of the opening menu
+ */
+function addCalendarNames(aEvent) {
+ let calendarMenuPopup = aEvent.target;
+ while (calendarMenuPopup.hasChildNodes()) {
+ calendarMenuPopup.lastChild.remove();
+ }
+ let tasks = getSelectedTasks();
+ let tasksSelected = tasks.length > 0;
+ if (tasksSelected) {
+ let selIndex = appendCalendarItems(
+ tasks[0],
+ calendarMenuPopup,
+ null,
+ "contextChangeTaskCalendar(event);"
+ );
+ if (tasks.every(task => task.calendar == tasks[0].calendar) && selIndex > -1) {
+ calendarMenuPopup.children[selIndex].setAttribute("checked", "true");
+ }
+ }
+}
+
+/**
+ * For each child of an element (for example all menuitems in a menu), if it defines a command
+ * set an attribute on the command, otherwise set it on the child node itself.
+ *
+ * @param aAttribute {string} - The attribute to set.
+ * @param aValue {boolean|string} - The value to set.
+ * @param aElement {Element} - The parent node.
+ */
+function setAttributeOnChildrenOrTheirCommands(aAttribute, aValue, aElement) {
+ for (let child of aElement.children) {
+ const commandName = child.getAttribute("command");
+ const command = commandName && document.getElementById(commandName);
+
+ const domObject = command || child;
+ domObject.setAttribute(aAttribute, aValue);
+ }
+}
+
+/**
+ * Change the opening context menu for the selected tasks.
+ *
+ * @param aEvent The popupshowing event of the opening menu.
+ */
+function changeContextMenuForTask(aEvent) {
+ if (aEvent.target.id !== "taskitem-context-menu") {
+ return;
+ }
+
+ handleTaskContextMenuStateChange(aEvent);
+
+ const treeNodeId = aEvent.target.triggerNode.closest(".calendar-task-tree").id;
+ const isTodaypane = treeNodeId == "unifinder-todo-tree";
+ const isMainTaskTree = treeNodeId == "calendar-task-tree";
+
+ document.getElementById("task-context-menu-new").hidden = isTodaypane;
+ document.getElementById("task-context-menu-modify").hidden = isTodaypane;
+ document.getElementById("task-context-menu-new-todaypane").hidden = isMainTaskTree;
+ document.getElementById("task-context-menu-modify-todaypane").hidden = isMainTaskTree;
+ document.getElementById("task-context-menu-filter-todaypane").hidden = isMainTaskTree;
+ document.getElementById("task-context-menu-separator-filter").hidden = isMainTaskTree;
+
+ let items = getSelectedTasks();
+ let tasksSelected = items.length > 0;
+
+ setAttributeOnChildrenOrTheirCommands("disabled", !tasksSelected, aEvent.target);
+
+ if (
+ calendarController.isCommandEnabled("calendar_new_todo_command") &&
+ calendarController.isCommandEnabled("calendar_new_todo_todaypane_command")
+ ) {
+ document.getElementById("calendar_new_todo_command").removeAttribute("disabled");
+ document.getElementById("calendar_new_todo_todaypane_command").removeAttribute("disabled");
+ } else {
+ document.getElementById("calendar_new_todo_command").setAttribute("disabled", "true");
+ document.getElementById("calendar_new_todo_todaypane_command").setAttribute("disabled", "true");
+ }
+
+ // make sure the "Paste" and "Cut" menu items are enabled
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_cut");
+
+ // make sure the filter menu is enabled
+ document.getElementById("task-context-menu-filter-todaypane").removeAttribute("disabled");
+
+ setAttributeOnChildrenOrTheirCommands(
+ "disabled",
+ false,
+ document.getElementById("task-context-menu-filter-todaypane-popup")
+ );
+
+ changeMenuForTask();
+
+ let menu = document.getElementById("task-context-menu-attendance-menu");
+ setupAttendanceMenu(menu, items);
+}
+
+/**
+ * Notify the task tree that the context menu open state has changed.
+ *
+ * @param aEvent The popupshowing or popuphiding event of the menu.
+ */
+function handleTaskContextMenuStateChange(aEvent) {
+ if (aEvent.target.id !== "taskitem-context-menu") {
+ return;
+ }
+
+ let tree = aEvent.target.triggerNode.closest(".calendar-task-tree");
+
+ if (tree) {
+ tree.updateFocus();
+ }
+}
+
+/**
+ * Change the opening menu for the selected tasks.
+ */
+function changeMenuForTask() {
+ // Make sure to update the status of some commands.
+ let commands = [
+ "calendar_delete_todo_command",
+ "calendar_toggle_completed_command",
+ "calendar_general-progress_command",
+ "calendar_general-priority_command",
+ "calendar_general-postpone_command",
+ ];
+ commands.forEach(goUpdateCommand);
+
+ let tasks = getSelectedTasks();
+ let tasksSelected = tasks.length > 0;
+ if (tasksSelected) {
+ let cmd = document.getElementById("calendar_toggle_completed_command");
+ if (tasks.every(task => task.isCompleted == tasks[0].isCompleted)) {
+ cmd.checked = tasks[0].isCompleted;
+ } else {
+ cmd.checked = false;
+ }
+ }
+}
+
+/**
+ * Handler function to change the progress of all selected tasks, or of
+ * the task loaded in the current tab.
+ *
+ * @param {short} aProgress - The new progress percentage
+ */
+function contextChangeTaskProgress(aProgress) {
+ if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") {
+ editToDoStatus(aProgress);
+ } else {
+ startBatchTransaction();
+ let tasks = getSelectedTasks();
+ for (let task of tasks) {
+ let newTask = task.clone().QueryInterface(Ci.calITodo);
+ newTask.percentComplete = aProgress;
+ switch (aProgress) {
+ case 0:
+ newTask.isCompleted = false;
+ break;
+ case 100:
+ newTask.isCompleted = true;
+ break;
+ default:
+ newTask.status = "IN-PROCESS";
+ newTask.completedDate = null;
+ break;
+ }
+ doTransaction("modify", newTask, newTask.calendar, task, null);
+ }
+ endBatchTransaction();
+ }
+}
+
+/**
+ * Handler function to change the calendar of the selected tasks. The targeted
+ * menuitem must have "calendar" property that implements calICalendar.
+ *
+ * @param aEvent The DOM event that triggered this command.
+ */
+function contextChangeTaskCalendar(aEvent) {
+ startBatchTransaction();
+ let tasks = getSelectedTasks();
+ for (let task of tasks) {
+ let newTask = task.clone();
+ newTask.calendar = aEvent.target.calendar;
+ doTransaction("modify", newTask, newTask.calendar, task, null);
+ }
+ endBatchTransaction();
+}
+
+/**
+ * Handler function to change the priority of the selected tasks, or of
+ * the task loaded in the current tab.
+ *
+ * @param {short} aPriority - The priority to set on the task(s)
+ */
+function contextChangeTaskPriority(aPriority) {
+ let tabType = gTabmail && gTabmail.currentTabInfo.mode.type;
+ if (tabType == "calendarTask" || tabType == "calendarEvent") {
+ editConfigState({ priority: aPriority });
+ } else {
+ startBatchTransaction();
+ let tasks = getSelectedTasks();
+ for (let task of tasks) {
+ let newTask = task.clone().QueryInterface(Ci.calITodo);
+ newTask.priority = aPriority;
+ doTransaction("modify", newTask, newTask.calendar, task, null);
+ }
+ endBatchTransaction();
+ }
+}
+
+/**
+ * Handler function to postpone the start and due dates of the selected
+ * tasks, or of the task loaded in the current tab. ISO 8601 format:
+ * "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We use this
+ * format intentionally instead of a calIDuration object because those
+ * objects cannot be serialized for message passing with iframes.)
+ *
+ * @param {string} aDuration - The duration to postpone in ISO 8601 format
+ */
+function contextPostponeTask(aDuration) {
+ let duration = cal.createDuration(aDuration);
+ if (!duration) {
+ cal.LOG("[calendar-task-tree] Postpone Task - Invalid duration " + aDuration);
+ return;
+ }
+
+ if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") {
+ postponeTask(aDuration);
+ } else {
+ startBatchTransaction();
+ let tasks = getSelectedTasks();
+
+ tasks.forEach(task => {
+ if (task.entryDate || task.dueDate) {
+ let newTask = task.clone();
+ cal.item.shiftOffset(newTask, duration);
+ doTransaction("modify", newTask, newTask.calendar, task, null);
+ }
+ });
+
+ endBatchTransaction();
+ }
+}
+
+/**
+ * Modifies the selected tasks with the event dialog
+ *
+ * @param initialDate (optional) The initial date for new task datepickers
+ */
+function modifyTaskFromContext(initialDate) {
+ let tasks = getSelectedTasks();
+ for (let task of tasks) {
+ modifyEventWithDialog(task, true, initialDate);
+ }
+}
+
+/**
+ * Delete the current selected item with focus from the task tree
+ *
+ * @param aDoNotConfirm If true, the user will not be asked to delete.
+ */
+function deleteToDoCommand(aDoNotConfirm) {
+ let tasks = getSelectedTasks();
+ calendarViewController.deleteOccurrences(tasks, false, aDoNotConfirm);
+}
+
+/**
+ * Gets the currently visible task tree
+ *
+ * @returns The XUL task tree element.
+ */
+function getTaskTree() {
+ if (gCurrentMode == "task") {
+ return document.getElementById("calendar-task-tree");
+ }
+ return document.getElementById("unifinder-todo-tree");
+}
+
+/**
+ * Gets the tasks selected in the currently visible task tree.
+ */
+function getSelectedTasks() {
+ let taskTree = getTaskTree();
+ return taskTree ? taskTree.selectedTasks : [];
+}
+
+/**
+ * Convert selected tasks to emails.
+ */
+function tasksToMail() {
+ let tasks = getSelectedTasks();
+ calendarMailButtonDNDObserver.onDropItems(tasks);
+}
+
+/**
+ * Convert selected tasks to events.
+ */
+function tasksToEvents() {
+ let tasks = getSelectedTasks();
+ calendarCalendarButtonDNDObserver.onDropItems(tasks);
+}
+
+/**
+ * Toggle the completed state on selected tasks.
+ *
+ * @param aEvent The originating event, can be null.
+ */
+function toggleCompleted(aEvent) {
+ if (aEvent.target.getAttribute("checked") == "true") {
+ contextChangeTaskProgress(0);
+ } else {
+ contextChangeTaskProgress(100);
+ }
+}
diff --git a/comm/calendar/base/content/calendar-task-tree-view.js b/comm/calendar/base/content/calendar-task-tree-view.js
new file mode 100644
index 0000000000..ebe258419f
--- /dev/null
+++ b/comm/calendar/base/content/calendar-task-tree-view.js
@@ -0,0 +1,495 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported CalendarTaskTreeView */
+
+/* import-globals-from item-editing/calendar-item-editing.js */
+/* import-globals-from widgets/mouseoverPreviews.js */
+
+/* globals cal */
+
+/**
+ * The tree view for a CalendarTaskTree.
+ */
+class CalendarTaskTreeView {
+ /**
+ * Creates a new task tree view and connects it to a given task tree.
+ *
+ * @param {CalendarTaskTree} taskTree - The task tree to connect the view to.
+ */
+ constructor(taskTree) {
+ this.tree = taskTree;
+ this.mSelectedColumn = null;
+ this.sortDirection = null;
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["nsITreeView"]);
+
+ /**
+ * Get the selected column.
+ *
+ * @returns {Element} A treecol element.
+ */
+ get selectedColumn() {
+ return this.mSelectedColumn;
+ }
+
+ /**
+ * Set the selected column and sort by that column.
+ *
+ * @param {Element} column - A treecol element.
+ */
+ set selectedColumn(column) {
+ const columnProperty = column.getAttribute("itemproperty");
+
+ this.tree.querySelectorAll("treecol").forEach(col => {
+ if (col.getAttribute("sortActive")) {
+ col.removeAttribute("sortActive");
+ col.removeAttribute("sortDirection");
+ }
+ if (columnProperty == col.getAttribute("itemproperty")) {
+ col.setAttribute("sortActive", "true");
+ col.setAttribute("sortDirection", this.sortDirection);
+ }
+ });
+ this.mSelectedColumn = column;
+ }
+
+ // High-level task tree manipulation
+
+ /**
+ * Adds an array of items (tasks) to the list if they match the currently applied filter.
+ *
+ * @param {object[]} items - An array of task objects to add.
+ * @param {boolean} [doNotSort] - Whether to re-sort after adding the tasks.
+ */
+ addItems(items, doNotSort) {
+ this.modifyItems(items, [], doNotSort, true);
+ }
+ /**
+ * Removes an array of items (tasks) from the list.
+ *
+ * @param {object[]} items - An array of task objects to remove.
+ */
+ removeItems(items) {
+ this.modifyItems([], items, true, false);
+ }
+
+ /**
+ * Removes an array of old items from the list, and adds an array of new items if
+ * they match the currently applied filter.
+ *
+ * @param {object[]} newItems - An array of new items to add.
+ * @param {object[]} oldItems - An array of old items to remove.
+ * @param {boolean} [doNotSort] - Whether to re-sort the list after modifying it.
+ * @param {boolean} [selectNew] - Whether to select the new tasks.
+ */
+ modifyItems(newItems = [], oldItems = [], doNotSort, selectNew) {
+ let selItem = this.tree.currentTask;
+ let selIndex = this.tree.currentIndex;
+ let firstHash = null;
+ let remIndexes = [];
+
+ this.tree.beginUpdateBatch();
+
+ let idiff = new cal.item.ItemDiff();
+ idiff.load(oldItems);
+ idiff.difference(newItems);
+ idiff.complete();
+ let delItems = idiff.deletedItems;
+ let addItems = idiff.addedItems;
+ let modItems = idiff.modifiedItems;
+
+ // Find the indexes of the old items that need to be removed.
+ for (let item of delItems.mArray) {
+ if (item.hashId in this.tree.mHash2Index) {
+ // The old item needs to be removed.
+ remIndexes.push(this.tree.mHash2Index[item.hashId]);
+ delete this.tree.mHash2Index[item.hashId];
+ }
+ }
+
+ // Modified items need to be updated.
+ for (let item of modItems.mArray) {
+ if (item.hashId in this.tree.mHash2Index) {
+ // Make sure we're using the new version of a modified item.
+ this.tree.mTaskArray[this.tree.mHash2Index[item.hashId]] = item;
+ }
+ }
+
+ // Remove the old items working backward from the end so the indexes stay valid.
+ remIndexes
+ .sort((a, b) => b - a)
+ .forEach(index => {
+ this.tree.mTaskArray.splice(index, 1);
+ this.tree.rowCountChanged(index, -1);
+ });
+
+ // Add the new items.
+ for (let item of addItems.mArray) {
+ if (!(item.hashId in this.tree.mHash2Index)) {
+ let index = this.tree.mTaskArray.length;
+ this.tree.mTaskArray.push(item);
+ this.tree.mHash2Index[item.hashId] = index;
+ this.tree.rowCountChanged(index, 1);
+ firstHash = firstHash || item.hashId;
+ }
+ }
+
+ if (doNotSort) {
+ this.tree.recreateHashTable();
+ } else {
+ this.tree.sortItems();
+ }
+
+ if (selectNew && firstHash && firstHash in this.tree.mHash2Index) {
+ // Select the first item added into the list.
+ selIndex = this.tree.mHash2Index[firstHash];
+ } else if (selItem && selItem.hashId in this.tree.mHash2Index) {
+ // Select the previously selected item.
+ selIndex = this.tree.mHash2Index[selItem.hashId];
+ } else if (selIndex >= this.tree.mTaskArray.length) {
+ // Make sure the previously selected index is valid.
+ selIndex = this.tree.mTaskArray.length - 1;
+ }
+
+ if (selIndex > -1) {
+ this.tree.view.selection.select(selIndex);
+ this.tree.ensureRowIsVisible(selIndex);
+ }
+
+ this.tree.endUpdateBatch();
+ }
+
+ /**
+ * Remove all tasks from the list/tree.
+ */
+ clear() {
+ let count = this.tree.mTaskArray.length;
+ if (count > 0) {
+ this.tree.mTaskArray = [];
+ this.tree.mHash2Index = {};
+ this.tree.rowCountChanged(0, -count);
+ this.tree.view.selection.clearSelection();
+ }
+ }
+
+ /**
+ * Refresh the display for a given task.
+ *
+ * @param {object} item - The task object to refresh.
+ */
+ updateItem(item) {
+ let index = this.tree.mHash2Index[item.hashId];
+ if (index) {
+ this.tree.invalidateRow(index);
+ }
+ }
+
+ /**
+ * Return the item (task) object that's related to a given event. If passed a column and/or row
+ * object, set their 'value' property to the column and/or row related to the event.
+ *
+ * @param {Event} event - An event.
+ * @param {object} [col] - A column object.
+ * @param {object} [row] - A row object.
+ * @returns {object | false} The task object related to the event or false if none found.
+ */
+ getItemFromEvent(event, col, row) {
+ let { col: eventColumn, row: eventRow } = this.tree.getCellAt(event.clientX, event.clientY);
+ if (col) {
+ col.value = eventColumn;
+ }
+ if (row) {
+ row.value = eventRow;
+ }
+ return eventRow > -1 && this.tree.mTaskArray[eventRow];
+ }
+
+ // nsITreeView Methods and Properties
+
+ get rowCount() {
+ return this.tree.mTaskArray.length;
+ }
+
+ getCellProperties(row, col) {
+ let rowProps = this.getRowProperties(row);
+ let colProps = this.getColumnProperties(col);
+ return rowProps + (rowProps && colProps ? " " : "") + colProps;
+ }
+
+ getColumnProperties(col) {
+ return col.element.getAttribute("id") || "";
+ }
+
+ getRowProperties(row) {
+ let properties = [];
+ let item = this.tree.mTaskArray[row];
+ if (item.priority > 0 && item.priority < 5) {
+ properties.push("highpriority");
+ } else if (item.priority > 5 && item.priority < 10) {
+ properties.push("lowpriority");
+ }
+ properties.push(cal.item.getProgressAtom(item));
+
+ // Add calendar name and id atom.
+ properties.push("calendar-" + cal.view.formatStringForCSSRule(item.calendar.name));
+ properties.push("calendarid-" + cal.view.formatStringForCSSRule(item.calendar.id));
+
+ // Add item status atom.
+ if (item.status) {
+ properties.push("status-" + item.status.toLowerCase());
+ }
+
+ // Alarm status atom.
+ if (item.getAlarms().length) {
+ properties.push("alarm");
+ }
+
+ // Task categories.
+ properties = properties.concat(item.getCategories().map(cal.view.formatStringForCSSRule));
+
+ return properties.join(" ");
+ }
+
+ cycleCell(row, col) {
+ let task = this.tree.mTaskArray[row];
+
+ // Prevent toggling completed status for parent items of
+ // repeating tasks or when the calendar is read-only.
+ if (!task || task.recurrenceInfo || task.calendar.readOnly) {
+ return;
+ }
+ if (col != null) {
+ let content = col.element.getAttribute("itemproperty");
+ if (content == "completed") {
+ let newTask = task.clone().QueryInterface(Ci.calITodo);
+ newTask.isCompleted = !task.completedDate;
+ doTransaction("modify", newTask, newTask.calendar, task, null);
+ }
+ }
+ }
+
+ cycleHeader(col) {
+ if (!this.selectedColumn) {
+ this.sortDirection = "ascending";
+ } else if (!this.sortDirection || this.sortDirection == "descending") {
+ this.sortDirection = "ascending";
+ } else {
+ this.sortDirection = "descending";
+ }
+ this.selectedColumn = col.element;
+ let selectedItems = this.tree.selectedTasks;
+ this.tree.sortItems();
+ if (selectedItems != undefined) {
+ this.tree.view.selection.clearSelection();
+ for (let item of selectedItems) {
+ let index = this.tree.mHash2Index[item.hashId];
+ this.tree.view.selection.toggleSelect(index);
+ }
+ }
+ }
+
+ getCellText(row, col) {
+ let task = this.tree.mTaskArray[row];
+ if (!task) {
+ return "";
+ }
+
+ const property = col.element.getAttribute("itemproperty");
+ switch (property) {
+ case "title":
+ // Return title, or "Untitled" if empty/null.
+ return task.title ? task.title.replace(/\n/g, " ") : cal.l10n.getCalString("eventUntitled");
+ case "entryDate":
+ case "dueDate":
+ case "completedDate":
+ return task.recurrenceInfo
+ ? cal.l10n.getDateFmtString("Repeating")
+ : this._formatDateTime(task[property]);
+ case "percentComplete":
+ return task.percentComplete > 0 ? task.percentComplete + "%" : "";
+ case "categories":
+ // TODO This is l10n-unfriendly.
+ return task.getCategories().join(", ");
+ case "location":
+ return task.getProperty("LOCATION");
+ case "status":
+ return getToDoStatusString(task);
+ case "calendar":
+ return task.calendar.name;
+ case "duration":
+ return this.tree.duration(task);
+ case "completed":
+ case "priority":
+ default:
+ return "";
+ }
+ }
+
+ getCellValue(row, col) {
+ let task = this.tree.mTaskArray[row];
+ if (!task) {
+ return null;
+ }
+ switch (col.element.getAttribute("itemproperty")) {
+ case "percentComplete":
+ return task.percentComplete;
+ }
+ return null;
+ }
+
+ setCellValue(row, col, value) {
+ return null;
+ }
+
+ getImageSrc(row, col) {
+ return "";
+ }
+
+ isEditable(row, col) {
+ return true;
+ }
+
+ /**
+ * Called to link the task tree to the tree view. A null argument un-sets/un-links the tree.
+ *
+ * @param {object | null} tree
+ */
+ setTree(tree) {
+ const hasOldTree = this.tree != null;
+ if (hasOldTree && !tree) {
+ // Balances the addObserver calls from the refresh method in the tree.
+
+ // Remove the composite calendar observer.
+ const composite = cal.view.getCompositeCalendar(window);
+ composite.removeObserver(this.tree.mTaskTreeObserver);
+
+ // Remove the preference observer.
+ const branch = Services.prefs.getBranch("");
+ branch.removeObserver("calendar.", this.tree.mPrefObserver);
+ }
+ this.tree = tree;
+ }
+
+ isContainer(row) {
+ return false;
+ }
+ isContainerOpen(row) {
+ return false;
+ }
+ isContainerEmpty(row) {
+ return false;
+ }
+
+ isSeparator(row) {
+ return false;
+ }
+
+ isSorted(row) {
+ return false;
+ }
+
+ canDrop() {
+ return false;
+ }
+
+ drop(row, orientation) {}
+
+ getParentIndex(row) {
+ return -1;
+ }
+
+ getLevel(row) {
+ return 0;
+ }
+
+ // End nsITreeView Methods and Properties
+ // Task Tree Event Handlers
+
+ onSelect(event) {}
+
+ /**
+ * Handle double click events.
+ *
+ * @param {Event} event - The double click event.
+ */
+ onDoubleClick(event) {
+ // Only handle left mouse button clicks.
+ if (event.button != 0) {
+ return;
+ }
+ const initialDate = cal.dtz.getDefaultStartDate(this.tree.getInitialDate());
+ const col = {};
+ const item = this.getItemFromEvent(event, col);
+ if (item) {
+ const itemProperty = col.value.element.getAttribute("itemproperty");
+
+ // If itemProperty == "completed" then the user has clicked a "completed" checkbox
+ // and `item` holds the checkbox state toggled by the first click. So, to make sure the
+ // user notices that the state changed, don't call modifyEventWithDialog.
+ if (itemProperty != "completed") {
+ modifyEventWithDialog(item, true, initialDate);
+ }
+ } else {
+ createTodoWithDialog(null, null, null, null, initialDate);
+ }
+ }
+
+ /**
+ * Handle key press events.
+ *
+ * @param {Event} event - The key press event.
+ */
+ onKeyPress(event) {
+ switch (event.key) {
+ case "Delete": {
+ event.target.triggerNode = this.tree;
+ document.getElementById("calendar_delete_todo_command").doCommand();
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ }
+ case " ": {
+ if (this.tree.currentIndex > -1) {
+ let col = this.tree.querySelector("[itemproperty='completed']");
+ this.cycleCell(this.tree.currentIndex, { element: col });
+ }
+ break;
+ }
+ case "Enter": {
+ let index = this.tree.currentIndex;
+ if (index > -1) {
+ modifyEventWithDialog(this.tree.mTaskArray[index]);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set the context menu on mousedown to change it before it is opened.
+ *
+ * @param {Event} event - The mousedown event.
+ */
+ onMouseDown(event) {
+ if (!this.getItemFromEvent(event)) {
+ this.tree.view.selection.invalidateSelection();
+ }
+ }
+
+ // Private Methods and Attributes
+
+ /**
+ * Format a datetime object for display.
+ *
+ * @param {object} dateTime - From a todo object, not a JavaScript date.
+ * @returns {string} Formatted string version of the datetime ("" if invalid).
+ */
+ _formatDateTime(dateTime) {
+ return dateTime && dateTime.isValid
+ ? cal.dtz.formatter.formatDateTime(dateTime.getInTimezone(cal.dtz.defaultTimezone))
+ : "";
+ }
+}
diff --git a/comm/calendar/base/content/calendar-task-tree.js b/comm/calendar/base/content/calendar-task-tree.js
new file mode 100644
index 0000000000..b7cc069e42
--- /dev/null
+++ b/comm/calendar/base/content/calendar-task-tree.js
@@ -0,0 +1,685 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MozXULElement, calendarController, invokeEventDragSession, CalendarTaskTreeView,
+ calFilter, TodayPane, currentView */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ const { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+
+ /**
+ * An observer for the calendar event data source. This keeps the unifinder
+ * display up to date when the calendar event data is changed.
+ *
+ * @implements {calIObserver}
+ * @implements {calICompositeObserver}
+ */
+ class TaskTreeObserver {
+ /**
+ * Creates and connects the new observer to a CalendarTaskTree and sets up Query Interface.
+ *
+ * @param {CalendarTaskTree} taskTree - The tree to observe.
+ */
+ constructor(taskTree) {
+ this.tree = taskTree;
+ this.QueryInterface = ChromeUtils.generateQI(["calICompositeObserver", "calIObserver"]);
+ }
+
+ // calIObserver Methods
+
+ onStartBatch() {}
+
+ onEndBatch() {}
+
+ onLoad() {
+ this.tree.refresh();
+ }
+
+ onAddItem(item) {
+ if (!this.tree.hasBeenVisible) {
+ return;
+ }
+
+ if (item.isTodo()) {
+ this.tree.mTreeView.addItems(this.tree.mFilter.getOccurrences(item));
+ }
+ }
+
+ onModifyItem(newItem, oldItem) {
+ if (!this.tree.hasBeenVisible) {
+ return;
+ }
+
+ if (newItem.isTodo() || oldItem.isTodo()) {
+ this.tree.mTreeView.modifyItems(
+ this.tree.mFilter.getOccurrences(newItem),
+ this.tree.mFilter.getOccurrences(oldItem)
+ );
+ // We also need to notify potential listeners.
+ let event = document.createEvent("Events");
+ event.initEvent("select", true, false);
+ this.tree.dispatchEvent(event);
+ }
+ }
+
+ onDeleteItem(deletedItem) {
+ if (!this.tree.hasBeenVisible) {
+ return;
+ }
+
+ if (deletedItem.isTodo()) {
+ this.tree.mTreeView.removeItems(this.tree.mFilter.getOccurrences(deletedItem));
+ }
+ }
+
+ onError(calendar, errNo, message) {}
+
+ onPropertyChanged(calendar, name, value, oldValue) {
+ switch (name) {
+ case "disabled":
+ if (value) {
+ this.tree.onCalendarRemoved(calendar);
+ } else {
+ this.tree.onCalendarAdded(calendar);
+ }
+ break;
+ }
+ }
+
+ onPropertyDeleting(calendar, name) {
+ this.onPropertyChanged(calendar, name, null, null);
+ }
+
+ // End calIObserver Methods
+ // calICompositeObserver Methods
+
+ onCalendarAdded(calendar) {
+ if (!calendar.getProperty("disabled")) {
+ this.tree.onCalendarAdded(calendar);
+ }
+ }
+
+ onCalendarRemoved(calendar) {
+ this.tree.onCalendarRemoved(calendar);
+ }
+
+ onDefaultCalendarChanged(newDefaultCalendar) {}
+
+ // End calICompositeObserver Methods
+ }
+
+ /**
+ * Custom element for table-style display of tasks (rows and columns).
+ *
+ * @augments {MozTree}
+ */
+ class CalendarTaskTree extends customElements.get("tree") {
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <treecols>
+ <treecol is="treecol-image" id="calendar-task-tree-col-completed"
+ class="calendar-task-tree-col-completed"
+ style="min-width: 18px"
+ fixed="true"
+ cycler="true"
+ sortKey="completedDate"
+ itemproperty="completed"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/compact/checkbox.svg"
+ label="&calendar.unifinder.tree.done.label;"
+ tooltiptext="&calendar.unifinder.tree.done.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol is="treecol-image" id="calendar-task-tree-col-priority"
+ class="calendar-task-tree-col-priority"
+ style="min-width: 17px"
+ fixed="true"
+ itemproperty="priority"
+ closemenu="none"
+ src="chrome://messenger/skin/icons/new/compact/priority.svg"
+ label="&calendar.unifinder.tree.priority.label;"
+ tooltiptext="&calendar.unifinder.tree.priority.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-title"
+ itemproperty="title"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.title.label;"
+ tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-entrydate"
+ itemproperty="entryDate"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.startdate.label;"
+ tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-duedate"
+ itemproperty="dueDate"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.duedate.label;"
+ tooltiptext="&calendar.unifinder.tree.duedate.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-duration"
+ itemproperty="duration"
+ sortKey="dueDate"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.duration.label;"
+ tooltiptext="&calendar.unifinder.tree.duration.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-completeddate"
+ itemproperty="completedDate"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.completeddate.label;"
+ tooltiptext="&calendar.unifinder.tree.completeddate.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-percentcomplete"
+ itemproperty="percentComplete"
+ style="flex: 1 auto; min-width: 40px;"
+ closemenu="none"
+ label="&calendar.unifinder.tree.percentcomplete.label;"
+ tooltiptext="&calendar.unifinder.tree.percentcomplete.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-categories"
+ itemproperty="categories"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.categories.label;"
+ tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-location"
+ itemproperty="location"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.location.label;"
+ tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-status"
+ itemproperty="status"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.status.label;"
+ tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/>
+ <splitter class="tree-splitter"/>
+ <treecol class="calendar-task-tree-col-calendar"
+ itemproperty="calendar"
+ style="flex: 1 auto"
+ closemenu="none"
+ label="&calendar.unifinder.tree.calendarname.label;"
+ tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/>
+ </treecols>
+ <treechildren class="calendar-task-treechildren"
+ tooltip="taskTreeTooltip"
+ ondblclick="mTreeView.onDoubleClick(event)"/>
+ `,
+ ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+
+ this.classList.add("calendar-task-tree");
+ this.setAttribute("enableColumnDrag", "true");
+ this.setAttribute("keepcurrentinview", "true");
+
+ this.addEventListener("select", event => {
+ this.mTreeView.onSelect(event);
+ if (calendarController.todo_tasktree_focused) {
+ calendarController.onSelectionChanged({ detail: this.selectedTasks });
+ }
+ });
+
+ this.addEventListener("focus", event => {
+ this.updateFocus();
+ });
+
+ this.addEventListener("blur", event => {
+ this.updateFocus();
+ });
+
+ this.addEventListener("keypress", event => {
+ this.mTreeView.onKeyPress(event);
+ });
+
+ this.addEventListener("mousedown", event => {
+ this.mTreeView.onMouseDown(event);
+ });
+
+ this.addEventListener("dragstart", event => {
+ if (event.target.localName != "treechildren") {
+ // We should only drag treechildren, not for example the scrollbar.
+ return;
+ }
+ let item = this.mTreeView.getItemFromEvent(event);
+ if (!item || item.calendar.readOnly) {
+ return;
+ }
+ invokeEventDragSession(item, event.target);
+ });
+
+ this.mTaskArray = [];
+ this.mHash2Index = {};
+ this.mPendingRefreshJobs = {};
+ this.mShowCompletedTasks = true;
+ this.mFilter = null;
+ this.mStartDate = null;
+ this.mEndDate = null;
+ this.mDateRangeFilter = null;
+ this.mTextFilterField = null;
+
+ this.mTreeView = new CalendarTaskTreeView(this);
+ this.mTaskTreeObserver = new TaskTreeObserver(this);
+
+ // Observes and responds to changes to calendar preferences.
+ this.mPrefObserver = (subject, topic, prefName) => {
+ switch (prefName) {
+ case "calendar.date.format":
+ case "calendar.timezone.local":
+ this.refresh();
+ break;
+ }
+ };
+
+ // Set up the tree filter.
+ this.mFilter = new calFilter();
+ this.mFilter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+
+ this.restoreColumnState();
+
+ window.addEventListener("unload", this.persistColumnState.bind(this));
+ }
+
+ get currentTask() {
+ const index = this.currentIndex;
+
+ const isSelected = this.view && this.view.selection && this.view.selection.isSelected(index);
+
+ return isSelected ? this.mTaskArray[index] : null;
+ }
+
+ get selectedTasks() {
+ let tasks = [];
+ let start = {};
+ let end = {};
+ if (!this.mTreeView.selection) {
+ return tasks;
+ }
+
+ const rangeCount = this.mTreeView.selection.getRangeCount();
+
+ for (let range = 0; range < rangeCount; range++) {
+ this.mTreeView.selection.getRangeAt(range, start, end);
+
+ for (let i = start.value; i <= end.value; i++) {
+ let task = this.getTaskAtRow(i);
+ if (task) {
+ tasks.push(this.getTaskAtRow(i));
+ }
+ }
+ }
+ return tasks;
+ }
+
+ set showCompleted(val) {
+ this.mShowCompletedTasks = val;
+ }
+
+ get showCompleted() {
+ return this.mShowCompletedTasks;
+ }
+
+ set textFilterField(val) {
+ this.mTextFilterField = val;
+ }
+
+ get textFilterField() {
+ return this.mTextFilterField;
+ }
+
+ /**
+ * We want to make several attributes of the calendar-task-tree column elements persist
+ * across restarts. Unfortunately there's no reliable way by using the XUL 'persist'
+ * attribute on the column elements. So instead we store the data on the calendar-task-tree
+ * element before Thunderbird quits (using `persistColumnState`), and then restore the
+ * attributes on the columns when Thunderbird starts up again (using `restoreColumnState`).
+ *
+ * This function reads data from column attributes and sets it on several attributes on the
+ * task tree element, which are persisted because they are in the "persist" attribute of
+ * the task tree element.
+ * (E.g. `persist="visible-columns ordinals widths sort-active sort-direction"`.)
+ */
+ persistColumnState() {
+ const columns = Array.from(this.querySelectorAll("treecol"));
+ const widths = columns.map(col => col.getBoundingClientRect().width || 0);
+ const ordinals = columns.map(col => col.ordinal);
+ const visibleColumns = columns
+ .filter(col => !col.hidden)
+ .map(col => col.getAttribute("itemproperty"));
+
+ this.setAttribute("widths", widths.join(" "));
+ this.setAttribute("ordinals", ordinals.join(" "));
+ this.setAttribute("visible-columns", visibleColumns.join(" "));
+
+ const sorted = this.mTreeView.selectedColumn;
+ if (sorted) {
+ this.setAttribute("sort-active", sorted.getAttribute("itemproperty"));
+ this.setAttribute("sort-direction", this.mTreeView.sortDirection);
+ } else {
+ this.removeAttribute("sort-active");
+ this.removeAttribute("sort-direction");
+ }
+ }
+
+ /**
+ * Reads data from several attributes on the calendar-task-tree element and sets it on the
+ * attributes of the columns of the tree. Called on Thunderbird startup to persist the
+ * state of the columns across restarts. Used with `persistTaskTreeColumnState` function.
+ */
+ restoreColumnState() {
+ let visibleColumns = this.getAttribute("visible-columns").split(" ");
+ let ordinals = this.getAttribute("ordinals").split(" ");
+ let widths = this.getAttribute("widths").split(" ");
+ let sorted = this.getAttribute("sort-active");
+ let sortDirection = this.getAttribute("sort-direction") || "ascending";
+
+ this.querySelectorAll("treecol").forEach(col => {
+ const itemProperty = col.getAttribute("itemproperty");
+ if (visibleColumns.includes(itemProperty)) {
+ col.removeAttribute("hidden");
+ } else {
+ col.setAttribute("hidden", "true");
+ }
+ if (ordinals && ordinals.length > 0) {
+ col.ordinal = ordinals.shift();
+ }
+ if (widths && widths.length > 0) {
+ col.style.width = Number(widths.shift()) + "px";
+ }
+ if (sorted && sorted == itemProperty) {
+ this.mTreeView.sortDirection = sortDirection;
+ this.mTreeView.selectedColumn = col;
+ }
+ });
+ // Update the ordinal positions of splitters to even numbers, so that
+ // they are in between columns.
+ let splitters = this.getElementsByTagName("splitter");
+ for (let i = 0; i < splitters.length; i++) {
+ splitters[i].style.MozBoxOrdinalGroup = (i + 1) * 2;
+ }
+ }
+
+ /**
+ * Calculates the text to display in the "Due In" column for the given task,
+ * the amount of time between now and when the task is due.
+ *
+ * @param {object} task - A task object.
+ * @returns {string} A formatted string for the "Due In" column for the task.
+ */
+ duration(task) {
+ const noValidDueDate = !(task && task.dueDate && task.dueDate.isValid);
+ if (noValidDueDate) {
+ return "";
+ }
+
+ const isCompleted = task.completedDate && task.completedDate.isValid;
+ const dur = task.dueDate.subtractDate(cal.dtz.now());
+ if (isCompleted && dur.isNegative) {
+ return "";
+ }
+
+ const absSeconds = Math.abs(dur.inSeconds);
+ const absMinutes = Math.ceil(absSeconds / 60);
+ const prefix = dur.isNegative ? "-" : "";
+
+ if (absMinutes >= 1440) {
+ // 1 day or more.
+ // Convert weeks to days; duration objects look like this (for 6, 7, and 8 days):
+ // { weeks: 0, days: 6 }
+ // { weeks: 1, days: 0 }
+ // { weeks: 0, days: 8 }
+ const days = dur.days + dur.weeks * 7;
+ return (
+ prefix + PluralForm.get(days, cal.l10n.getCalString("dueInDays")).replace("#1", days)
+ );
+ } else if (absMinutes >= 60) {
+ // 1 hour or more.
+ return (
+ prefix +
+ PluralForm.get(dur.hours, cal.l10n.getCalString("dueInHours")).replace("#1", dur.hours)
+ );
+ }
+ // Less than one hour.
+ return cal.l10n.getCalString("dueInLessThanOneHour");
+ }
+
+ /**
+ * Return the task object at a given row.
+ *
+ * @param {number} row - The index number identifying the row.
+ * @returns {object | null} A task object or null if none found.
+ */
+ getTaskAtRow(row) {
+ return row > -1 ? this.mTaskArray[row] : null;
+ }
+
+ /**
+ * Return the task object related to a given event.
+ *
+ * @param {Event} event - The event.
+ * @returns {object | false} The task object related to the event or false if none found.
+ */
+ getTaskFromEvent(event) {
+ return this.mTreeView.getItemFromEvent(event);
+ }
+
+ refreshFromCalendar(calendar) {
+ if (!this.hasBeenVisible) {
+ return;
+ }
+
+ let refreshJob = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ tree: this,
+ calendar: null,
+ items: null,
+ operation: null,
+
+ async cancel() {
+ if (this.operation) {
+ await this.operation.cancel();
+ this.operation = null;
+ this.items = [];
+ }
+ },
+
+ async execute() {
+ if (calendar.id in this.tree.mPendingRefreshJobs) {
+ this.tree.mPendingRefreshJobs[calendar.id].cancel();
+ }
+ this.calendar = calendar;
+ this.items = [];
+ this.tree.mPendingRefreshJobs[calendar.id] = this;
+ this.operation = cal.iterate.streamValues(this.tree.mFilter.getItems(calendar));
+
+ for await (let items of this.operation) {
+ this.items = this.items.concat(items);
+ }
+
+ if (!this.tree.mTreeView.tree) {
+ // Looks like we've been disconnected from the DOM, there's no point in continuing.
+ return;
+ }
+
+ if (calendar.id in this.tree.mPendingRefreshJobs) {
+ delete this.tree.mPendingRefreshJobs[calendar.id];
+ }
+
+ let oldItems = this.tree.mTaskArray.filter(item => item.calendar.id == calendar.id);
+ this.tree.mTreeView.modifyItems(this.items, oldItems);
+ this.tree.dispatchEvent(new CustomEvent("refresh", { bubbles: false }));
+ },
+ };
+
+ refreshJob.execute();
+ }
+
+ selectAll() {
+ if (this.mTreeView.selection) {
+ this.mTreeView.selection.selectAll();
+ }
+ }
+
+ /**
+ * Refreshes the display. Called during connectedCallback and by event observers.
+ * Sets up the tree view, calendar event observer, and preference observer.
+ */
+ refresh() {
+ // Only set the view if it's not already mTreeView, otherwise things get confused.
+ if (this.view?.wrappedJSObject != this.mTreeView) {
+ this.view = this.mTreeView;
+ }
+
+ cal.view.getCompositeCalendar(window).addObserver(this.mTaskTreeObserver);
+
+ Services.prefs.getBranch("").addObserver("calendar.", this.mPrefObserver);
+
+ const cals = cal.view.getCompositeCalendar(window).getCalendars() || [];
+ const enabledCals = cals.filter(calendar => !calendar.getProperty("disabled"));
+
+ enabledCals.forEach(calendar => this.refreshFromCalendar(calendar));
+ }
+
+ onCalendarAdded(calendar) {
+ if (!calendar.getProperty("disabled")) {
+ this.refreshFromCalendar(calendar);
+ }
+ }
+
+ onCalendarRemoved(calendar) {
+ const tasks = this.mTaskArray.filter(task => task.calendar.id == calendar.id);
+ this.mTreeView.removeItems(tasks);
+ }
+
+ sortItems() {
+ if (this.mTreeView.selectedColumn) {
+ let column = this.mTreeView.selectedColumn;
+ let modifier = this.mTreeView.sortDirection == "descending" ? -1 : 1;
+ let sortKey = column.getAttribute("sortKey") || column.getAttribute("itemproperty");
+
+ cal.unifinder.sortItems(this.mTaskArray, sortKey, modifier);
+ }
+
+ this.recreateHashTable();
+ }
+
+ recreateHashTable() {
+ this.mHash2Index = this.mTaskArray.reduce((hash2Index, task, i) => {
+ hash2Index[task.hashId] = i;
+ return hash2Index;
+ }, {});
+
+ if (this.mTreeView.tree) {
+ this.mTreeView.tree.invalidate();
+ }
+ }
+
+ getInitialDate() {
+ return currentView().selectedDay || cal.dtz.now();
+ }
+
+ doUpdateFilter(filter) {
+ let needsRefresh = false;
+ let oldStart = this.mFilter.mStartDate;
+ let oldEnd = this.mFilter.mEndDate;
+ let filterText = this.mFilter.filterText || "";
+
+ if (filter) {
+ let props = this.mFilter.filterProperties;
+ this.mFilter.applyFilter(filter);
+ needsRefresh = !props || !props.equals(this.mFilter.filterProperties);
+ } else {
+ this.mFilter.updateFilterDates();
+ }
+
+ if (this.mTextFilterField) {
+ let field = document.getElementById(this.mTextFilterField);
+ if (field) {
+ this.mFilter.filterText = field.value;
+ needsRefresh =
+ needsRefresh || filterText.toLowerCase() != this.mFilter.filterText.toLowerCase();
+ }
+ }
+
+ // We only need to refresh the tree if the filter properties or date range changed.
+ const start = this.mFilter.startDate;
+ const end = this.mFilter.mEndDate;
+
+ const sameStartDates = start && oldStart && oldStart.compare(start) == 0;
+ const sameEndDates = end && oldEnd && oldEnd.compare(end) == 0;
+
+ if (
+ needsRefresh ||
+ ((start || oldStart) && !sameStartDates) ||
+ ((end || oldEnd) && !sameEndDates)
+ ) {
+ this.refresh();
+ }
+ }
+
+ updateFilter(filter) {
+ this.doUpdateFilter(filter);
+ }
+
+ updateFocus() {
+ let menuOpen = false;
+
+ // We need to consider the tree focused if the context menu is open.
+ if (this.hasAttribute("context")) {
+ let context = document.getElementById(this.getAttribute("context"));
+ if (context && context.state) {
+ menuOpen = context.state == "open" || context.state == "showing";
+ }
+ }
+
+ let focused = document.activeElement == this || menuOpen;
+
+ calendarController.onSelectionChanged({ detail: focused ? this.selectedTasks : [] });
+ calendarController.todo_tasktree_focused = focused;
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this.persistColumnState();
+ this.mTreeView = null;
+ }
+ }
+
+ customElements.define("calendar-task-tree", CalendarTaskTree, { extends: "tree" });
+
+ /**
+ * Custom element for the task tree that appears in the todaypane.
+ */
+ class CalendarTaskTreeTodaypane extends CalendarTaskTree {
+ getInitialDate() {
+ return TodayPane.start || cal.dtz.now();
+ }
+ updateFilter(filter) {
+ this.mFilter.selectedDate = this.getInitialDate();
+ this.doUpdateFilter(filter);
+ }
+ }
+
+ customElements.define("calendar-task-tree-todaypane", CalendarTaskTreeTodaypane, {
+ extends: "tree",
+ });
+}
diff --git a/comm/calendar/base/content/calendar-task-view.js b/comm/calendar/base/content/calendar-task-view.js
new file mode 100644
index 0000000000..f63fe9ffde
--- /dev/null
+++ b/comm/calendar/base/content/calendar-task-view.js
@@ -0,0 +1,470 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported taskDetailsView, sendMailToOrganizer, taskViewCopyLink */
+
+/* import-globals-from ../../../mail/base/content/mailCore.js */
+/* import-globals-from item-editing/calendar-item-editing.js */
+/* import-globals-from ../src/calApplicationUtils.js */
+/* import-globals-from calendar-ui-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { recurrenceRule2String } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+
+var taskDetailsView = {
+ /**
+ * Task Details Events
+ *
+ * XXXberend Please document this function, possibly also consolidate since
+ * its the only function in taskDetailsView.
+ */
+ onSelect(event) {
+ function displayElement(id, flag) {
+ document.getElementById(id).hidden = !flag;
+ return flag;
+ }
+
+ let dateFormatter = cal.dtz.formatter;
+
+ let item = document.getElementById("calendar-task-tree").currentTask;
+ if (
+ displayElement("calendar-task-details-container", item != null) &&
+ displayElement("calendar-task-view-splitter", item != null)
+ ) {
+ document.getElementById("calendar-task-details-title-row").toggleAttribute("hidden", false);
+ document.getElementById("calendar-task-details-title").textContent = item.title
+ ? item.title.replace(/\n/g, " ")
+ : "";
+
+ let organizer = item.organizer;
+ if (
+ !document
+ .getElementById("calendar-task-details-organizer-row")
+ .toggleAttribute("hidden", !organizer)
+ ) {
+ let name = organizer.commonName;
+ if (!name || name.length <= 0) {
+ if (organizer.id && organizer.id.length) {
+ name = organizer.id;
+ let re = new RegExp("^mailto:(.*)", "i");
+ let matches = re.exec(name);
+ if (matches) {
+ name = matches[1];
+ }
+ }
+ }
+ if (
+ !document
+ .getElementById("calendar-task-details-organizer-row")
+ .toggleAttribute("hidden", !name)
+ ) {
+ document.getElementById("calendar-task-details-organizer").textContent = name;
+ }
+ }
+
+ let priority = 0;
+ if (item.calendar.getProperty("capabilities.priority.supported")) {
+ priority = parseInt(item.priority, 10);
+ }
+ document
+ .getElementById("calendar-task-details-priority-row")
+ .toggleAttribute("hidden", priority == 0);
+ displayElement("calendar-task-details-priority-low", priority >= 6 && priority <= 9);
+ displayElement("calendar-task-details-priority-normal", priority == 5);
+ displayElement("calendar-task-details-priority-high", priority >= 1 && priority <= 4);
+
+ let status = item.getProperty("STATUS");
+ if (
+ !document
+ .getElementById("calendar-task-details-status-row")
+ .toggleAttribute("hidden", !status)
+ ) {
+ let statusDetails = document.getElementById("calendar-task-details-status");
+ switch (status) {
+ case "NEEDS-ACTION": {
+ statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusNeedsAction");
+ break;
+ }
+ case "IN-PROCESS": {
+ let percent = 0;
+ let property = item.getProperty("PERCENT-COMPLETE");
+ if (property != null) {
+ percent = parseInt(property, 10);
+ }
+ statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusInProgress", [
+ percent,
+ ]);
+ break;
+ }
+ case "COMPLETED": {
+ if (item.completedDate) {
+ let completedDate = item.completedDate.getInTimezone(cal.dtz.defaultTimezone);
+ statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusCompletedOn", [
+ dateFormatter.formatDateTime(completedDate),
+ ]);
+ }
+ break;
+ }
+ case "CANCELLED": {
+ statusDetails.textContent = cal.l10n.getCalString("taskDetailsStatusCancelled");
+ break;
+ }
+ default: {
+ document
+ .getElementById("calendar-task-details-status-row")
+ .toggleAttribute("hidden", true);
+ break;
+ }
+ }
+ }
+ let categories = item.getCategories();
+ if (
+ !document
+ .getElementById("calendar-task-details-category-row")
+ .toggleAttribute("hidden", categories.length == 0)
+ ) {
+ document.getElementById("calendar-task-details-category").textContent =
+ categories.join(", ");
+ }
+
+ let taskStartDate = item[cal.dtz.startDateProp(item)];
+ if (taskStartDate) {
+ document.getElementById("task-start-date").textContent =
+ cal.dtz.getStringForDateTime(taskStartDate);
+ }
+ document.getElementById("task-start-row").toggleAttribute("hidden", !taskStartDate);
+
+ let taskDueDate = item[cal.dtz.endDateProp(item)];
+ if (taskDueDate) {
+ document.getElementById("task-due-date").textContent =
+ cal.dtz.getStringForDateTime(taskDueDate);
+ }
+ document.getElementById("task-due-row").toggleAttribute("hidden", !taskDueDate);
+
+ let parentItem = item;
+ if (parentItem.parentItem != parentItem) {
+ // XXXdbo Didn't we want to get rid of these checks?
+ parentItem = parentItem.parentItem;
+ }
+ let recurrenceInfo = parentItem.recurrenceInfo;
+ let recurStart = parentItem.recurrenceStartDate;
+ if (
+ !document
+ .getElementById("calendar-task-details-repeat-row")
+ .toggleAttribute("hidden", !recurrenceInfo || !recurStart)
+ ) {
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ let startDate = recurStart.getInTimezone(kDefaultTimezone);
+ let endDate = item.dueDate ? item.dueDate.getInTimezone(kDefaultTimezone) : null;
+ let detailsString = recurrenceRule2String(
+ recurrenceInfo,
+ startDate,
+ endDate,
+ startDate.isDate
+ );
+ if (detailsString) {
+ let rpv = document.getElementById("calendar-task-details-repeat");
+ rpv.textContent = detailsString.split("\n").join(" ");
+ }
+ }
+ let iframe = document.getElementById("calendar-task-details-description");
+ let docFragment = cal.view.textToHtmlDocumentFragment(
+ item.descriptionText,
+ iframe.contentDocument,
+ item.descriptionHTML
+ );
+
+ // Make any links open in the user's default browser, not in Thunderbird.
+ for (let anchor of docFragment.querySelectorAll("a")) {
+ anchor.addEventListener("click", function (event) {
+ event.preventDefault();
+ if (event.isTrusted) {
+ launchBrowser(anchor.getAttribute("href"), event);
+ }
+ });
+ }
+ iframe.contentDocument.body.replaceChildren(docFragment);
+ let link = iframe.contentDocument.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://messenger/skin/shared/editorContent.css";
+ iframe.contentDocument.head.replaceChildren(link);
+ let attachmentRows = document.getElementById("calendar-task-details-attachment-rows");
+ while (attachmentRows.lastChild) {
+ attachmentRows.lastChild.remove();
+ }
+ let attachments = item.getAttachments();
+ if (displayElement("calendar-task-details-attachment-row", attachments.length > 0)) {
+ displayElement("calendar-task-details-attachment-rows", true);
+ for (let attachment of attachments) {
+ let url = attachment.calIAttachment.uri.spec;
+ let urlLabel = document.createXULElement("label");
+ urlLabel.setAttribute("class", "text-link");
+ urlLabel.setAttribute("value", url);
+ urlLabel.setAttribute("tooltiptext", url);
+ urlLabel.setAttribute("crop", "end");
+ urlLabel.setAttribute("onclick", "if (event.button != 2) launchBrowser(this.value);");
+ urlLabel.setAttribute("context", "taskview-link-context-menu");
+ attachmentRows.appendChild(urlLabel);
+ }
+ }
+ }
+ },
+
+ loadCategories() {
+ let categoryPopup = document.getElementById("task-actions-category-popup");
+ let item = document.getElementById("calendar-task-tree").currentTask;
+
+ let itemCategories = item.getCategories();
+ let categoryList = cal.category.fromPrefs();
+ for (let cat of itemCategories) {
+ if (!categoryList.includes(cat)) {
+ categoryList.push(cat);
+ }
+ }
+ cal.l10n.sortArrayByLocaleCollator(categoryList);
+
+ let maxCount = item.calendar.getProperty("capabilities.categories.maxCount");
+
+ while (categoryPopup.childElementCount > 2) {
+ categoryPopup.lastChild.remove();
+ }
+ if (maxCount == 1) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("class", "menuitem-iconic");
+ menuitem.setAttribute("label", cal.l10n.getCalString("None"));
+ menuitem.setAttribute("type", "radio");
+ if (itemCategories.length === 0) {
+ menuitem.setAttribute("checked", "true");
+ }
+ categoryPopup.appendChild(menuitem);
+ }
+ for (let cat of categoryList) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("class", "menuitem-iconic calendar-category");
+ menuitem.setAttribute("label", cat);
+ menuitem.setAttribute("value", cat);
+ menuitem.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio");
+ if (itemCategories.includes(cat)) {
+ menuitem.setAttribute("checked", "true");
+ }
+ categoryPopup.appendChild(menuitem);
+ }
+ },
+
+ saveCategories(event) {
+ let categoryPopup = document.getElementById("task-actions-category-popup");
+ let item = document.getElementById("calendar-task-tree").currentTask;
+
+ let oldCategories = item.getCategories();
+ let categories = Array.from(
+ categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"),
+ menuitem => menuitem.value
+ );
+ let unchanged = oldCategories.length == categories.length;
+ for (let i = 0; unchanged && i < categories.length; i++) {
+ unchanged = oldCategories[i] == categories[i];
+ }
+
+ if (!unchanged) {
+ let newItem = item.clone();
+ newItem.setCategories(categories);
+ doTransaction("modify", newItem, newItem.calendar, item, null);
+ return false;
+ }
+
+ return true;
+ },
+
+ categoryTextboxKeypress(event) {
+ let category = event.target.value;
+ let categoryPopup = document.getElementById("task-actions-category-popup");
+
+ switch (event.key) {
+ case " ": {
+ // The menu popup seems to eat this keypress.
+ let start = event.target.selectionStart;
+ event.target.value =
+ category.substring(0, start) + " " + category.substring(event.target.selectionEnd);
+ event.target.selectionStart = event.target.selectionEnd = start + 1;
+ return;
+ }
+ case "Tab":
+ case "ArrowDown":
+ case "ArrowUp": {
+ event.target.blur();
+ event.preventDefault();
+
+ let key = event.key == "ArrowUp" ? "ArrowUp" : "ArrowDown";
+ categoryPopup.dispatchEvent(new KeyboardEvent("keydown", { key }));
+ categoryPopup.dispatchEvent(new KeyboardEvent("keyup", { key }));
+ return;
+ }
+ case "Escape":
+ if (category) {
+ event.target.value = "";
+ } else {
+ categoryPopup.hidePopup();
+ }
+ event.preventDefault();
+ return;
+ case "Enter":
+ category = category.trim();
+ if (category != "") {
+ break;
+ }
+ return;
+ default: {
+ return;
+ }
+ }
+
+ event.preventDefault();
+
+ let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category");
+ let categories = Array.from(categoryList, cat => cat.getAttribute("value"));
+
+ let modified = false;
+ let newIndex = categories.indexOf(category);
+ if (newIndex > -1) {
+ if (categoryList[newIndex].getAttribute("checked") != "true") {
+ categoryList[newIndex].setAttribute("checked", "true");
+ modified = true;
+ }
+ } else {
+ const localeCollator = new Intl.Collator();
+ let compare = localeCollator.compare;
+ newIndex = cal.data.binaryInsert(categories, category, compare, true);
+
+ let item = document.getElementById("calendar-task-tree").currentTask;
+ let maxCount = item.calendar.getProperty("capabilities.categories.maxCount");
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("class", "menuitem-iconic calendar-category");
+ menuitem.setAttribute("label", category);
+ menuitem.setAttribute("value", category);
+ menuitem.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio");
+ menuitem.setAttribute("checked", true);
+ categoryPopup.insertBefore(menuitem, categoryList[newIndex]);
+
+ modified = true;
+ }
+
+ if (modified) {
+ categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category[checked]");
+ categories = Array.from(categoryList, cat => cat.getAttribute("value"));
+
+ let item = document.getElementById("calendar-task-tree").currentTask;
+ let newItem = item.clone();
+ newItem.setCategories(categories);
+ doTransaction("modify", newItem, newItem.calendar, item, null);
+ }
+
+ event.target.value = "";
+ },
+};
+
+/**
+ * Updates the currently applied filter for the task view and refreshes the task
+ * tree.
+ *
+ * @param {string} [filter] - The filter name to set.
+ */
+function taskViewUpdate(filter) {
+ if (!filter) {
+ let taskFilterGroup = document.getElementById("task-tree-filtergroup");
+ filter = taskFilterGroup.value || "all";
+ }
+
+ let tree = document.getElementById("calendar-task-tree");
+ let oldFilter = tree.getAttribute("filterValue");
+ if (filter != oldFilter) {
+ tree.setAttribute("filterValue", filter);
+ document
+ .querySelectorAll(
+ `menuitem[command="calendar_task_filter_command"][type="radio"],
+ toolbarbutton[command="calendar_task_filter_command"][type="radio"]`
+ )
+ .forEach(item => {
+ if (item.getAttribute("value") == filter) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ });
+ let radio = document.querySelector(
+ `radio[command="calendar_task_filter_command"][value="${filter}"]`
+ );
+ if (radio) {
+ radio.radioGroup.selectedItem = radio;
+ }
+ }
+ tree.updateFilter(filter);
+}
+
+/**
+ * Prepares a dialog to send an email to the organizer of the currently selected
+ * task in the task view.
+ *
+ * XXX We already have a function with this name in the event dialog. Either
+ * consolidate or make name more clear.
+ */
+function sendMailToOrganizer() {
+ let item = document.getElementById("calendar-task-tree").currentTask;
+ if (item != null) {
+ let organizer = item.organizer;
+ let email = cal.email.getAttendeeEmail(organizer, true);
+ let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [
+ item.title,
+ ]);
+ let identity = item.calendar.getProperty("imip.identity");
+ cal.email.sendTo(email, emailSubject, null, identity);
+ }
+}
+
+// Install event listeners for the display deck change and connect task tree to filter field
+function taskViewOnLoad() {
+ let calendarDisplayBox = document.getElementById("calendarDisplayBox");
+ let tree = document.getElementById("calendar-task-tree");
+
+ if (calendarDisplayBox && tree) {
+ tree.textFilterField = "task-text-filter-field";
+
+ // setup the platform-dependent placeholder for the text filter field
+ let textFilter = document.getElementById("task-text-filter-field");
+ if (textFilter) {
+ let base = textFilter.getAttribute("emptytextbase");
+ let keyLabel = textFilter.getAttribute(
+ AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac"
+ );
+
+ textFilter.setAttribute("placeholder", base.replace("#1", keyLabel));
+ textFilter.value = "";
+ }
+ taskViewUpdate();
+ }
+
+ // Setup customizeDone handler for the task action toolbox.
+ let toolbox = document.getElementById("task-actions-toolbox");
+ toolbox.customizeDone = function (aEvent) {
+ MailToolboxCustomizeDone(aEvent, "CustomizeTaskActionsToolbar");
+ };
+
+ Services.obs.notifyObservers(window, "calendar-taskview-startup-done");
+}
+
+/**
+ * Copy the value of the given link node to the clipboard
+ *
+ * @param linkNode The node containing the value to copy to the clipboard
+ */
+function taskViewCopyLink(linkNode) {
+ if (linkNode) {
+ let linkAddress = linkNode.value;
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
+ clipboard.copyString(linkAddress);
+ }
+}
diff --git a/comm/calendar/base/content/calendar-today-pane.inc.xhtml b/comm/calendar/base/content/calendar-today-pane.inc.xhtml
new file mode 100644
index 0000000000..70df2ed912
--- /dev/null
+++ b/comm/calendar/base/content/calendar-today-pane.inc.xhtml
@@ -0,0 +1,179 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<splitter id="today-splitter"
+ class="calendar-sidebar-splitter hide-when-calendar-deactivated"
+ collapse="after"
+ resizebefore="closest"
+ state="collapsed"
+ oncommand="TodayPane.onCommandTodaySplitter();">
+</splitter>
+<calendar-modevbox id="today-pane-panel"
+ class="hide-when-calendar-deactivated"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ modewidths="200,200,200,200,200,200"
+ refcontrol="calendar_toggle_todaypane_command"
+ persist="modewidths">
+ <box class="sidebar-header" align="center">
+ <label id="today-pane-header"/>
+ <spacer flex="1"/>
+ <calendar-modebox mode="mail,calendar,chat,calendarEvent,calendarTask">
+ <toolbarbutton id="today-pane-cycler-prev"
+ dir="prev"
+ class="today-pane-cycler"
+ oncommand="TodayPane.cyclePaneView(-1);"/>
+ <toolbarbutton id="today-pane-cycler-next"
+ dir="next"
+ class="today-pane-cycler"
+ oncommand="TodayPane.cyclePaneView(1);"/>
+ </calendar-modebox>
+ <spacer id="buttonspacer"/>
+ <toolbarbutton id="today-closer" class="today-closebutton close-icon"
+ oncommand="document.getElementById('today-pane-panel').setVisible(false, true, true);
+ TodayPane.updateDisplay();
+ TodayPane.updateSplitterState();"/>
+ </box>
+ <vbox flex="1">
+ <calendar-modevbox id="agenda-panel"
+ flex="1"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ collapsedinmodes="calendar"
+ persist="collapsed height collapsedinmodes">
+ <calendar-modebox id="today-none-box"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ collapsedinmodes="mail,calendar,task,chat,calendarEvent,calendarTask"
+ refcontrol="calTodayPaneDisplayNone"
+ persist="collapsedinmodes"/>
+ <calendar-modebox id="today-minimonth-box"
+ pack="center"
+ class="today-subpane"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ collapsedinmodes="mail,calendar,task,chat,calendarEvent,calendarTask"
+ refcontrol="calTodayPaneDisplayMinimonth"
+ persist="collapsedinmodes">
+ <calendar-minimonth id="today-minimonth"
+ onchange="TodayPane.setDaywithjsDate(this.value);"/>
+ </calendar-modebox>
+ <calendar-modebox id="mini-day-box"
+ mode="mail,calendar,task,chat,calendarEvent,calendarTask"
+ class="today-subpane"
+ refcontrol="calTodayPaneDisplayMiniday"
+ collapsedinmodes=""
+ persist="collapsedinmodes"
+ onwheel="TodayPane.advance(event.detail > 0 ? 1 : -1);">
+ <hbox id="mini-day-image" flex="1">
+ <stack id="dateContainer">
+ <hbox pack="center"
+ align="center">
+ <label id="datevalue-label" class="dateValue"
+ ondblclick="TodayPane.onDoubleClick(event);"
+ onmousedown="TodayPane.onMousedown(event);"/>
+ </hbox>
+ <hbox id="dragCenter-image-container" flex="1" pack="center" align="center">
+ <html:img id="dragCenter-image"
+ src="chrome://calendar/skin/shared/widgets/drag-center.svg"
+ alt=""
+ hidden="true" />
+ </hbox>
+ </stack>
+ <vbox flex="1">
+ <hbox pack="center">
+ <label id="weekdayNameLabel"
+ ondblclick="TodayPane.onDoubleClick(event);"
+ flex="1"/>
+ <hbox pack="end">
+ <toolbarbutton id="previous-day-button"
+ class="miniday-nav-buttons"
+ tooltiptext="&onedaybackward.tooltip;"
+ onmousedown="TodayPane.onMousedown(event, -1);"
+ dir="-1"/>
+ <toolbarbutton id="today-button"
+ class="miniday-nav-buttons"
+ tooltiptext="&showToday.tooltip;"
+ oncommand="TodayPane.setDay(cal.dtz.now());"/>
+ <toolbarbutton id="next-day-button"
+ class="miniday-nav-buttons"
+ tooltiptext="&onedayforward.tooltip;"
+ onmousedown="TodayPane.onMousedown(event, 1);"
+ dir="1"/>
+ </hbox>
+ </hbox>
+ <hbox pack="start">
+ <label id="monthNameContainer" class="monthlabel"
+ ondblclick="TodayPane.onDoubleClick(event);"/>
+ <label id="currentWeek-label" class="monthlabel"
+ ondblclick="TodayPane.onDoubleClick(event);"/>
+ <spacer flex="1"/>
+ </hbox>
+ </vbox>
+ <toolbarbutton id="miniday-dropdown-button"
+ tooltiptext="&showselectedday.tooltip;"
+ type="menu"
+ wantdropmarker="true">
+ <panel id="miniday-month-panel" position="after_end"
+ onpopupshown="this.firstElementChild.focusCalendar();">
+ <calendar-minimonth id="miniday-dropdown-minimonth"
+ flex="1"
+ onchange="TodayPane.setDaywithjsDate(this.value);
+ document.getElementById('miniday-month-panel').hidePopup();"/>
+ </panel>
+ </toolbarbutton>
+ </hbox>
+ </calendar-modebox>
+ <vbox id="agenda-container" tooltip="itemTooltip">
+ <hbox id="agenda-toolbar" class="themeable-brighttext">
+ <toolbarbutton id="todaypane-new-event-button"
+ mode="mail"
+ iconsize="small"
+ orient="horizontal"
+ label="&calendar.newevent.button.label;"
+ tooltiptext="&calendar.newevent.button.tooltip;"
+ command="calendar_new_event_todaypane_command"/>
+ </hbox>
+ <html:ul is="agenda-list" id="agenda" role="listbox"></html:ul>
+ <template id="agenda-listitem" xmlns="http://www.w3.org/1999/xhtml">
+ <div class="agenda-date-header"></div>
+ <div class="agenda-listitem-details">
+ <div class="agenda-listitem-calendar"></div>
+ <div class="agenda-listitem-details-inner">
+ <time class="agenda-listitem-time"></time>
+ <span class="agenda-listitem-title"></span>
+ <span class="agenda-listitem-relative"></span>
+ </div>
+ <img class="agenda-listitem-overlap" />
+ </div>
+ </template>
+ </vbox>
+ </calendar-modevbox>
+ <splitter id="today-pane-splitter" persist="hidden" orient="vertical"/>
+ <calendar-modevbox id="todo-tab-panel"
+ mode="mail,calendar,chat,calendarEvent,calendarTask"
+ collapsedinmodes="mail,task,chat,calendarEvent,calendarTask"
+ persist="height collapsedinmodes"
+ ondragover="calendarTaskButtonDNDObserver.onDragOver(event);"
+ ondrop="calendarTaskButtonDNDObserver.onDrop(event);">
+ <box id="show-completed-checkbox-box" align="center">
+ <checkbox id="show-completed-checkbox"
+ label="&calendar.unifinder.showcompletedtodos.label;"
+ flex="1"
+ crop="end"
+ oncommand="TodayPane.updateCalendarToDoUnifinder()"
+ persist="checked"
+ autocheck="false"/>
+ </box>
+ <vbox id="calendar-task-tree-detail" flex="1">
+ <tree is="calendar-task-tree-todaypane" id="unifinder-todo-tree"
+ flex="1"
+ visible-columns="completed priority title"
+ persist="visible-columns ordinals widths sort-active sort-direction filterValue"
+ context="taskitem-context-menu"/>
+ <html:input id="unifinder-task-edit-field"
+ class="task-edit-field themeableSearchBox"
+ onfocus="taskEdit.onFocus(event)"
+ onblur="taskEdit.onBlur(event)"
+ onkeypress="taskEdit.onKeyPress(event)"/>
+ </vbox>
+ </calendar-modevbox>
+ </vbox>
+</calendar-modevbox>
diff --git a/comm/calendar/base/content/calendar-ui-utils.js b/comm/calendar/base/content/calendar-ui-utils.js
new file mode 100644
index 0000000000..11d92ab6da
--- /dev/null
+++ b/comm/calendar/base/content/calendar-ui-utils.js
@@ -0,0 +1,596 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported disableElementWithLock,
+ * enableElementWithLock,
+ * appendCalendarItems, checkRadioControl,
+ * checkRadioControlAppmenu,
+ * updateUnitLabelPlural, updateMenuLabelsPlural,
+ * getOptimalMinimumWidth, getOptimalMinimumHeight,
+ * setupAttendanceMenu
+ */
+
+/* import-globals-from ../../../mail/base/content/globalOverlay.js */
+/* import-globals-from ../../../mail/base/content/utilityOverlay.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+/**
+ * This function unconditionally disables the element for
+ * which the id has been passed as argument. Furthermore, it
+ * remembers who was responsible for this action by using
+ * the given key (lockId). In case the control should be
+ * enabled again the lock gets removed, but the control only
+ * gets enabled if *all* possibly held locks have been removed.
+ *
+ * @param elementId The element ID of the element to disable.
+ * @param lockId The ID of the lock to set.
+ */
+function disableElementWithLock(elementId, lockId) {
+ // unconditionally disable the element.
+ document.getElementById(elementId).setAttribute("disabled", "true");
+
+ // remember that this element has been locked with
+ // the key passed as argument. we keep a primitive
+ // form of ref-count in the attribute 'lock'.
+ let element = document.getElementById(elementId);
+ if (element) {
+ if (!element.hasAttribute(lockId)) {
+ element.setAttribute(lockId, "true");
+ let n = parseInt(element.getAttribute("lock") || 0, 10);
+ element.setAttribute("lock", n + 1);
+ }
+ }
+}
+
+/**
+ * This function is intended to be used in tandem with the
+ * above defined function 'disableElementWithLock()'.
+ * See the respective comment for further details.
+ *
+ * @see disableElementWithLock
+ * @param elementId The element ID of the element to enable.
+ * @param lockId The ID of the lock to set.
+ */
+function enableElementWithLock(elementId, lockId) {
+ let element = document.getElementById(elementId);
+ if (!element) {
+ dump("unable to find " + elementId + "\n");
+ return;
+ }
+
+ if (element.hasAttribute(lockId)) {
+ element.removeAttribute(lockId);
+ let n = parseInt(element.getAttribute("lock") || 0, 10) - 1;
+ if (n > 0) {
+ element.setAttribute("lock", n);
+ } else {
+ element.removeAttribute("lock");
+ }
+ if (n <= 0) {
+ element.removeAttribute("disabled");
+ }
+ }
+}
+
+/**
+ * Sorts a sorted array of calendars by pref |calendar.list.sortOrder|.
+ * Repairs that pref if dangling entries exist.
+ *
+ * @param calendars An array of calendars to sort.
+ */
+function sortCalendarArray(calendars) {
+ let ret = calendars.concat([]);
+ let sortOrder = {};
+ let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" ");
+ for (let i = 0; i < sortOrderPref.length; ++i) {
+ sortOrder[sortOrderPref[i]] = i;
+ }
+ function sortFunc(cal1, cal2) {
+ let orderIdx1 = sortOrder[cal1.id] || -1;
+ let orderIdx2 = sortOrder[cal2.id] || -1;
+ if (orderIdx1 < orderIdx2) {
+ return -1;
+ }
+ if (orderIdx1 > orderIdx2) {
+ return 1;
+ }
+ return 0;
+ }
+ ret.sort(sortFunc);
+
+ // check and repair pref when an array of all calendars has been passed:
+ let sortOrderString = Services.prefs.getStringPref("calendar.list.sortOrder", "");
+ let wantedOrderString = ret.map(calendar => calendar.id).join(" ");
+ if (wantedOrderString != sortOrderString && cal.manager.getCalendars().length == ret.length) {
+ Services.prefs.setStringPref("calendar.list.sortOrder", wantedOrderString);
+ }
+
+ return ret;
+}
+
+/**
+ * Fills up a menu - either a menupopup or a menulist - with menuitems that refer
+ * to calendars.
+ *
+ * @param aItem The event or task
+ * @param aCalendarMenuParent The direct parent of the menuitems - either a
+ * menupopup or a menulist
+ * @param aCalendarToUse The default-calendar
+ * @param aOnCommand A string that is applied to the "oncommand"
+ * attribute of each menuitem
+ * @returns The index of the calendar that matches the
+ * default-calendar. By default 0 is returned.
+ */
+function appendCalendarItems(aItem, aCalendarMenuParent, aCalendarToUse, aOnCommand) {
+ let calendarToUse = aCalendarToUse || aItem.calendar;
+ let calendars = sortCalendarArray(cal.manager.getCalendars());
+ let indexToSelect = 0;
+ let index = -1;
+ for (let i = 0; i < calendars.length; ++i) {
+ let calendar = calendars[i];
+ if (
+ calendar.id == calendarToUse.id ||
+ (calendar &&
+ cal.acl.isCalendarWritable(calendar) &&
+ (cal.acl.userCanAddItemsToCalendar(calendar) ||
+ (calendar == aItem.calendar && cal.acl.userCanModifyItem(aItem))) &&
+ cal.item.isItemSupported(aItem, calendar))
+ ) {
+ let menuitem = addMenuItem(aCalendarMenuParent, calendar.name, calendar.name);
+ menuitem.calendar = calendar;
+ index++;
+ if (aOnCommand) {
+ menuitem.setAttribute("oncommand", aOnCommand);
+ }
+ if (aCalendarMenuParent.localName == "menupopup") {
+ menuitem.setAttribute("type", "checkbox");
+ }
+ if (calendarToUse && calendarToUse.id == calendar.id) {
+ indexToSelect = index;
+ }
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ menuitem.style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`);
+ menuitem.classList.add("menuitem-iconic");
+ }
+ }
+ return indexToSelect;
+}
+
+/**
+ * Helper function to add a menuitem to a menulist or similar.
+ *
+ * @param aParent The XUL node to add the menuitem to.
+ * @param aLabel The label string of the menuitem.
+ * @param aValue The value attribute of the menuitem.
+ * @param aCommand The oncommand attribute of the menuitem.
+ * @returns The newly created menuitem
+ */
+function addMenuItem(aParent, aLabel, aValue, aCommand) {
+ let item = null;
+ if (aParent.localName == "menupopup") {
+ item = document.createXULElement("menuitem");
+ item.setAttribute("label", aLabel);
+ if (aValue) {
+ item.setAttribute("value", aValue);
+ }
+ if (aCommand) {
+ item.command = aCommand;
+ }
+ aParent.appendChild(item);
+ } else if (aParent.localName == "menulist") {
+ item = aParent.appendItem(aLabel, aValue);
+ }
+ return item;
+}
+
+/**
+ * Gets the correct plural form of a given unit.
+ *
+ * @param aLength The number to use to determine the plural form
+ * @param aUnit The unit to find the plural form of
+ * @param aIncludeLength (optional) If true, the length will be included in the
+ * result. If false, only the pluralized unit is returned.
+ * @returns A string containing the pluralized version of the unit
+ */
+function unitPluralForm(aLength, aUnit, aIncludeLength = true) {
+ let unitProp =
+ {
+ minutes: "unitMinutes",
+ hours: "unitHours",
+ days: "unitDays",
+ weeks: "unitWeeks",
+ }[aUnit] || "unitMinutes";
+
+ return PluralForm.get(aLength, cal.l10n.getCalString(unitProp))
+ .replace("#1", aIncludeLength ? aLength : "")
+ .trim();
+}
+
+/**
+ * Update the given unit label to show the correct plural form.
+ *
+ * @param aLengthFieldId The ID of the element containing the number
+ * @param aLabelId The ID of the label to update.
+ * @param aUnit The unit to use for the label.
+ */
+function updateUnitLabelPlural(aLengthFieldId, aLabelId, aUnit) {
+ let label = document.getElementById(aLabelId);
+ let length = Number(document.getElementById(aLengthFieldId).value);
+
+ label.value = unitPluralForm(length, aUnit, false);
+}
+
+/**
+ * Update the given menu to show the correct plural form in the list.
+ *
+ * @param aLengthFieldId The ID of the element containing the number
+ * @param aMenuId The menu to update labels in.
+ */
+function updateMenuLabelsPlural(aLengthFieldId, aMenuId) {
+ let menu = document.getElementById(aMenuId);
+ let length = Number(document.getElementById(aLengthFieldId).value);
+
+ // update the menu items
+ let items = menu.getElementsByTagName("menuitem");
+ for (let menuItem of items) {
+ menuItem.label = unitPluralForm(length, menuItem.value, false);
+ }
+
+ // force the menu selection to redraw
+ let saveSelectedIndex = menu.selectedIndex;
+ menu.selectedIndex = -1;
+ menu.selectedIndex = saveSelectedIndex;
+}
+
+/**
+ * A helper function to calculate and add up certain css-values of a box.
+ * It is required, that all css values can be converted to integers
+ * see also
+ * http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSview-getComputedStyle
+ *
+ * @param aXULElement The xul element to be inspected.
+ * @param aStyleProps The css style properties for which values are to be retrieved
+ * e.g. 'font-size', 'min-width" etc.
+ * @returns An integer value denoting the optimal minimum width
+ */
+function getSummarizedStyleValues(aXULElement, aStyleProps) {
+ let retValue = 0;
+ let cssStyleDeclares = document.defaultView.getComputedStyle(aXULElement);
+ for (let prop of aStyleProps) {
+ retValue += parseInt(cssStyleDeclares.getPropertyValue(prop), 10);
+ }
+ return retValue;
+}
+
+/**
+ * Calculates the optimal minimum width based on the set css style-rules
+ * by considering the css rules for the min-width, padding, border, margin
+ * and border of the box.
+ *
+ * @param aXULElement The xul element to be inspected.
+ * @returns An integer value denoting the optimal minimum width
+ */
+function getOptimalMinimumWidth(aXULElement) {
+ return getSummarizedStyleValues(aXULElement, [
+ "min-width",
+ "padding-left",
+ "padding-right",
+ "margin-left",
+ "margin-top",
+ "border-left-width",
+ "border-right-width",
+ ]);
+}
+
+/**
+ * Calculates the optimal minimum height based on the set css style-rules
+ * by considering the css rules for the font-size, padding, border, margin
+ * and border of the box. In its current state the line-height is considered
+ * by assuming that it's size is about one third of the size of the font-size
+ *
+ * @param aXULElement The xul-element to be inspected.
+ * @returns An integer value denoting the optimal minimum height
+ */
+function getOptimalMinimumHeight(aXULElement) {
+ // the following line of code presumes that the line-height is set to "normal"
+ // which is supposed to be a "reasonable distance" between the lines
+ let firstEntity = parseInt(1.35 * getSummarizedStyleValues(aXULElement, ["font-size"]), 10);
+ let secondEntity = getSummarizedStyleValues(aXULElement, [
+ "padding-bottom",
+ "padding-top",
+ "margin-bottom",
+ "margin-top",
+ "border-bottom-width",
+ "border-top-width",
+ ]);
+ return firstEntity + secondEntity;
+}
+
+/**
+ * Sets up the attendance context menu, based on the given items
+ *
+ * @param {Node} aMenu The context menu item containing the required
+ * menu or menuitem elements
+ * @param {Array} aItems - An array of the selected calEvent or calTodo
+ * items to display the context menu for
+ */
+function setupAttendanceMenu(aMenu, aItems) {
+ /**
+ * For menu items in scope, a check mark will be annotated corresponding to
+ * the partstat and removed for all others
+ *
+ * The user always selected single items or occurrences of series but never
+ * the master event of a series. That said, for the items in aItems, one of
+ * following scenarios applies:
+ *
+ * A. one none-recurring item which have attendees
+ * B. multiple none-recurring items which have attendees
+ * C. one occurrence of a series which has attendees
+ * D. multiple occurrences of the same series which have attendees
+ * E. multiple occurrences of different series which have attendees
+ * F. mixture of non-recurring and occurrences of one or more series which
+ * have attendees
+ * G. any mixture including a single item or an occurrence which doesn't
+ * have any attendees
+ *
+ * For scenarios A and B, the user will be prompted with a single set of
+ * available partstats and the according options to change it.
+ *
+ * For C, D and E the user was prompted with a set of partstats for both,
+ * the occurrence and the master. In case of E, no partstat information
+ * was annotated.
+ *
+ * For F, only a single set of available partstat options was prompted
+ * without annotating any partstat.
+ *
+ * For G, no context menu would be displayed, so we don't need to deal with
+ * that scenario here.
+ *
+ * Now the following matrix applies to take action of the users choice for
+ * the relevant participant (for columns, see explanation below):
+ * +---+------------------+-------------+--------+-----------------+
+ * | # | SELECTED | DISPLAYED | STATUS | MENU ACTION |
+ * | | CAL ITEMS | SUBMENU | PRESET | APPLIES ON |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | | this-occ* | yes | selected item |
+ * | A | one +-------------+--------+-----------------+
+ * | | single item | all-occ | n/a |
+ * | | | | menu not displayed |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | | this-occ* | no | selected items |
+ * | B | multiple +-------------+--------+-----------------+
+ * | | single items | all-occ | n/a |
+ * | | | | menu not displayed |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | | this-occ | yes | sel. |
+ * | | one | | | occurrences |
+ * | C | occurrence +-------------+--------+-----------------+
+ * | | of a master | all-occ | yes | master of sel. |
+ * | | | | | occurrence |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | | this-occ | no | sel. |
+ * | | multiple | | | occurrences |
+ * | D | occurrences +-------------+--------+-----------------+
+ * | | of one master | all-occ | yes | master of sel. |
+ * | | | | | occurrences |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | | this-occ | no | sel. |
+ * | | multiple | | | occurrences |
+ * | E | occurrences of +-------------+--------+-----------------+
+ * | | multiple masters | all-occ | no | masters of sel. |
+ * | | | | | occurrences |
+ * +---+------------------+-------------+--------+-----------------+
+ * | | multiple single | this-occ* | no | selected items |
+ * | | and occurrences | | | and occurrences |
+ * | F | of multiple +-------------+--------+-----------------+
+ * | | masters | all-occ | n/a |
+ * | | | | menu not displayed |
+ * +---+------------------+-------------+--------------------------+
+ * | | any combination | |
+ * | G | including at | n/a |
+ * | | least one items | no attendance menu displayed |
+ * | | or occurrence | |
+ * | | w/o attendees | |
+ * +---+------------------+----------------------------------------+
+ *
+ * #: scenario as described above
+ * SELECTED CAL ITEMS: item types the user selected to prompt the context
+ * menu for
+ * DISPLAYED SUBMENU: the subbmenu displayed
+ * STATUS PRESET: whether or not a partstat is annotated to the menu
+ * items, if the respective submenu is displayed
+ * MENU ACTION APPLIES ON: the cal item, the respective partstat should be
+ * applied on, if the respective submenu is
+ * displayed
+ *
+ * this-occ* means that in this cases the submenu label is not displayed -
+ * additionally, if status is not preset the menu item for 'NEEDS-ACTIONS'
+ * will not be displayed, if the status is already different (consistent
+ * how we deal with that case at other places)
+ *
+ * @param {NodeList} aMenuItems A list of DOM nodes
+ * @param {string} aScope Either 'this-occurrence' or
+ * 'all-occurrences'
+ * @param {string} aPartStat A valid participation status
+ * as per RfC 5545
+ */
+ function checkMenuItem(aMenuItems, aScope, aPartStat) {
+ let toRemove = [];
+ let toAdd = [];
+ for (let item of aMenuItems) {
+ if (item.getAttribute("scope") == aScope && item.nodeName != "label") {
+ if (item.getAttribute("value") == aPartStat) {
+ switch (item.nodeName) {
+ case "menu": {
+ // Since menu elements cannot have checkmarks,
+ // we add a menuitem for this partstat and hide
+ // the menu element instead
+ let checkedId = "checked-" + item.getAttribute("id");
+ if (!document.getElementById(checkedId)) {
+ let checked = item.ownerDocument.createXULElement("menuitem");
+ checked.setAttribute("type", "checkbox");
+ checked.setAttribute("checked", "true");
+ checked.setAttribute("label", item.getAttribute("label"));
+ checked.setAttribute("value", item.getAttribute("value"));
+ checked.setAttribute("scope", item.getAttribute("scope"));
+ checked.setAttribute("id", checkedId);
+ item.setAttribute("hidden", "true");
+ toAdd.push([item, checked]);
+ }
+ break;
+ }
+ case "menuitem": {
+ item.removeAttribute("hidden");
+ item.setAttribute("checked", "true");
+ break;
+ }
+ }
+ } else if (item.nodeName == "menuitem") {
+ if (item.getAttribute("id").startsWith("checked-")) {
+ // we inserted a menuitem before for this partstat, so
+ // we revert that now
+ let menu = document.getElementById(item.getAttribute("id").substr(8));
+ menu.removeAttribute("hidden");
+ toRemove.push(item);
+ } else {
+ item.removeAttribute("checked");
+ }
+ } else if (item.nodeName == "menu") {
+ item.removeAttribute("hidden");
+ }
+ }
+ }
+ for (let [item, checked] of toAdd) {
+ item.before(checked);
+ }
+ for (let item of toRemove) {
+ item.remove();
+ }
+ }
+
+ /**
+ * Hides the items from the provided node list. If a partstat is provided,
+ * only the matching item will be hidden
+ *
+ * @param {NodeList} aMenuItems A list of DOM nodes
+ * @param {string} aPartStat [optional] A valid participation
+ * status as per RfC 5545
+ */
+ function hideItems(aNodeList, aPartStat = null) {
+ for (let item of aNodeList) {
+ if (aPartStat && aPartStat != item.getAttribute("value")) {
+ continue;
+ }
+ item.setAttribute("hidden", "true");
+ }
+ }
+
+ /**
+ * Provides the user's participation status for a provided item
+ *
+ * @param {calEvent|calTodo} aItem The calendar item to inspect
+ * @returns {?string} The participation status string
+ * as per RfC 5545 or null if no
+ * participant was detected
+ */
+ function getInvitationStatus(aItem) {
+ let party = null;
+ if (cal.itip.isInvitation(aItem)) {
+ party = cal.itip.getInvitedAttendee(aItem);
+ } else if (aItem.organizer && aItem.getAttendees().length) {
+ let calOrgId = aItem.calendar.getProperty("organizerId");
+ if (calOrgId && calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) {
+ party = aItem.organizer;
+ }
+ }
+ return party && (party.participationStatus || "NEEDS-ACTION");
+ }
+
+ goUpdateCommand("calendar_attendance_command");
+
+ let singleMenuItems = aMenu.getElementsByAttribute("scope", "this-occurrence");
+ let seriesMenuItems = aMenu.getElementsByAttribute("scope", "all-occurrences");
+ let labels = aMenu.getElementsByAttribute("class", "calendar-context-heading-label");
+
+ if (aItems.length == 1) {
+ // we offer options for both single and recurring items. In case of the
+ // latter and the item is an occurrence, we offer status information and
+ // actions for both, the occurrence and the series
+ let thisPartStat = getInvitationStatus(aItems[0]);
+
+ if (aItems[0].recurrenceId) {
+ // we get the partstat - if this is null, no participant could
+ // be identified, so we bail out
+ let seriesPartStat = getInvitationStatus(aItems[0].parentItem);
+ if (seriesPartStat) {
+ // let's make sure we display the labels to distinguish series
+ // and occurrence
+ for (let label of labels) {
+ label.removeAttribute("hidden");
+ }
+
+ checkMenuItem(seriesMenuItems, "all-occurrences", seriesPartStat);
+
+ if (seriesPartStat != "NEEDS-ACTION") {
+ hideItems(seriesMenuItems, "NEEDS-ACTION");
+ }
+ // until we support actively delegating items, we also only
+ // display this status if it is already set
+ if (seriesPartStat != "DELEGATED") {
+ hideItems(seriesMenuItems, "DELEGATED");
+ }
+ } else {
+ hideItems(seriesMenuItems);
+ }
+ } else {
+ // here we don't need the all-occurrences scope, so let's hide all
+ // labels and related menu items
+ hideItems(labels);
+ hideItems(seriesMenuItems);
+ }
+
+ // also for the single occurrence we check whether there's a partstat
+ // available and bail out otherwise - we also make sure to not display
+ // the NEEDS-ACTION menu item if the current status is already different
+ if (thisPartStat) {
+ checkMenuItem(singleMenuItems, "this-occurrence", thisPartStat);
+ if (thisPartStat != "NEEDS-ACTION") {
+ hideItems(singleMenuItems, "NEEDS-ACTION");
+ }
+ // until we support actively delegating items, we also only display
+ // this status if it is already set (by another client or the server)
+ if (thisPartStat != "DELEGATED") {
+ hideItems(singleMenuItems, "DELEGATED");
+ }
+ } else {
+ // in this case, we hide the entire attendance menu
+ aMenu.setAttribute("hidden", "true");
+ }
+ } else if (aItems.length > 1) {
+ // the user displayed a context menu for multiple selected items.
+ // The selection might comprise single and recurring events, so we need
+ // to deal here with any combination thereof. To do so, we don't display
+ // a partstat control for the entire series but only for the selected
+ // occurrences. As we have a potential mixture of partstat, we also don't
+ // display the current status and no action towards NEEDS-ACTIONS.
+ hideItems(labels);
+ hideItems(seriesMenuItems);
+ hideItems(singleMenuItems, "NEEDS-ACTION");
+ } else {
+ // there seems to be no item passed in, so we don't display anything
+ hideItems(labels);
+ hideItems(seriesMenuItems);
+ hideItems(singleMenuItems);
+ }
+}
+
+/**
+ * Open the calendar settings to define the weekdays.
+ */
+function showCalendarWeekPreferences() {
+ openPreferencesTab("paneCalendar", "calendarPaneCategory");
+}
diff --git a/comm/calendar/base/content/calendar-unifinder.js b/comm/calendar/base/content/calendar-unifinder.js
new file mode 100644
index 0000000000..00839540e3
--- /dev/null
+++ b/comm/calendar/base/content/calendar-unifinder.js
@@ -0,0 +1,988 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals calFilter, calFilter, getViewBox, openEventDialogForViewing,
+ modifyEventWithDialog, createEventWithDialog, currentView,
+ calendarController, editSelectedEvents, deleteSelectedEvents,
+ calendarUpdateDeleteCommand, getEventStatusString, goToggleToolbar */
+
+/* exported gCalendarEventTreeClicked, unifinderDoubleClick, unifinderKeyPress,
+ * focusSearch, ensureUnifinderLoaded, toggleUnifinder
+ */
+
+/**
+ * U N I F I N D E R
+ *
+ * This is a hacked in interface to the unifinder. We will need to
+ * improve this to make it usable in general.
+ *
+ * NOTE: Including this file will cause a load handler to be added to the
+ * window.
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+// Set this to true when the calendar event tree is clicked to allow for
+// multiple selection
+var gCalendarEventTreeClicked = false;
+
+// Store the start and enddate, because the providers can't be trusted when
+// dealing with all-day events. So we need to filter later. See bug 306157
+
+var gUnifinderNeedsRefresh = true;
+
+/**
+ * Checks if the unifinder is hidden
+ *
+ * @returns Returns true if the unifinder is hidden.
+ */
+function isUnifinderHidden() {
+ let tabmail = document.getElementById("tabmail");
+ return (
+ tabmail.currentTabInfo?.mode.type != "calendar" ||
+ document.getElementById("bottom-events-box").hidden
+ );
+}
+
+/**
+ * Returns the current filter applied to the unifinder.
+ *
+ * @returns The string name of the applied filter.
+ */
+function getCurrentUnifinderFilter() {
+ return document.getElementById("event-filter-menulist").selectedItem.value;
+}
+
+/**
+ * Observer for the calendar event data source. This keeps the unifinder
+ * display up to date when the calendar event data is changed
+ *
+ * @see calIObserver
+ * @see calICompositeObserver
+ */
+var unifinderObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calICompositeObserver", "nsIObserver", "calIObserver"]),
+
+ // calIObserver:
+ onStartBatch() {
+ gUnifinderNeedsRefresh = true;
+ },
+
+ onEndBatch() {
+ if (isUnifinderHidden()) {
+ // If the unifinder is hidden, all further item operations might
+ // produce invalid entries in the unifinder. From now on, ignore
+ // those operations and refresh as soon as the unifinder is shown
+ // again.
+ gUnifinderNeedsRefresh = true;
+ unifinderTreeView.clearItems();
+ } else {
+ refreshEventTree();
+ }
+ },
+
+ onLoad() {},
+
+ onAddItem(aItem) {
+ if (aItem.isEvent() && !gUnifinderNeedsRefresh) {
+ this.addItemToTree(aItem);
+ }
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.onDeleteItem(aOldItem);
+ this.onAddItem(aNewItem);
+ },
+
+ onDeleteItem(aDeletedItem) {
+ if (aDeletedItem.isEvent() && !gUnifinderNeedsRefresh) {
+ this.removeItemFromTree(aDeletedItem);
+ }
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {},
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ switch (aName) {
+ case "disabled":
+ refreshEventTree();
+ break;
+ }
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.onPropertyChanged(aCalendar, aName, null, null);
+ },
+
+ // calICompositeObserver:
+ onCalendarAdded(aAddedCalendar) {
+ if (!aAddedCalendar.getProperty("disabled")) {
+ if (isUnifinderHidden()) {
+ gUnifinderNeedsRefresh = true;
+ } else {
+ addItemsFromCalendar(aAddedCalendar, addItemsFromSingleCalendarInternal);
+ }
+ }
+ },
+
+ onCalendarRemoved(aDeletedCalendar) {
+ if (!aDeletedCalendar.getProperty("disabled")) {
+ removeItemsFromCalendar(aDeletedCalendar);
+ }
+ },
+
+ onDefaultCalendarChanged(aNewDefaultCalendar) {},
+
+ /**
+ * Add an unifinder item to the tree. It is safe to call these for any
+ * event. The functions will determine whether or not anything actually
+ * needs to be done to the tree.
+ *
+ * @returns aItem The item to add to the tree.
+ */
+ addItemToTree(aItem) {
+ let items;
+ let filter = unifinderTreeView.mFilter;
+
+ if (filter.startDate && filter.endDate) {
+ items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate);
+ } else {
+ items = [aItem];
+ }
+ unifinderTreeView.addItems(items.filter(filter.isItemInFilters, filter));
+ },
+
+ /**
+ * Remove an item from the unifinder tree. It is safe to call these for any
+ * event. The functions will determine whether or not anything actually
+ * needs to be done to the tree.
+ *
+ * @returns aItem The item to remove from the tree.
+ */
+ removeItemFromTree(aItem) {
+ let items;
+ let filter = unifinderTreeView.mFilter;
+ if (filter.startDate && filter.endDate && aItem.parentItem == aItem) {
+ items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate);
+ } else {
+ items = [aItem];
+ }
+ // XXX: do we really still need this, we are always checking it in the refreshInternal
+ unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter));
+ },
+
+ observe() {
+ refreshEventTree();
+ },
+};
+
+/**
+ * Called when calendar component is loaded to prepare the unifinder. This function is
+ * used to add observers, event listeners, etc.
+ */
+function prepareCalendarUnifinder() {
+ let unifinderTree = document.getElementById("unifinder-search-results-tree");
+ // Check that this is not the hidden window, which has no UI elements
+ if (!unifinderTree) {
+ return;
+ }
+
+ // Add pref observer
+ Services.prefs.addObserver("calendar.date.format", unifinderObserver);
+ Services.obs.addObserver(unifinderObserver, "defaultTimezoneChanged");
+
+ // set up our calendar event observer
+ let ccalendar = cal.view.getCompositeCalendar(window);
+ ccalendar.addObserver(unifinderObserver);
+
+ // Set up the filter
+ unifinderTreeView.mFilter = new calFilter();
+
+ // Set up the unifinder views.
+ unifinderTreeView.treeElement = unifinderTree;
+ unifinderTree.view = unifinderTreeView;
+
+ // Listen for changes in the selected day, so we can update if need be
+ let viewBox = getViewBox();
+ viewBox.addEventListener("dayselect", unifinderDaySelect);
+ viewBox.addEventListener("itemselect", unifinderItemSelect, true);
+
+ // Set up sortDirection and sortActive, in case it persisted
+ let sorted = unifinderTree.getAttribute("sort-active");
+ let sortDirection = unifinderTree.getAttribute("sort-direction");
+ if (!sortDirection || sortDirection == "undefined") {
+ sortDirection = "ascending";
+ }
+ let treecols = unifinderTree.getElementsByTagName("treecol");
+ for (let col of treecols) {
+ let content = col.getAttribute("itemproperty");
+ if (sorted && sorted.length > 0) {
+ if (sorted == content) {
+ unifinderTreeView.sortDirection = sortDirection;
+ unifinderTreeView.selectedColumn = col;
+ }
+ }
+ }
+
+ unifinderTreeView.ready = true;
+
+ // Display something upon first load. onLoad doesn't work properly for
+ // observers
+ if (!isUnifinderHidden()) {
+ refreshEventTree();
+ }
+}
+
+/**
+ * Called when the window is unloaded to clean up any observers and listeners
+ * added.
+ */
+function finishCalendarUnifinder() {
+ let ccalendar = cal.view.getCompositeCalendar(window);
+ ccalendar.removeObserver(unifinderObserver);
+
+ // Remove pref observer
+ Services.prefs.removeObserver("calendar.date.format", unifinderObserver);
+ Services.obs.removeObserver(unifinderObserver, "defaultTimezoneChanged");
+
+ let viewBox = getViewBox();
+ if (viewBox) {
+ viewBox.removeEventListener("dayselect", unifinderDaySelect);
+ viewBox.removeEventListener("itemselect", unifinderItemSelect, true);
+ }
+
+ // Persist the sort
+ let unifinderTree = document.getElementById("unifinder-search-results-tree");
+ let sorted = unifinderTreeView.selectedColumn;
+ if (sorted) {
+ unifinderTree.setAttribute("sort-active", sorted.getAttribute("itemproperty"));
+ unifinderTree.setAttribute("sort-direction", unifinderTreeView.sortDirection);
+ } else {
+ unifinderTree.removeAttribute("sort-active");
+ unifinderTree.removeAttribute("sort-direction");
+ }
+}
+
+/**
+ * Event listener for the view deck's dayselect event.
+ */
+function unifinderDaySelect() {
+ let filter = getCurrentUnifinderFilter();
+ if (filter == "current" || filter == "currentview") {
+ refreshEventTree();
+ }
+}
+
+/**
+ * Event listener for the view deck's itemselect event.
+ */
+function unifinderItemSelect(aEvent) {
+ unifinderTreeView.setSelectedItems(aEvent.detail);
+}
+
+/**
+ * Helper function to display event datetimes in the unifinder.
+ *
+ * @param aDatetime A calIDateTime object to format.
+ * @returns The passed date's formatted in the default timezone.
+ */
+function formatUnifinderEventDateTime(aDatetime) {
+ return cal.dtz.formatter.formatDateTime(aDatetime.getInTimezone(cal.dtz.defaultTimezone));
+}
+
+/**
+ * Handler function for double clicking the unifinder.
+ *
+ * @param event The DOM doubleclick event.
+ */
+function unifinderDoubleClick(event) {
+ // We only care about button 0 (left click) events
+ if (event.button != 0) {
+ return;
+ }
+
+ // find event by id
+ let calendarEvent = unifinderTreeView.getItemFromEvent(event);
+
+ if (calendarEvent) {
+ if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) {
+ modifyEventWithDialog(calendarEvent, true);
+ return;
+ }
+ openEventDialogForViewing(calendarEvent);
+ } else {
+ createEventWithDialog();
+ }
+}
+
+/**
+ * Handler function for selection in the unifinder.
+ *
+ * @param event The DOM selection event.
+ */
+function unifinderSelect(event) {
+ let tree = unifinderTreeView.treeElement;
+ if (!tree.view.selection || tree.view.selection.getRangeCount() == 0) {
+ return;
+ }
+
+ let selectedItems = [];
+ gCalendarEventTreeClicked = true;
+
+ // Get the selected events from the tree
+ let start = {};
+ let end = {};
+ let numRanges = tree.view.selection.getRangeCount();
+
+ for (let range = 0; range < numRanges; range++) {
+ tree.view.selection.getRangeAt(range, start, end);
+
+ for (let i = start.value; i <= end.value; i++) {
+ try {
+ selectedItems.push(unifinderTreeView.getItemAt(i));
+ } catch (e) {
+ cal.WARN("Error getting Event from row: " + e + "\n");
+ }
+ }
+ }
+
+ if (selectedItems.length == 1) {
+ // Go to the day of the selected item in the current view.
+ currentView().goToDay(selectedItems[0].startDate);
+ }
+
+ // Set up the selected items in the view. Pass in true, so we don't end
+ // up in a circular loop
+ currentView().setSelectedItems(selectedItems, true);
+ currentView().centerSelectedItems();
+ calendarController.onSelectionChanged({ detail: selectedItems });
+ document.getElementById("unifinder-search-results-tree").focus();
+}
+
+/**
+ * Handler function for keypress in the unifinder.
+ *
+ * @param aEvent The DOM Key event.
+ */
+function unifinderKeyPress(aEvent) {
+ switch (aEvent.key) {
+ case "Enter":
+ // Enter, edit the event
+ editSelectedEvents();
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ break;
+ case "Backspace":
+ case "Delete":
+ deleteSelectedEvents();
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ break;
+ }
+}
+
+/**
+ * Tree controller for unifinder search results
+ */
+var unifinderTreeView = {
+ QueryInterface: ChromeUtils.generateQI(["nsITreeView"]),
+
+ // Provide a default tree that holds all the functions used here to avoid
+ // cludgy if (this.tree) { this.tree.rowCountChanged(...); } constructs.
+ tree: {
+ rowCountChanged() {},
+ beginUpdateBatch() {},
+ endUpdateBatch() {},
+ invalidate() {},
+ },
+
+ ready: false,
+ treeElement: null,
+ doingSelection: false,
+ mFilter: null,
+ mSelectedColumn: null,
+ sortDirection: null,
+
+ /**
+ * Returns the currently selected column in the unifinder (used for sorting).
+ */
+ get selectedColumn() {
+ return this.mSelectedColumn;
+ },
+
+ /**
+ * Sets the currently selected column in the unifinder (used for sorting).
+ */
+ set selectedColumn(aCol) {
+ let tree = document.getElementById("unifinder-search-results-tree");
+ let treecols = tree.getElementsByTagName("treecol");
+ for (let col of treecols) {
+ if (col.getAttribute("sortActive")) {
+ col.removeAttribute("sortActive");
+ col.removeAttribute("sortDirection");
+ }
+ if (aCol.getAttribute("itemproperty") == col.getAttribute("itemproperty")) {
+ col.setAttribute("sortActive", "true");
+ col.setAttribute("sortDirection", this.sortDirection);
+ }
+ }
+ this.mSelectedColumn = aCol;
+ },
+
+ /**
+ * Event functions
+ */
+
+ eventArray: [],
+ eventIndexMap: {},
+
+ /**
+ * Add an item to the unifinder tree.
+ *
+ * @param aItemArray An array of items to add.
+ */
+ addItems(aItemArray) {
+ this.tree.beginUpdateBatch();
+
+ let bulkSort = aItemArray.length > this.eventArray.length;
+ if (bulkSort || !this.selectedColumn) {
+ // If there's more items being added than already exist,
+ // just append them and sort the whole list afterwards.
+ // If there's no selected column, don't sort at all.
+ let index = this.eventArray.length;
+ this.eventArray = this.eventArray.concat(aItemArray);
+ if (bulkSort && this.selectedColumn) {
+ this.sortItems();
+ } else {
+ this.tree.rowCountChanged(index, aItemArray.length);
+ }
+ } else {
+ // Otherwise, for each item to be added, work out its
+ // new position in the list and splice it in there.
+ // This saves a lot of function calls and calculation.
+ let modifier = this.sortDirection == "descending" ? -1 : 1;
+ let sortKey = unifinderTreeView.selectedColumn.getAttribute("itemproperty");
+ let comparer = cal.unifinder.sortEntryComparer(sortKey);
+
+ let values = this.eventArray.map(item => cal.unifinder.getItemSortKey(item, sortKey));
+ for (let item of aItemArray) {
+ let itemValue = cal.unifinder.getItemSortKey(item, sortKey);
+ let index = values.findIndex(value => comparer(value, itemValue, modifier) >= 0);
+ if (index < 0) {
+ this.eventArray.push(item);
+ this.tree.rowCountChanged(values.length, 1);
+ values.push(itemValue);
+ } else {
+ this.eventArray.splice(index, 0, item);
+ this.tree.rowCountChanged(index, 1);
+ values.splice(index, 0, itemValue);
+ }
+ }
+ }
+
+ this.tree.endUpdateBatch();
+ this.calculateIndexMap(true);
+ },
+
+ /**
+ * Remove items from the unifinder tree.
+ *
+ * @param aItemArray An array of items to remove.
+ */
+ removeItems(aItemArray) {
+ let indexesToRemove = [];
+ // Removing items is a bit tricky. Our getItemRow function takes the
+ // index from a cached map, so removing an item from the array will
+ // remove the wrong indexes. We don't want to just invalidate the map,
+ // since this will cause O(n^2) behavior. Instead, we keep a sorted
+ // array of the indexes to remove:
+ for (let item of aItemArray) {
+ let row = this.getItemRow(item);
+ if (row > -1) {
+ if (!indexesToRemove.length || row <= indexesToRemove[0]) {
+ indexesToRemove.unshift(row);
+ } else {
+ indexesToRemove.push(row);
+ }
+ }
+ }
+
+ // Then we go through the indexes to remove, and remove then from the
+ // array. We subtract one delta for each removed index to make sure the
+ // correct element is removed from the array and the correct
+ // notification is sent.
+ this.tree.beginUpdateBatch();
+ for (let delta = 0; delta < indexesToRemove.length; delta++) {
+ let index = indexesToRemove[delta];
+ this.eventArray.splice(index - delta, 1);
+ this.tree.rowCountChanged(index - delta, -1);
+ }
+ this.tree.endUpdateBatch();
+
+ // Finally, we recalculate the index map once. This way we end up with
+ // (given that Array.prototype.unshift doesn't loop but just prepends or
+ // maps memory smartly) O(3n) behavior. Lets hope its worth it.
+ this.calculateIndexMap(true);
+ },
+
+ /**
+ * Clear all items from the unifinder.
+ */
+ clearItems() {
+ let oldCount = this.eventArray.length;
+ this.eventArray = [];
+ if (this.tree) {
+ this.tree.rowCountChanged(0, -oldCount);
+ }
+ this.calculateIndexMap();
+ },
+
+ /**
+ * Sets the items that should be in the unifinder. This removes all items
+ * that were previously in the unifinder.
+ */
+ setItems(aItemArray) {
+ let oldCount = this.eventArray.length;
+ this.eventArray = aItemArray.slice(0);
+ this.tree.rowCountChanged(oldCount - 1, this.eventArray.length - oldCount);
+ this.sortItems();
+ },
+
+ /**
+ * Recalculate the index map that improves performance when accessing
+ * unifinder items. This is usually done automatically when adding/removing
+ * items.
+ *
+ * @param aDontInvalidate (optional) Don't invalidate the tree, i.e if
+ * you correctly issued rowCountChanged
+ * notices.
+ */
+ calculateIndexMap(aDontInvalidate) {
+ this.eventIndexMap = {};
+ for (let i = 0; i < this.eventArray.length; i++) {
+ this.eventIndexMap[this.eventArray[i].hashId] = i;
+ }
+
+ if (this.tree && !aDontInvalidate) {
+ this.tree.invalidate();
+ }
+ },
+
+ /**
+ * Sort the items in the unifinder by the currently selected column.
+ */
+ sortItems() {
+ if (this.selectedColumn) {
+ let modifier = this.sortDirection == "descending" ? -1 : 1;
+ let sortKey = unifinderTreeView.selectedColumn.getAttribute("itemproperty");
+
+ cal.unifinder.sortItems(this.eventArray, sortKey, modifier);
+ }
+ this.calculateIndexMap();
+ },
+
+ /**
+ * Get the index of the row associated with the passed item.
+ *
+ * @param item The item to search for.
+ * @returns The row index of the passed item.
+ */
+ getItemRow(item) {
+ if (this.eventIndexMap[item.hashId] === undefined) {
+ return -1;
+ }
+ return this.eventIndexMap[item.hashId];
+ },
+
+ /**
+ * Get the item at the given row index.
+ *
+ * @param item The row index to get the item for.
+ * @returns The item at the given row.
+ */
+ getItemAt(aRow) {
+ return this.eventArray[aRow];
+ },
+
+ /**
+ * Get the calendar item from the given DOM event
+ *
+ * @param event The DOM mouse event to get the item for.
+ * @returns The item under the mouse position.
+ */
+ getItemFromEvent(event) {
+ let row = this.tree.getRowAt(event.clientX, event.clientY);
+
+ if (row > -1) {
+ return this.getItemAt(row);
+ }
+ return null;
+ },
+
+ /**
+ * Change the selection in the unifinder.
+ *
+ * @param aItemArray An array of items to select.
+ */
+ setSelectedItems(aItemArray) {
+ if (
+ this.doingSelection ||
+ !this.tree ||
+ !this.tree.view ||
+ !("getSelectedItems" in currentView())
+ ) {
+ return;
+ }
+
+ this.doingSelection = true;
+
+ // If no items were passed, get the selected items from the view.
+ aItemArray = aItemArray || currentView().getSelectedItems();
+
+ calendarUpdateDeleteCommand(aItemArray);
+
+ /**
+ * The following is a brutal hack, caused by
+ * http://lxr.mozilla.org/mozilla1.0/source/layout/xul/base/src/tree/src/nsTreeSelection.cpp#555
+ * and described in bug 168211
+ * http://bugzilla.mozilla.org/show_bug.cgi?id=168211
+ * Do NOT remove anything in the next 3 lines, or the selection in the tree will not work.
+ */
+ this.treeElement.onselect = null;
+ this.treeElement.removeEventListener("select", unifinderSelect, true);
+ this.tree.view.selection.selectEventsSuppressed = true;
+ this.tree.view.selection.clearSelection();
+
+ if (aItemArray && aItemArray.length == 1) {
+ // If only one item is selected, scroll to it
+ let rowToScrollTo = this.getItemRow(aItemArray[0]);
+ if (rowToScrollTo > -1) {
+ this.tree.ensureRowIsVisible(rowToScrollTo);
+ this.tree.view.selection.select(rowToScrollTo);
+ }
+ } else if (aItemArray && aItemArray.length > 1) {
+ // If there is more than one item, just select them all.
+ for (let item of aItemArray) {
+ let row = this.getItemRow(item);
+ this.tree.view.selection.rangedSelect(row, row, true);
+ }
+ }
+
+ // This needs to be in a setTimeout
+ setTimeout(() => unifinderTreeView.resetAllowSelection(), 1);
+ },
+
+ /**
+ * Due to a selection issue described in bug 168211 this method is needed to
+ * re-add the selection listeners selection listeners.
+ */
+ resetAllowSelection() {
+ if (!this.tree) {
+ return;
+ }
+ /**
+ * Do not change anything in the following lines, they are needed as
+ * described in the selection observer above
+ */
+ this.doingSelection = false;
+
+ this.tree.view.selection.selectEventsSuppressed = false;
+ this.treeElement.addEventListener("select", unifinderSelect, true);
+ },
+
+ /**
+ * Tree View Implementation
+ *
+ * @see nsITreeView
+ */
+ get rowCount() {
+ return this.eventArray.length;
+ },
+
+ // TODO this code is currently identical to the task tree. We should create
+ // an itemTreeView that these tree views can inherit, that contains this
+ // code, and possibly other code related to sorting and storing items. See
+ // bug 432582 for more details.
+ getCellProperties(aRow, aCol) {
+ let rowProps = this.getRowProperties(aRow);
+ let colProps = this.getColumnProperties(aCol);
+ return rowProps + (rowProps && colProps ? " " : "") + colProps;
+ },
+ getRowProperties(aRow) {
+ let properties = [];
+ let item = this.eventArray[aRow];
+ if (item.priority > 0 && item.priority < 5) {
+ properties.push("highpriority");
+ } else if (item.priority > 5 && item.priority < 10) {
+ properties.push("lowpriority");
+ }
+
+ // Add calendar name atom
+ properties.push("calendar-" + cal.view.formatStringForCSSRule(item.calendar.name));
+
+ // Add item status atom
+ if (item.status) {
+ properties.push("status-" + item.status.toLowerCase());
+ }
+
+ // Alarm status atom
+ if (item.getAlarms().length) {
+ properties.push("alarm");
+ }
+
+ // Task categories
+ properties = properties.concat(item.getCategories().map(cal.view.formatStringForCSSRule));
+
+ return properties.join(" ");
+ },
+ getColumnProperties(aCol) {
+ return "";
+ },
+
+ isContainer() {
+ return false;
+ },
+
+ isContainerOpen(aRow) {
+ return false;
+ },
+
+ isContainerEmpty(aRow) {
+ return false;
+ },
+
+ isSeparator(aRow) {
+ return false;
+ },
+
+ isSorted(aRow) {
+ return false;
+ },
+
+ canDrop(aRow, aOrientation) {
+ return false;
+ },
+
+ drop(aRow, aOrientation) {},
+
+ getParentIndex(aRow) {
+ return -1;
+ },
+
+ hasNextSibling(aRow, aAfterIndex) {},
+
+ getLevel(aRow) {
+ return 0;
+ },
+
+ getImageSrc(aRow, aOrientation) {},
+
+ getCellValue(aRow, aCol) {
+ return null;
+ },
+
+ getCellText(row, column) {
+ let calendarEvent = this.eventArray[row];
+
+ switch (column.element.getAttribute("itemproperty")) {
+ case "title": {
+ return calendarEvent.title ? calendarEvent.title.replace(/\n/g, " ") : "";
+ }
+ case "startDate": {
+ return formatUnifinderEventDateTime(calendarEvent.startDate);
+ }
+ case "endDate": {
+ let eventEndDate = calendarEvent.endDate.clone();
+ // XXX reimplement
+ // let eventEndDate = getCurrentNextOrPreviousRecurrence(calendarEvent);
+ if (calendarEvent.startDate.isDate) {
+ // display enddate is ical enddate - 1
+ eventEndDate.day = eventEndDate.day - 1;
+ }
+ return formatUnifinderEventDateTime(eventEndDate);
+ }
+ case "categories": {
+ return calendarEvent.getCategories().join(", ");
+ }
+ case "location": {
+ return calendarEvent.getProperty("LOCATION");
+ }
+ case "status": {
+ return getEventStatusString(calendarEvent);
+ }
+ case "calendar": {
+ return calendarEvent.calendar.name;
+ }
+ default: {
+ return false;
+ }
+ }
+ },
+
+ setTree(tree) {
+ this.tree = tree;
+ },
+
+ toggleOpenState(aRow) {},
+
+ cycleHeader(col) {
+ if (!this.selectedColumn) {
+ this.sortDirection = "ascending";
+ } else if (!this.sortDirection || this.sortDirection == "descending") {
+ this.sortDirection = "ascending";
+ } else {
+ this.sortDirection = "descending";
+ }
+ this.selectedColumn = col.element;
+ this.sortItems();
+ },
+
+ isEditable(aRow, aCol) {
+ return false;
+ },
+
+ setCellValue(aRow, aCol, aValue) {},
+ setCellText(aRow, aCol, aValue) {},
+
+ outParameter: {}, // used to obtain dates during sort
+};
+
+/**
+ * Refresh the unifinder tree by getting items from the composite calendar and
+ * applying the current filter.
+ */
+function refreshEventTree() {
+ if (!unifinderTreeView.ready) {
+ return;
+ }
+
+ let field = document.getElementById("unifinder-search-field");
+ if (field) {
+ unifinderTreeView.mFilter.filterText = field.value;
+ }
+
+ addItemsFromCalendar(
+ cal.view.getCompositeCalendar(window),
+ addItemsFromCompositeCalendarInternal
+ );
+
+ gUnifinderNeedsRefresh = false;
+}
+
+/**
+ * EXTENSION_POINTS
+ * Filters the passed event array according to the currently applied filter.
+ * Afterwards, applies the items to the unifinder view.
+ *
+ * If you are implementing a new filter, you can overwrite this function and
+ * filter the items accordingly and afterwards call this function with the
+ * result.
+ *
+ * @param eventArray The array of items to be set in the unifinder.
+ */
+function addItemsFromCompositeCalendarInternal(eventArray) {
+ let newItems = eventArray.filter(
+ unifinderTreeView.mFilter.isItemInFilters,
+ unifinderTreeView.mFilter
+ );
+ unifinderTreeView.setItems(newItems);
+
+ // Select selected events in the tree. Not passing the argument gets the
+ // items from the view.
+ unifinderTreeView.setSelectedItems();
+}
+
+function addItemsFromSingleCalendarInternal(eventArray) {
+ let newItems = eventArray.filter(
+ unifinderTreeView.mFilter.isItemInFilters,
+ unifinderTreeView.mFilter
+ );
+ unifinderTreeView.setItems(unifinderTreeView.eventArray.concat(newItems));
+
+ // Select selected events in the tree. Not passing the argument gets the
+ // items from the view.
+ unifinderTreeView.setSelectedItems();
+}
+
+async function addItemsFromCalendar(aCalendar, aAddItemsInternalFunc) {
+ if (isUnifinderHidden()) {
+ // If the unifinder is hidden, don't refresh the events to reduce needed
+ // getItems calls.
+ return;
+ }
+
+ let filter = 0;
+
+ filter |= aCalendar.ITEM_FILTER_TYPE_EVENT;
+
+ // Not all xul might be there yet...
+ if (!document.getElementById("unifinder-search-field")) {
+ return;
+ }
+ unifinderTreeView.mFilter.applyFilter(getCurrentUnifinderFilter());
+
+ if (unifinderTreeView.mFilter.startDate && unifinderTreeView.mFilter.endDate) {
+ filter |= aCalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ }
+
+ let items = await aCalendar.getItemsAsArray(
+ filter,
+ 0,
+ unifinderTreeView.mFilter.startDate,
+ unifinderTreeView.mFilter.endDate
+ );
+
+ let refreshTreeInternalFunc = function () {
+ aAddItemsInternalFunc(items);
+ };
+ setTimeout(refreshTreeInternalFunc, 0);
+}
+
+function removeItemsFromCalendar(aCalendar) {
+ let filter = unifinderTreeView.mFilter;
+ let items = unifinderTreeView.eventArray.filter(item => item.calendar.id == aCalendar.id);
+
+ unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter));
+}
+
+/**
+ * Focuses the unifinder search field
+ */
+function focusSearch() {
+ document.getElementById("unifinder-search-field").focus();
+}
+
+/**
+ * The unifinder is hidden if the calendar tab is not selected. When the tab
+ * is selected, this function is called so that unifinder setup completes.
+ */
+function ensureUnifinderLoaded() {
+ if (!isUnifinderHidden() && gUnifinderNeedsRefresh) {
+ refreshEventTree();
+ }
+}
+
+/**
+ * Toggles the hidden state of the unifinder.
+ */
+function toggleUnifinder() {
+ // Toggle the elements
+ goToggleToolbar("bottom-events-box", "calendar_show_unifinder_command");
+ goToggleToolbar("calendar-view-splitter");
+ window.dispatchEvent(new CustomEvent("viewresize"));
+
+ unifinderTreeView.treeElement.view = unifinderTreeView;
+
+ // When the unifinder is hidden, refreshEventTree is not called. Make sure
+ // the event tree is refreshed now.
+ if (!isUnifinderHidden() && gUnifinderNeedsRefresh) {
+ refreshEventTree();
+ }
+
+ // Make sure the selection is correct
+ if (unifinderTreeView.doingSelection) {
+ unifinderTreeView.resetAllowSelection();
+ }
+ unifinderTreeView.setSelectedItems();
+}
diff --git a/comm/calendar/base/content/calendar-view-menu.inc.xhtml b/comm/calendar/base/content/calendar-view-menu.inc.xhtml
new file mode 100644
index 0000000000..166807f363
--- /dev/null
+++ b/comm/calendar/base/content/calendar-view-menu.inc.xhtml
@@ -0,0 +1,195 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<menuseparator id="calViewMenuSeparator"
+ class="hide-when-calendar-deactivated"/>
+<menu id="calTodayPaneMenu"
+ class="hide-when-calendar-deactivated"
+ label="&calendar.context.button.label;"
+ accesskey="&calendar.context.button.accesskey;">
+ <menupopup id="calTodayPaneMenuPopup">
+ <menuitem id="calShowTodayPane-2"
+ label="&todaypane.showTodayPane.label;"
+ accesskey="&todaypane.showTodayPane.accesskey;"
+ type="checkbox"
+ key="todaypanekey"
+ command="calendar_toggle_todaypane_command"/>
+ <menuseparator id="calSeparatorBeforeDisplayMiniday"/>
+ <menuitem id="calTodayPaneDisplayMiniday"
+ name="minidisplay"
+ value="miniday"
+ type="radio"
+ oncommand="TodayPane.displayMiniSection('miniday')"
+ label="&todaypane.showMiniday.label;"
+ accesskey="&todaypane.showMiniday.accesskey;"/>
+ <menuitem id="calTodayPaneDisplayMinimonth"
+ name="minidisplay"
+ value="minimonth"
+ type="radio"
+ oncommand="TodayPane.displayMiniSection('minimonth')"
+ label="&todaypane.showMinimonth.label;"
+ accesskey="&todaypane.showMinimonth.accesskey;"/>
+ <menuitem id="calTodayPaneDisplayNone"
+ name="minidisplay"
+ value="none"
+ type="radio"
+ oncommand="TodayPane.displayMiniSection('none')"
+ label="&todaypane.showNone.label;"
+ accesskey="&todaypane.showNone.accesskey;"/>
+ </menupopup>
+</menu>
+<menu id="calCalendarMenu"
+ class="hide-when-calendar-deactivated"
+ observes="calendar_in_foreground"
+ label="&lightning.menu.view.calendar.label;"
+ accesskey="&lightning.menu.view.calendar.accesskey;">
+ <menupopup id="calCalendarMenuPopup">
+ <menuitem id="calChangeViewDay"
+ label="&lightning.toolbar.day.label;"
+ accesskey="&lightning.toolbar.day.accesskey;"
+ type="radio"
+ name="calendarMenuViews"
+ command="calendar_day-view_command"/>
+ <menuitem id="calChangeViewWeek"
+ label="&lightning.toolbar.week.label;"
+ accesskey="&lightning.toolbar.week.accesskey;"
+ type="radio"
+ name="calendarMenuViews"
+ command="calendar_week-view_command"/>
+ <menuitem id="calChangeViewMultiweek"
+ label="&lightning.toolbar.multiweek.label;"
+ accesskey="&lightning.toolbar.multiweek.accesskey;"
+ type="radio"
+ name="calendarMenuViews"
+ command="calendar_multiweek-view_command"/>
+ <menuitem id="calChangeViewMonth"
+ label="&lightning.toolbar.month.label;"
+ accesskey="&lightning.toolbar.month.accesskey;"
+ type="radio"
+ name="calendarMenuViews"
+ command="calendar_month-view_command"/>
+ <menuseparator id="calBeforeCalendarViewSection"/>
+ <menu id="calCalendarPaneMenu"
+ label="&lightning.toolbar.calendarmenu.label;"
+ accesskey="&lightning.toolbar.calendarmenu.accesskey;">
+ <menupopup id="calCalendarPanePopup"
+ onpopupshowing="initViewCalendarPaneMenu()">
+ <menuitem id="calViewCalendarPane"
+ type="checkbox"
+ label="&lightning.toolbar.calendarpane.label;"
+ accesskey="&lightning.toolbar.calendarpane.accesskey;"
+ command="calendar_toggle_calendarsidebar_command"/>
+ <menuseparator id="calCalendarPaneMenuSeparator"/>
+ <menuitem id="calTasksViewMinimonth"
+ type="checkbox"
+ label="&calendar.tasks.view.minimonth.label;"
+ accesskey="&calendar.tasks.view.minimonth.accesskey;"
+ command="calendar_toggle_minimonthpane_command"/>
+ <menuitem id="calTasksViewCalendarlist"
+ type="checkbox"
+ label="&calendar.tasks.view.calendarlist.label;"
+ accesskey="&calendar.tasks.view.calendarlist.accesskey;"
+ command="calendar_toggle_calendarlist_command"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="calBeforeCurrentViewMenu"/>
+ <menu id="calCalendarCurrentViewMenu"
+ observes="calendar_mode_calendar"
+ label="&showCurrentView.label;"
+ accesskey="&showCurrentView.accesskey;">
+ <menupopup id="calCalendarCurrentViewMenuPopup">
+ <menuitem type="checkbox"
+ id="calWorkdaysOnlyMenuitem"
+ label="&calendar.onlyworkday.checkbox.label;"
+ accesskey="&calendar.onlyworkday.checkbox.accesskey;"
+ command="calendar_toggle_workdays_only_command"/>
+ <menuitem type="checkbox"
+ id="calTasksInViewMenuitem"
+ label="&calendar.displaytodos.checkbox.label;"
+ accesskey="&calendar.displaytodos.checkbox.accesskey;"
+ command="calendar_toggle_tasks_in_view_command"/>
+ <menuitem type="checkbox"
+ id="calShowCompletedInViewMenuItem"
+ label="&calendar.completedtasks.checkbox.label;"
+ accesskey="&calendar.completedtasks.checkbox.accesskey;"
+ command="calendar_toggle_show_completed_in_view_command"/>
+ <menuitem type="checkbox"
+ id="calViewRotated"
+ label="&calendar.orientation.label;"
+ accesskey="&calendar.orientation.accesskey;"
+ command="calendar_toggle_orientation_command"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+</menu>
+<menu id="calTasksMenu"
+ class="hide-when-calendar-deactivated"
+ observes="calendar_mode_task"
+ label="&lightning.menu.view.tasks.label;"
+ accesskey="&lightning.menu.view.tasks.accesskey;">
+ <menupopup id="calTasksMenuPopup">
+ <menuitem id="calTasksViewFilterTasks"
+ type="checkbox"
+ label="&calendar.tasks.view.filtertasks.label;"
+ accesskey="&calendar.tasks.view.filtertasks.accesskey;"
+ command="calendar_toggle_filter_command"/>
+ <menuseparator id="calTasksViewSeparator"/>
+ <menuitem id="calTasksViewFilterCurrent"
+ name="filtergroup"
+ value="throughcurrent"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.current.label;"
+ accesskey="&calendar.task.filter.current.accesskey;"/>
+ <menuitem id="calTasksViewFilterToday"
+ name="filtergroup"
+ value="throughtoday"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.today.label;"
+ accesskey="&calendar.task.filter.today.accesskey;"/>
+ <menuitem id="calTasksViewFilterNext7days"
+ name="filtergroup"
+ value="throughsevendays"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.next7days.label;"
+ accesskey="&calendar.task.filter.next7days.accesskey;"/>
+ <menuitem id="calTasksViewFilterNotstartedtasks"
+ name="filtergroup"
+ value="notstarted"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.notstarted.label;"
+ accesskey="&calendar.task.filter.notstarted.accesskey;"/>
+ <menuitem id="calTasksViewFilterOverdue"
+ name="filtergroup"
+ value="overdue"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.overdue.label;"
+ accesskey="&calendar.task.filter.overdue.accesskey;"/>
+ <menuitem id="calTasksViewFilterCompleted"
+ name="filtergroup"
+ type="radio"
+ value="completed"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.completed.label;"
+ accesskey="&calendar.task.filter.completed.accesskey;"/>
+ <menuitem id="calTasksViewFilterOpen"
+ name="filtergroup"
+ type="radio"
+ value="open"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.open.label;"
+ accesskey="&calendar.task.filter.open.accesskey;"/>
+ <menuitem id="calTasksViewFilterAll"
+ name="filtergroup"
+ value="all"
+ type="radio"
+ command="calendar_task_filter_command"
+ label="&calendar.task.filter.all.label;"
+ accesskey="&calendar.task.filter.all.accesskey;"/>
+ </menupopup>
+</menu>
diff --git a/comm/calendar/base/content/calendar-views-utils.js b/comm/calendar/base/content/calendar-views-utils.js
new file mode 100644
index 0000000000..b88f0e5954
--- /dev/null
+++ b/comm/calendar/base/content/calendar-views-utils.js
@@ -0,0 +1,617 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported switchToView, minimonthPick,
+ * observeViewDaySelect, toggleOrientation,
+ * toggleWorkdaysOnly, toggleTasksInView, toggleShowCompletedInView,
+ * goToDate, gLastShownCalendarView, deleteSelectedEvents,
+ * editSelectedEvents, selectAllEvents, calendarNavigationBar
+ */
+
+/* import-globals-from item-editing/calendar-item-editing.js */
+/* import-globals-from calendar-modes.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { countOccurrences } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+/**
+ * Controller for the views
+ *
+ * @see calIcalendarViewController
+ */
+var calendarViewController = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarViewController"]),
+
+ /**
+ * Creates a new event
+ *
+ * @see calICalendarViewController
+ */
+ createNewEvent(calendar, startTime, endTime, forceAllday) {
+ // if we're given both times, skip the dialog
+ if (startTime && endTime && !startTime.isDate && !endTime.isDate) {
+ let item = new CalEvent();
+ setDefaultItemValues(item, calendar, startTime, endTime);
+ doTransaction("add", item, item.calendar, null, null);
+ } else {
+ createEventWithDialog(calendar, startTime, null, null, null, forceAllday);
+ }
+ },
+
+ /**
+ * View the given occurrence.
+ *
+ * @param {calIItemBase} occurrence
+ * @see calICalendarViewController
+ */
+ viewOccurrence(occurrence) {
+ openEventDialogForViewing(occurrence);
+ },
+
+ /**
+ * Modifies the given occurrence
+ *
+ * @see calICalendarViewController
+ */
+ modifyOccurrence(occurrence, newStartTime, newEndTime, newTitle) {
+ // if modifying this item directly (e.g. just dragged to new time),
+ // then do so; otherwise pop up the dialog
+ if (newStartTime || newEndTime || newTitle) {
+ let instance = occurrence.clone();
+
+ if (newTitle) {
+ instance.title = newTitle;
+ }
+
+ // When we made the executive decision (in bug 352862) that
+ // dragging an occurrence of a recurring event would _only_ act
+ // upon _that_ occurrence, we removed a bunch of code from this
+ // function. If we ever revert that decision, check CVS history
+ // here to get that code back.
+
+ if (newStartTime || newEndTime) {
+ // Yay for variable names that make this next line look silly
+ if (instance.isEvent()) {
+ if (newStartTime && instance.startDate) {
+ instance.startDate = newStartTime;
+ }
+ if (newEndTime && instance.endDate) {
+ instance.endDate = newEndTime;
+ }
+ } else {
+ if (newStartTime && instance.entryDate) {
+ instance.entryDate = newStartTime;
+ }
+ if (newEndTime && instance.dueDate) {
+ instance.dueDate = newEndTime;
+ }
+ }
+ }
+
+ doTransaction("modify", instance, instance.calendar, occurrence, null);
+ } else {
+ modifyEventWithDialog(occurrence, true);
+ }
+ },
+
+ /**
+ * Deletes the given occurrences
+ *
+ * @see calICalendarViewController
+ */
+ deleteOccurrences(occurrencesArg, useParentItems, doNotConfirm, extResponseArg = null) {
+ if (!cal.window.promptDeleteItems(occurrencesArg)) {
+ return;
+ }
+ startBatchTransaction();
+ let recurringItems = {};
+ let extResponse = extResponseArg || { responseMode: Ci.calIItipItem.USER };
+
+ let getSavedItem = function (itemToDelete) {
+ // Get the parent item, saving it in our recurringItems object for
+ // later use.
+ let hashVal = itemToDelete.parentItem.hashId;
+ if (!recurringItems[hashVal]) {
+ recurringItems[hashVal] = {
+ oldItem: itemToDelete.parentItem,
+ newItem: itemToDelete.parentItem.clone(),
+ };
+ }
+ return recurringItems[hashVal];
+ };
+
+ // Make sure we are modifying a copy of aOccurrences, otherwise we will
+ // run into race conditions when the view's doRemoveItem removes the
+ // array elements while we are iterating through them. While we are at
+ // it, filter out any items that have readonly calendars, so that
+ // checking for one total item below also works out if all but one item
+ // are readonly.
+ let occurrences = occurrencesArg.filter(item => cal.acl.isCalendarWritable(item.calendar));
+
+ // we check how many occurrences the parent item has
+ let parents = new Map();
+ for (let occ of occurrences) {
+ if (!parents.has(occ.id)) {
+ parents.set(occ.id, countOccurrences(occ));
+ }
+ }
+
+ let promptUser = !doNotConfirm;
+ let previousResponse = 0;
+ for (let itemToDelete of occurrences) {
+ if (parents.get(itemToDelete.id) == -1) {
+ // we have scheduled the master item for deletion in a previous
+ // loop already
+ continue;
+ }
+ if (useParentItems || parents.get(itemToDelete.id) == 1 || previousResponse == 3) {
+ // Usually happens when ctrl-click is used. In that case we
+ // don't need to ask the user if he wants to delete an
+ // occurrence or not.
+ // if an occurrence is the only one of a series or the user
+ // decided so before, we delete the series, too.
+ itemToDelete = itemToDelete.parentItem;
+ parents.set(itemToDelete.id, -1);
+ } else if (promptUser) {
+ let [targetItem, , response] = promptOccurrenceModification(itemToDelete, false, "delete");
+ if (!response) {
+ // The user canceled the dialog, bail out
+ break;
+ }
+ itemToDelete = targetItem;
+
+ // if we have multiple items and the user decided already for one
+ // item whether to delete the occurrence or the entire series,
+ // we apply that decision also to subsequent items
+ previousResponse = response;
+ promptUser = false;
+ }
+
+ // Now some dirty work: Make sure more than one occurrence can be
+ // deleted by saving the recurring items and removing occurrences as
+ // they come in. If this is not an occurrence, we can go ahead and
+ // delete the whole item.
+ if (itemToDelete.parentItem.hashId == itemToDelete.hashId) {
+ doTransaction("delete", itemToDelete, itemToDelete.calendar, null, null, extResponse);
+ } else {
+ let savedItem = getSavedItem(itemToDelete);
+ savedItem.newItem.recurrenceInfo.removeOccurrenceAt(itemToDelete.recurrenceId);
+ // Dont start the transaction yet. Do so later, in case the
+ // parent item gets modified more than once.
+ }
+ }
+
+ // Now handle recurring events. This makes sure that all occurrences
+ // that have been passed are deleted.
+ for (let hashVal in recurringItems) {
+ let ritem = recurringItems[hashVal];
+ doTransaction(
+ "modify",
+ ritem.newItem,
+ ritem.newItem.calendar,
+ ritem.oldItem,
+ null,
+ extResponse
+ );
+ }
+ endBatchTransaction();
+ },
+};
+
+/**
+ * This function does the common steps to switch between views. Should be called
+ * from app-specific view switching functions
+ *
+ * @param viewType The type of view to select.
+ */
+function switchToView(viewType) {
+ let viewBox = getViewBox();
+ let selectedDay;
+ let currentSelection = [];
+
+ // Set up the view commands
+ let views = viewBox.children;
+ for (let i = 0; i < views.length; i++) {
+ let view = views[i];
+ let commandId = "calendar_" + view.id + "_command";
+ let command = document.getElementById(commandId);
+ if (view.id == viewType + "-view") {
+ command.setAttribute("checked", "true");
+ } else {
+ command.removeAttribute("checked");
+ }
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("previousViewButton"),
+ `calendar-nav-button-prev-tooltip-${viewType}`
+ );
+ document.l10n.setAttributes(
+ document.getElementById("nextViewButton"),
+ `calendar-nav-button-next-tooltip-${viewType}`
+ );
+ document.l10n.setAttributes(
+ document.getElementById("calendar-view-context-menu-previous"),
+ `calendar-context-menu-previous-${viewType}`
+ );
+ document.l10n.setAttributes(
+ document.getElementById("calendar-view-context-menu-next"),
+ `calendar-context-menu-next-${viewType}`
+ );
+
+ // These are hidden until the calendar is loaded.
+ for (let node of document.querySelectorAll(".hide-before-calendar-loaded")) {
+ node.removeAttribute("hidden");
+ }
+
+ // Anyone wanting to plug in a view needs to follow this naming scheme
+ let view = document.getElementById(viewType + "-view");
+ let oldView = currentView();
+ if (oldView?.isActive) {
+ if (oldView == view) {
+ // Not actually changing view, there's nothing else to do.
+ return;
+ }
+
+ selectedDay = oldView.selectedDay;
+ currentSelection = oldView.getSelectedItems();
+ oldView.deactivate();
+ }
+
+ if (!selectedDay) {
+ selectedDay = cal.dtz.now();
+ }
+ for (let i = 0; i < viewBox.children.length; i++) {
+ if (view.id == viewBox.children[i].id) {
+ viewBox.children[i].hidden = false;
+ viewBox.setAttribute("selectedIndex", i);
+ } else {
+ viewBox.children[i].hidden = true;
+ }
+ }
+
+ view.ensureInitialized();
+ if (!view.controller) {
+ view.timezone = cal.dtz.defaultTimezone;
+ view.controller = calendarViewController;
+ }
+
+ view.goToDay(selectedDay);
+ view.setSelectedItems(currentSelection);
+
+ view.onResize(view);
+ view.activate();
+}
+
+/**
+ * Returns the calendar view box element.
+ *
+ * @returns The view-box element.
+ */
+function getViewBox() {
+ return document.getElementById("view-box");
+}
+
+/**
+ * Returns the currently selected calendar view.
+ *
+ * @returns The selected calendar view
+ */
+function currentView() {
+ for (let element of getViewBox().children) {
+ if (!element.hidden) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * Handler function to set the selected day in the minimonth to the currently
+ * selected day in the current view.
+ *
+ * @param event The "dayselect" event emitted from the views.
+ *
+ */
+function observeViewDaySelect(event) {
+ let date = event.detail;
+ let jsDate = new Date(date.year, date.month, date.day);
+
+ // for the month and multiweek view find the main month,
+ // which is the month with the most visible days in the view;
+ // note, that the main date is the first day of the main month
+ let jsMainDate;
+ if (!event.target.supportsDisjointDates) {
+ let mainDate = null;
+ let maxVisibleDays = 0;
+ let startDay = currentView().startDay;
+ let endDay = currentView().endDay;
+ let firstMonth = startDay.startOfMonth;
+ let lastMonth = endDay.startOfMonth;
+ for (let month = firstMonth.clone(); month.compare(lastMonth) <= 0; month.month += 1) {
+ let visibleDays = 0;
+ if (month.compare(firstMonth) == 0) {
+ visibleDays = startDay.endOfMonth.day - startDay.day + 1;
+ } else if (month.compare(lastMonth) == 0) {
+ visibleDays = endDay.day;
+ } else {
+ visibleDays = month.endOfMonth.day;
+ }
+ if (visibleDays > maxVisibleDays) {
+ mainDate = month.clone();
+ maxVisibleDays = visibleDays;
+ }
+ }
+ jsMainDate = new Date(mainDate.year, mainDate.month, mainDate.day);
+ }
+
+ getMinimonth().selectDate(jsDate, jsMainDate);
+ currentView().focus();
+}
+
+/**
+ * Shows the given date in the current view, if in calendar mode.
+ *
+ * @param aNewDate The new date as a JSDate.
+ */
+function minimonthPick(aNewDate) {
+ if (gCurrentMode == "calendar" || gCurrentMode == "task") {
+ let cdt = cal.dtz.jsDateToDateTime(aNewDate, currentView().timezone);
+ cdt.isDate = true;
+ currentView().goToDay(cdt);
+
+ // update date filter for task tree
+ let tree = document.getElementById("calendar-task-tree");
+ tree.updateFilter();
+ }
+}
+
+/**
+ * Provides a neutral way to get the minimonth.
+ *
+ * @returns The XUL minimonth element.
+ */
+function getMinimonth() {
+ return document.getElementById("calMinimonth");
+}
+
+/**
+ * Update the view orientation based on the checked state of the command
+ */
+function toggleOrientation() {
+ let cmd = document.getElementById("calendar_toggle_orientation_command");
+ let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true";
+ cmd.setAttribute("checked", newValue);
+
+ for (let view of getViewBox().children) {
+ view.rotated = newValue == "true";
+ }
+
+ // orientation refreshes automatically
+}
+
+/**
+ * Toggle the workdays only checkbox and refresh the current view
+ *
+ * XXX We shouldn't need to refresh the view just to toggle the workdays. This
+ * should happen automatically.
+ */
+function toggleWorkdaysOnly() {
+ let cmd = document.getElementById("calendar_toggle_workdays_only_command");
+ let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true";
+ cmd.setAttribute("checked", newValue);
+
+ for (let view of getViewBox().children) {
+ view.workdaysOnly = newValue == "true";
+ }
+
+ // Refresh the current view
+ currentView().goToDay();
+}
+
+/**
+ * Toggle the tasks in view checkbox and refresh the current view
+ */
+function toggleTasksInView() {
+ let cmd = document.getElementById("calendar_toggle_tasks_in_view_command");
+ let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true";
+ cmd.setAttribute("checked", newValue);
+
+ for (let view of getViewBox().children) {
+ view.tasksInView = newValue == "true";
+ }
+
+ // Refresh the current view
+ currentView().goToDay();
+}
+
+/**
+ * Toggle the show completed in view checkbox and refresh the current view
+ */
+function toggleShowCompletedInView() {
+ let cmd = document.getElementById("calendar_toggle_show_completed_in_view_command");
+ let newValue = cmd.getAttribute("checked") == "true" ? "false" : "true";
+ cmd.setAttribute("checked", newValue);
+
+ for (let view of getViewBox().children) {
+ view.showCompleted = newValue == "true";
+ }
+
+ // Refresh the current view
+ currentView().goToDay();
+}
+
+/**
+ * Open the calendar layout options menu popup.
+ *
+ * @param {Event} event - The click DOMEvent.
+ */
+function showCalControlBarMenuPopup(event) {
+ let moreContext = document.getElementById("calControlBarMenuPopup");
+ moreContext.openPopup(event.target, { triggerEvent: event });
+}
+
+/**
+ * Provides a neutral way to go to the current day in the views and minimonth.
+ *
+ * @param date The date to go.
+ */
+function goToDate(date) {
+ getMinimonth().value = cal.dtz.dateTimeToJsDate(date);
+ currentView().goToDay(date);
+}
+
+var gLastShownCalendarView = {
+ _lastView: null,
+
+ /**
+ * Returns the calendar view that was selected before restart, or the current
+ * calendar view if it has already been set in this session.
+ *
+ * @returns {string} The last calendar view.
+ */
+ get() {
+ if (!this._lastView) {
+ if (Services.xulStore.hasValue(document.location.href, "view-box", "selectedIndex")) {
+ let viewBox = getViewBox();
+ let selectedIndex = Services.xulStore.getValue(
+ document.location.href,
+ "view-box",
+ "selectedIndex"
+ );
+ for (let i = 0; i < viewBox.children.length; i++) {
+ viewBox.children[i].hidden = selectedIndex != i;
+ }
+ let viewNode = viewBox.children[selectedIndex];
+ this._lastView = viewNode.id.replace(/-view/, "");
+ document
+ .querySelector(`.calview-toggle-item[aria-controls="${viewNode.id}"]`)
+ ?.setAttribute("aria-selected", true);
+ } else {
+ // No deck item was selected beforehand, default to week view.
+ this._lastView = "week";
+ document
+ .querySelector(`.calview-toggle-item[aria-controls="week-view"]`)
+ ?.setAttribute("aria-selected", true);
+ }
+ }
+ return this._lastView;
+ },
+
+ set(view) {
+ this._lastView = view;
+ },
+};
+
+/**
+ * Deletes items currently selected in the view and clears selection.
+ */
+function deleteSelectedEvents() {
+ let selectedItems = currentView().getSelectedItems();
+ calendarViewController.deleteOccurrences(selectedItems, false, false);
+ // clear selection
+ currentView().setSelectedItems([], true);
+}
+
+/**
+ * Open the items currently selected in the view.
+ */
+function viewSelectedEvents() {
+ let items = currentView().getSelectedItems();
+ if (items.length >= 1) {
+ openEventDialogForViewing(items[0]);
+ }
+}
+
+/**
+ * Edit the items currently selected in the view with the event dialog.
+ */
+function editSelectedEvents() {
+ let selectedItems = currentView().getSelectedItems();
+ if (selectedItems && selectedItems.length >= 1) {
+ modifyEventWithDialog(selectedItems[0], true);
+ }
+}
+
+/**
+ * Select all events from all calendars. Use with care.
+ */
+async function selectAllEvents() {
+ let composite = cal.view.getCompositeCalendar(window);
+ let filter = composite.ITEM_FILTER_CLASS_OCCURRENCES;
+
+ if (currentView().tasksInView) {
+ filter |= composite.ITEM_FILTER_TYPE_ALL;
+ } else {
+ filter |= composite.ITEM_FILTER_TYPE_EVENT;
+ }
+ if (currentView().showCompleted) {
+ filter |= composite.ITEM_FILTER_COMPLETED_ALL;
+ } else {
+ filter |= composite.ITEM_FILTER_COMPLETED_NO;
+ }
+
+ // Need to move one day out to get all events
+ let end = currentView().endDay.clone();
+ end.day += 1;
+
+ let items = await composite.getItemsAsArray(filter, 0, currentView().startDay, end);
+ currentView().setSelectedItems(items, false);
+}
+
+var calendarNavigationBar = {
+ setDateRange(startDate, endDate) {
+ let docTitle = "";
+ if (startDate) {
+ let intervalLabel = document.getElementById("intervalDescription");
+ let firstWeekNo = cal.weekInfoService.getWeekTitle(startDate);
+ let secondWeekNo = firstWeekNo;
+ let weekLabel = document.getElementById("calendarWeek");
+ if (startDate.nativeTime == endDate.nativeTime) {
+ intervalLabel.textContent = cal.dtz.formatter.formatDate(startDate);
+ } else {
+ intervalLabel.textContent = currentView().getRangeDescription();
+ secondWeekNo = cal.weekInfoService.getWeekTitle(endDate);
+ }
+ if (secondWeekNo == firstWeekNo) {
+ weekLabel.textContent = cal.l10n.getCalString("singleShortCalendarWeek", [firstWeekNo]);
+ weekLabel.tooltipText = cal.l10n.getCalString("singleLongCalendarWeek", [firstWeekNo]);
+ } else {
+ weekLabel.textContent = cal.l10n.getCalString("severalShortCalendarWeeks", [
+ firstWeekNo,
+ secondWeekNo,
+ ]);
+ weekLabel.tooltipText = cal.l10n.getCalString("severalLongCalendarWeeks", [
+ firstWeekNo,
+ secondWeekNo,
+ ]);
+ }
+ docTitle = intervalLabel.textContent;
+ }
+
+ if (gCurrentMode == "calendar") {
+ document.title =
+ (docTitle ? docTitle + " - " : "") +
+ cal.l10n.getAnyString("branding", "brand", "brandFullName");
+ }
+ },
+};
+
+var timezoneObserver = {
+ observe() {
+ let minimonth = getMinimonth();
+ minimonth.update(minimonth.value);
+ },
+};
+Services.obs.addObserver(timezoneObserver, "defaultTimezoneChanged");
+window.addEventListener("unload", () => {
+ Services.obs.removeObserver(timezoneObserver, "defaultTimezoneChanged");
+});
diff --git a/comm/calendar/base/content/calendar-views.js b/comm/calendar/base/content/calendar-views.js
new file mode 100644
index 0000000000..6d5e7faa3f
--- /dev/null
+++ b/comm/calendar/base/content/calendar-views.js
@@ -0,0 +1,286 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements, MozXULElement */
+
+"use strict";
+
+// The calendar view class hierarchy.
+//
+// CalendarFilteredViewMixin
+// |
+// CalendarBaseView
+// / \
+// CalendarMultidayBaseView CalendarMonthBaseView
+// / \ / \
+// CalendarDayView CalendarWeekView CalendarMultiweekView CalendarMonthView
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ /**
+ * The calendar view for viewing a single day.
+ *
+ * @augments {MozElements.CalendarMultidayBaseView}
+ * @implements {calICalendarView}
+ */
+ class CalendarDayView extends MozElements.CalendarMultidayBaseView {
+ get observerID() {
+ return "day-view-observer";
+ }
+
+ get supportsWorkdaysOnly() {
+ return false;
+ }
+
+ goToDay(date) {
+ if (!date) {
+ this.relayout();
+ return;
+ }
+ const timezoneDate = date.getInTimezone(this.timezone);
+ this.setDateRange(timezoneDate, timezoneDate);
+ this.selectedDay = timezoneDate;
+ }
+
+ moveView(number) {
+ if (number) {
+ const currentDay = this.startDay.clone();
+ currentDay.day += number;
+ this.goToDay(currentDay);
+ } else {
+ this.goToDay(cal.dtz.now());
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(CalendarDayView, [Ci.calICalendarView]);
+
+ customElements.define("calendar-day-view", CalendarDayView);
+
+ /**
+ * The calendar view for viewing a single week.
+ *
+ * @augments {MozElements.CalendarMultidayBaseView}
+ * @implements {calICalendarView}
+ */
+ class CalendarWeekView extends MozElements.CalendarMultidayBaseView {
+ get observerID() {
+ return "week-view-observer";
+ }
+
+ goToDay(date) {
+ this.displayDaysOff = !this.mWorkdaysOnly;
+
+ if (!date) {
+ this.relayout();
+ return;
+ }
+ date = date.getInTimezone(this.timezone);
+ const weekStart = cal.weekInfoService.getStartOfWeek(date);
+ const weekEnd = weekStart.clone();
+ weekEnd.day += 6;
+ this.setDateRange(weekStart, weekEnd);
+ this.selectedDay = date;
+ }
+
+ moveView(number) {
+ if (number) {
+ const date = this.selectedDay.clone();
+ date.day += 7 * number;
+ this.goToDay(date);
+ } else {
+ this.goToDay(cal.dtz.now());
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(CalendarWeekView, [Ci.calICalendarView]);
+
+ customElements.define("calendar-week-view", CalendarWeekView);
+
+ /**
+ * The calendar view for viewing multiple weeks.
+ *
+ * @augments {MozElements.CalendarMonthBaseView}
+ * @implements {calICalendarView}
+ */
+ class CalendarMultiweekView extends MozElements.CalendarMonthBaseView {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true via super.connectedCallback.
+ super.connectedCallback();
+
+ this.mWeeksInView = Services.prefs.getIntPref("calendar.weeks.inview", 4);
+ }
+
+ set weeksInView(weeks) {
+ this.mWeeksInView = weeks;
+ Services.prefs.setIntPref("calendar.weeks.inview", Number(weeks));
+ this.refreshView();
+ }
+
+ get weeksInView() {
+ return this.mWeeksInView;
+ }
+
+ get supportsZoom() {
+ return true;
+ }
+
+ get observerID() {
+ return "multiweek-view-observer";
+ }
+
+ zoomIn(level = 1) {
+ const visibleWeeks = level + Services.prefs.getIntPref("calendar.weeks.inview", 4);
+
+ Services.prefs.setIntPref("calendar.weeks.inview", Math.min(visibleWeeks, 6));
+ }
+
+ zoomOut(level = 1) {
+ const visibleWeeks = level + Services.prefs.getIntPref("calendar.weeks.inview", 4);
+
+ Services.prefs.setIntPref("calendar.weeks.inview", Math.max(visibleWeeks, 2));
+ }
+
+ zoomReset() {
+ Services.prefs.setIntPref("calendar.view.visiblehours", 4);
+ }
+
+ goToDay(date) {
+ this.showFullMonth = false;
+ this.displayDaysOff = !this.mWorkdaysOnly;
+
+ // If date is null it means that only a refresh is needed
+ // without changing the start and end of the view.
+ if (date) {
+ date = date.getInTimezone(this.timezone);
+
+ // Get the first date that should be shown. This is the
+ // start of the week of the day that we're centering around
+ // adjusted for the day the week starts on and the number
+ // of previous weeks we're supposed to display.
+ const dayStart = cal.weekInfoService.getStartOfWeek(date);
+ dayStart.day -= 7 * Services.prefs.getIntPref("calendar.previousweeks.inview", 0);
+
+ // The last day we're supposed to show.
+ const dayEnd = dayStart.clone();
+ dayEnd.day += 7 * this.mWeeksInView - 1;
+ this.setDateRange(dayStart, dayEnd);
+ this.selectedDay = date;
+ } else {
+ this.relayout();
+ }
+ }
+
+ moveView(weeksToMove) {
+ if (weeksToMove) {
+ const date = this.startDay.clone();
+ const savedSelectedDay = this.selectedDay.clone();
+ // weeksToMove only corresponds to the number of weeks to move
+ // make sure to compensate for previous weeks in view too.
+ const prevWeeks = Services.prefs.getIntPref("calendar.previousweeks.inview", 4);
+ date.day += 7 * (weeksToMove + prevWeeks);
+ this.goToDay(date);
+ savedSelectedDay.day += 7 * weeksToMove;
+ this.selectedDay = savedSelectedDay;
+ } else {
+ const date = cal.dtz.now();
+ this.goToDay(date);
+ this.selectedDay = date;
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(CalendarMultiweekView, [Ci.calICalendarView]);
+
+ customElements.define("calendar-multiweek-view", CalendarMultiweekView);
+
+ /**
+ * The calendar view for viewing a single month.
+ *
+ * @augments {MozElements.CalendarMonthBaseView}
+ * @implements {calICalendarView}
+ */
+ class CalendarMonthView extends MozElements.CalendarMonthBaseView {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ // this.hasConnected is set to true via super.connectedCallback.
+ super.connectedCallback();
+ }
+
+ // calICalendarView Methods and Properties.
+
+ get observerID() {
+ return "month-view-observer";
+ }
+
+ goToDay(date) {
+ this.displayDaysOff = !this.mWorkdaysOnly;
+
+ this.showDate(date ? date.getInTimezone(this.timezone) : null);
+ if (!date) {
+ this.setDateBoxRelations();
+ }
+ }
+
+ getRangeDescription() {
+ const monthName = cal.l10n.formatMonth(
+ this.rangeStartDate.month + 1,
+ "calendar",
+ "monthInYear"
+ );
+
+ return cal.l10n.getCalString("monthInYear", [monthName, this.rangeStartDate.year]);
+ }
+
+ moveView(number) {
+ const dates = this.getDateList();
+ this.displayDaysOff = !this.mWorkdaysOnly;
+
+ if (number) {
+ // The first few dates in this list are likely in the month
+ // prior to the one actually being shown (since the month
+ // probably doesn't start on a Sunday). The 7th item must
+ // be in correct month though.
+ const date = dates[6].clone();
+
+ date.month += number;
+ // Store selected day before we move.
+ const oldSelectedDay = this.selectedDay;
+
+ this.goToDay(date);
+
+ // Most of the time we want to select the date with the
+ // same day number in the next month.
+ const newSelectedDay = oldSelectedDay.clone();
+ newSelectedDay.month += number;
+
+ // Correct for accidental rollover into the next month.
+ if ((newSelectedDay.month - number + 12) % 12 != oldSelectedDay.month) {
+ newSelectedDay.month -= 1;
+ newSelectedDay.day = newSelectedDay.endOfMonth.day;
+ }
+
+ this.selectedDay = newSelectedDay;
+ } else {
+ const date = cal.dtz.now();
+ this.goToDay(date);
+ this.selectedDay = date;
+ }
+ }
+
+ // End calICalendarView Methods and Properties.
+ }
+
+ MozXULElement.implementCustomInterface(CalendarMonthView, [Ci.calICalendarView]);
+
+ customElements.define("calendar-month-view", CalendarMonthView);
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js
new file mode 100644
index 0000000000..8f384960ba
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.js
@@ -0,0 +1,484 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported onDismissAllAlarms, setupWindow, finishWindow, addWidgetFor,
+ * removeWidgetFor, onSelectAlarm, ensureCalendarVisible
+ */
+
+/* global MozElements */
+
+/* import-globals-from ../item-editing/calendar-item-editing.js */
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+window.addEventListener("load", event => {
+ setupWindow();
+ window.arguments[0].wrappedJSObject.window_onLoad();
+});
+window.addEventListener("unload", finishWindow);
+window.addEventListener("focus", onFocusWindow);
+window.addEventListener("keypress", event => {
+ if (event.key == "Escape") {
+ window.close();
+ }
+});
+
+var gShutdownDetected = false;
+
+/**
+ * Detects the "mail-unloading-messenger" notification to prevent snoozing items
+ * as well as closes this window when the main window is closed. Not doing so can
+ * cause data loss with CalStorageCalendar.
+ */
+var gShutdownObserver = {
+ observe() {
+ let windows = Array.from(Services.wm.getEnumerator("mail:3pane"));
+ if (windows.filter(win => !win.closed).length == 0) {
+ gShutdownDetected = true;
+ window.close();
+ }
+ },
+};
+
+addEventListener("DOMContentLoaded", () => {
+ document.getElementById("alarm-snooze-all-popup").addEventListener("snooze", event => {
+ snoozeAllItems(event.detail);
+ });
+});
+
+XPCOMUtils.defineLazyGetter(this, "gReadOnlyNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ document.getElementById("readonly-notification").append(element);
+ });
+});
+
+/**
+ * Helper function to get the alarm service and cache it.
+ *
+ * @returns The alarm service component
+ */
+function getAlarmService() {
+ if (!("mAlarmService" in window)) {
+ window.mAlarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(
+ Ci.calIAlarmService
+ );
+ }
+ return window.mAlarmService;
+}
+
+/**
+ * Event handler for the 'snooze' event. Snoozes the given alarm by the given
+ * number of minutes using the alarm service.
+ *
+ * @param event The snooze event
+ */
+function onSnoozeAlarm(event) {
+ // reschedule alarm:
+ let duration = getDuration(event.detail);
+ if (aboveSnoozeLimit(duration)) {
+ // we prevent snoozing too far if the alarm wouldn't be displayed
+ return;
+ }
+ getAlarmService().snoozeAlarm(event.target.item, event.target.alarm, duration);
+}
+
+/**
+ * Event handler for the 'dismiss' event. Dismisses the given alarm using the
+ * alarm service.
+ *
+ * @param event The snooze event
+ */
+function onDismissAlarm(event) {
+ getAlarmService().dismissAlarm(event.target.item, event.target.alarm);
+}
+
+/**
+ * Called to dismiss all alarms in the alarm window.
+ */
+function onDismissAllAlarms() {
+ // removes widgets on the fly:
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ let parentItems = {};
+ let widgets = [];
+
+ // Make a copy of the child nodes as they get modified live
+ for (let node of alarmRichlist.children) {
+ // Check if the node is a valid alarm and is still part of DOM
+ if (
+ node.parentNode &&
+ node.item &&
+ node.alarm &&
+ !(node.item.parentItem.hashId in parentItems)
+ ) {
+ // We only need to acknowledge one occurrence for repeating items
+ parentItems[node.item.parentItem.hashId] = node.item.parentItem;
+ widgets.push({ item: node.item, alarm: node.alarm });
+ }
+ }
+ for (let widget of widgets) {
+ getAlarmService().dismissAlarm(widget.item, widget.alarm);
+ }
+}
+
+/**
+ * Event handler fired when the alarm widget's "Details..." label was clicked.
+ * Open the event dialog in the most recent Thunderbird window.
+ *
+ * @param event The itemdetails event.
+ */
+function onItemDetails(event) {
+ // We want this to happen in a calendar window if possible. Otherwise open
+ // it using our window.
+ let calWindow = cal.window.getCalendarWindow();
+ if (calWindow) {
+ calWindow.modifyEventWithDialog(event.target.item, true);
+ } else {
+ modifyEventWithDialog(event.target.item, true);
+ }
+}
+
+/**
+ * Sets up the alarm dialog, initializing the default snooze length and setting
+ * up the relative date update timer.
+ */
+var gRelativeDateUpdateTimer;
+function setupWindow() {
+ // We want to update when we are at 0 seconds past the minute. To do so, use
+ // setTimeout to wait until we are there, then setInterval to execute every
+ // minute. Since setInterval is not totally exact, we may run into problems
+ // here. I hope not!
+ let current = new Date();
+
+ let timeout = (60 - current.getSeconds()) * 1000;
+ gRelativeDateUpdateTimer = setTimeout(() => {
+ updateRelativeDates();
+ gRelativeDateUpdateTimer = setInterval(updateRelativeDates, 60 * 1000);
+ }, timeout);
+
+ // Configure the shutdown observer.
+ Services.obs.addObserver(gShutdownObserver, "mail-unloading-messenger");
+
+ // Give focus to the alarm richlist after onload completes. See bug 103197
+ setTimeout(onFocusWindow, 0);
+}
+
+/**
+ * Unload function for the alarm dialog. If applicable, snooze the remaining
+ * alarms and clean up the relative date update timer.
+ */
+function finishWindow() {
+ Services.obs.removeObserver(gShutdownObserver, "mail-unloading-messenger");
+
+ if (gShutdownDetected) {
+ return;
+ }
+
+ let alarmRichlist = document.getElementById("alarm-richlist");
+
+ if (alarmRichlist.children.length > 0) {
+ // If there are still items, the window wasn't closed using dismiss
+ // all/snooze all. This can happen when the closer is clicked or escape
+ // is pressed. Snooze all remaining items using the default snooze
+ // property.
+ let snoozePref = Services.prefs.getIntPref("calendar.alarms.defaultsnoozelength", 0);
+ if (snoozePref <= 0) {
+ snoozePref = 5;
+ }
+
+ snoozeAllItems(snoozePref);
+ }
+
+ // Stop updating the relative time
+ clearTimeout(gRelativeDateUpdateTimer);
+}
+
+/**
+ * Set up the focused element. If no element is focused, then switch to the
+ * richlist.
+ */
+function onFocusWindow() {
+ if (!document.commandDispatcher.focusedElement) {
+ document.getElementById("alarm-richlist").focus();
+ }
+}
+
+/**
+ * Timer callback to update all relative date labels
+ */
+function updateRelativeDates() {
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ for (let node of alarmRichlist.children) {
+ if (node.item && node.alarm) {
+ node.updateRelativeDateLabel();
+ }
+ }
+}
+
+/**
+ * Function to snooze all alarms the given number of minutes.
+ *
+ * @param aDurationMinutes The duration in minutes
+ */
+function snoozeAllItems(aDurationMinutes) {
+ let duration = getDuration(aDurationMinutes);
+ if (aboveSnoozeLimit(duration)) {
+ // we prevent snoozing too far if the alarm wouldn't be displayed
+ return;
+ }
+
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ let parentItems = {};
+
+ // Make a copy of the child nodes as they get modified live
+ for (let node of alarmRichlist.children) {
+ // Check if the node is a valid alarm and is still part of DOM
+ if (
+ node.parentNode &&
+ node.item &&
+ node.alarm &&
+ cal.acl.isCalendarWritable(node.item.calendar) &&
+ cal.acl.userCanModifyItem(node.item) &&
+ !(node.item.parentItem.hashId in parentItems)
+ ) {
+ // We only need to acknowledge one occurrence for repeating items
+ parentItems[node.item.parentItem.hashId] = node.item.parentItem;
+ getAlarmService().snoozeAlarm(node.item, node.alarm, duration);
+ }
+ }
+ // we need to close the widget here explicitly because the dialog will stay
+ // opened if there a still not snoozable alarms
+ document.getElementById("alarm-snooze-all-button").firstElementChild.hidePopup();
+}
+
+/**
+ * Receive a calIDuration object for a given number of minutes
+ *
+ * @param {long} aMinutes The number of minutes
+ * @returns {calIDuration}
+ */
+function getDuration(aMinutes) {
+ const MINUTESINWEEK = 7 * 24 * 60;
+
+ // converting to weeks if any is required to avoid an integer overflow of duration.minutes as
+ // this is of type short
+ let weeks = Math.floor(aMinutes / MINUTESINWEEK);
+ aMinutes -= weeks * MINUTESINWEEK;
+
+ let duration = cal.createDuration();
+ duration.minutes = aMinutes;
+ duration.weeks = weeks;
+ duration.normalize();
+ return duration;
+}
+
+/**
+ * Check whether the snooze period exceeds the current limitation of the AlarmService and prompt
+ * the user with a message if so
+ *
+ * @param {calIDuration} aDuration The duration to snooze
+ * @returns {boolean}
+ */
+function aboveSnoozeLimit(aDuration) {
+ const LIMIT = Ci.calIAlarmService.MAX_SNOOZE_MONTHS;
+
+ let currentTime = cal.dtz.now().getInTimezone(cal.dtz.UTC);
+ let limitTime = currentTime.clone();
+ limitTime.month += LIMIT;
+
+ let durationUntilLimit = limitTime.subtractDate(currentTime);
+ if (aDuration.compare(durationUntilLimit) > 0) {
+ let msg = PluralForm.get(LIMIT, cal.l10n.getCalString("alarmSnoozeLimitExceeded"));
+ cal.showError(msg.replace("#1", LIMIT), window);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Sets up the window title, counting the number of alarms in the window.
+ */
+function setupTitle() {
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ let reminders = alarmRichlist.children.length;
+
+ let title = PluralForm.get(reminders, cal.l10n.getCalString("alarmWindowTitle.label"));
+ document.title = title.replace("#1", reminders);
+}
+
+/**
+ * Comparison function for the start date of a calendar item and
+ * the start date of a calendar-alarm-widget.
+ *
+ * @param aItem A calendar item for the comparison of the start date property
+ * @param aWidgetItem The alarm widget item for the start date comparison with the given calendar item
+ * @returns 1 - if the calendar item starts before the calendar-alarm-widget
+ * -1 - if the calendar-alarm-widget starts before the calendar item
+ * 0 - otherwise
+ */
+function widgetAlarmComptor(aItem, aWidgetItem) {
+ if (aItem == null || aWidgetItem == null) {
+ return -1;
+ }
+
+ // Get the dates to compare
+ let aDate = aItem[cal.dtz.startDateProp(aItem)];
+ let bDate = aWidgetItem[cal.dtz.startDateProp(aWidgetItem)];
+
+ return aDate.compare(bDate);
+}
+
+/**
+ * Add an alarm widget for the passed alarm and item.
+ *
+ * @param aItem The calendar item to add a widget for.
+ * @param aAlarm The alarm to add a widget for.
+ */
+function addWidgetFor(aItem, aAlarm) {
+ let widget = document.createXULElement("richlistitem", {
+ is: "calendar-alarm-widget-richlistitem",
+ });
+ let alarmRichlist = document.getElementById("alarm-richlist");
+
+ // Add widgets sorted by start date ascending
+ cal.data.binaryInsertNode(alarmRichlist, widget, aItem, widgetAlarmComptor, false);
+
+ widget.item = aItem;
+ widget.alarm = aAlarm;
+ widget.addEventListener("snooze", onSnoozeAlarm);
+ widget.addEventListener("dismiss", onDismissAlarm);
+ widget.addEventListener("itemdetails", onItemDetails);
+
+ setupTitle();
+ doReadOnlyChecks();
+
+ if (!alarmRichlist.userSelectedWidget) {
+ // Always select first widget of the list.
+ // Since the onselect event causes scrolling,
+ // we don't want to process the event when adding widgets.
+ alarmRichlist.suppressOnSelect = true;
+ alarmRichlist.selectedItem = alarmRichlist.firstElementChild;
+ alarmRichlist.suppressOnSelect = false;
+ }
+
+ window.focus();
+ window.getAttention();
+}
+
+/**
+ * Remove the alarm widget for the passed alarm and item.
+ *
+ * @param aItem The calendar item to remove the alarm widget for.
+ * @param aAlarm The alarm to remove the widget for.
+ */
+function removeWidgetFor(aItem, aAlarm) {
+ let hashId = aItem.hashId;
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ let nodes = alarmRichlist.children;
+ let notfound = true;
+ for (let i = nodes.length - 1; notfound && i >= 0; --i) {
+ let widget = nodes[i];
+ if (
+ widget.item &&
+ widget.item.hashId == hashId &&
+ widget.alarm &&
+ widget.alarm.icalString == aAlarm.icalString
+ ) {
+ if (widget.selected) {
+ // Advance selection if needed
+ widget.control.selectedItem = widget.previousElementSibling || widget.nextElementSibling;
+ }
+
+ widget.removeEventListener("snooze", onSnoozeAlarm);
+ widget.removeEventListener("dismiss", onDismissAlarm);
+ widget.removeEventListener("itemdetails", onItemDetails);
+
+ widget.remove();
+ doReadOnlyChecks();
+ closeIfEmpty();
+ notfound = false;
+ }
+ }
+
+ // Update the title
+ setupTitle();
+ closeIfEmpty();
+}
+
+/**
+ * Enables/disables the 'snooze all' button and displays or removes a r/o
+ * notification based on the readability of the calendars of the alarms visible
+ * in the alarm list
+ */
+function doReadOnlyChecks() {
+ let countRO = 0;
+ let alarmRichlist = document.getElementById("alarm-richlist");
+ for (let node of alarmRichlist.children) {
+ if (!cal.acl.isCalendarWritable(node.item.calendar) || !cal.acl.userCanModifyItem(node.item)) {
+ countRO++;
+ }
+ }
+
+ // we disable the button if there are only alarms for not-writable items
+ let snoozeAllButton = document.getElementById("alarm-snooze-all-button");
+ snoozeAllButton.disabled = countRO && countRO == alarmRichlist.children.length;
+ if (snoozeAllButton.disabled) {
+ let tooltip = cal.l10n.getString("calendar-alarms", "reminderDisabledSnoozeButtonTooltip");
+ snoozeAllButton.setAttribute("tooltiptext", tooltip);
+ } else {
+ snoozeAllButton.removeAttribute("tooltiptext");
+ }
+
+ let notification = gReadOnlyNotification.getNotificationWithValue("calendar-readonly");
+ if (countRO && !notification) {
+ let message = cal.l10n.getString("calendar-alarms", "reminderReadonlyNotification", [
+ snoozeAllButton.label,
+ ]);
+ gReadOnlyNotification.appendNotification(
+ "calendar-readonly",
+ {
+ label: message,
+ priority: gReadOnlyNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ } else if (notification && !countRO) {
+ gReadOnlyNotification.removeNotification(notification);
+ }
+}
+
+/**
+ * Close the alarm dialog if there are no further alarm widgets
+ */
+function closeIfEmpty() {
+ let alarmRichlist = document.getElementById("alarm-richlist");
+
+ // we don't want to close if the alarm service is still loading, as the
+ // removed alarms may be immediately added again.
+ if (!alarmRichlist.hasChildNodes() && !getAlarmService().isLoading) {
+ window.close();
+ }
+}
+
+/**
+ * Handler function called when an alarm entry in the richlistbox is selected
+ *
+ * @param event The DOM event from the click action
+ */
+function onSelectAlarm(event) {
+ let richList = document.getElementById("alarm-richlist");
+ if (richList == event.target) {
+ richList.ensureElementIsVisible(richList.getSelectedItem(0));
+ richList.userSelectedWidget = true;
+ }
+}
+
+function ensureCalendarVisible(aCalendar) {
+ // This function is called on the alarm dialog from calendar-item-editing.js.
+ // Normally, it makes sure that the calendar being edited is made visible,
+ // but the alarm dialog is too far away from the calendar views that it
+ // makes sense to force visibility for the calendar. Therefore, do nothing.
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml
new file mode 100644
index 0000000000..02591f4810
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-alarm-dialog.xhtml
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/calendar-alarm-dialog.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://calendar/locale/calendar.dtd">
+
+<html
+ id="calendar-alarm-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="calendar-alarm-dialog"
+ windowtype="Calendar:AlarmWindow"
+ persist="screenX screenY width height"
+ lightweightthemes="true"
+ width="600"
+ height="300"
+ scrolling="false"
+>
+ <head>
+ <title>&calendar.alarm.title.label;</title>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-alarm-widget.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-alarm-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <vbox id="readonly-notification">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+ <richlistbox id="alarm-richlist" flex="1" onselect="onSelectAlarm(event)" />
+
+ <hbox id="alarm-actionbar" pack="end" align="center">
+ <button id="alarm-snooze-all-button" type="menu" label="&calendar.alarm.snoozeallfor.label;">
+ <menupopup is="calendar-snooze-popup" id="alarm-snooze-all-popup" ignorekeys="true" />
+ </button>
+ <button
+ id="alarm-dismiss-all-button"
+ label="&calendar.alarm.dismissall.label;"
+ oncommand="onDismissAllAlarms();"
+ />
+ </hbox>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js
new file mode 100644
index 0000000000..d53569028c
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals getPreviewForItem */ // From mouseoverPreviews.js
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+window.addEventListener("DOMContentLoaded", onLoad);
+
+function onLoad() {
+ let dialog = document.querySelector("dialog");
+ let item = window.arguments[0].item;
+ let vbox = getPreviewForItem(item, false);
+ if (vbox) {
+ document.getElementById("item-box").replaceWith(vbox);
+ }
+
+ let descr = document.getElementById("conflicts-description");
+
+ // TODO These strings should move to Fluent.
+ // For that matter, this dialog should be reworked!
+ document.title = cal.l10n.getCalString("itemModifiedOnServerTitle");
+ descr.textContent = cal.l10n.getCalString("itemModifiedOnServer");
+
+ if (window.arguments[0].mode == "modify") {
+ descr.textContent += cal.l10n.getCalString("modifyWillLoseData");
+ dialog.getButton("accept").setAttribute("label", cal.l10n.getCalString("proceedModify"));
+ } else {
+ descr.textContent += cal.l10n.getCalString("deleteWillLoseData");
+ dialog.getButton("accept").setAttribute("label", cal.l10n.getCalString("proceedDelete"));
+ }
+
+ dialog.getButton("cancel").setAttribute("label", cal.l10n.getCalString("updateFromServer"));
+}
+
+document.addEventListener("dialogaccept", () => {
+ window.arguments[0].overwrite = true;
+});
+
+document.addEventListener("dialogcancel", () => {
+ window.arguments[0].overwrite = false;
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml
new file mode 100644
index 0000000000..868df36248
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-conflicts-dialog.xhtml
@@ -0,0 +1,35 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-views.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<html
+ id="calendar-conflicts-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:Conflicts"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <link rel="localization" href="branding/brand.ftl" />
+ <script defer="defer" src="chrome://calendar/content/widgets/mouseoverPreviews.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-conflicts-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog>
+ <vbox id="conflicts-vbox" flex="1">
+ <vbox id="item-box" flex="1" />
+ <description id="conflicts-description" style="max-width: 40em; margin-top: 1ex" />
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-creation.js b/comm/calendar/base/content/dialogs/calendar-creation.js
new file mode 100644
index 0000000000..b4d2a7c2e4
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-creation.js
@@ -0,0 +1,836 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ChromeUtils.defineModuleGetter(this, "MsgAuthPrompt", "resource:///modules/MsgAsyncPrompter.jsm");
+
+/* exported checkRequired, fillLocationPlaceholder, selectProvider, updateNoCredentials, */
+
+/* import-globals-from calendar-identity-utils.js */
+
+/**
+ * For managing dialog button handler state. Stores the current handlers so we
+ * can remove them with removeEventListener. Provides a way to look up the
+ * button handler functions to be used with a given panel.
+ */
+var gButtonHandlers = {
+ accept: null,
+ extra2: null,
+
+ // Maps a panel DOM node ID to the button handlers to use for that panel.
+ forNodeId: {
+ "panel-select-calendar-type": {
+ accept: selectCalendarType,
+ },
+ "panel-local-calendar-settings": {
+ accept: registerLocalCalendar,
+ extra2: () => selectPanel("panel-select-calendar-type"),
+ },
+ "panel-network-calendar-settings": {
+ accept: event => {
+ event.preventDefault();
+ event.stopPropagation();
+ findCalendars();
+ },
+ extra2: () => selectPanel("panel-select-calendar-type"),
+ },
+ "panel-select-calendars": {
+ accept: createNetworkCalendars,
+ extra2: () => selectPanel("panel-network-calendar-settings"),
+ },
+ "panel-addon-calendar-settings": {
+ extra2: () => selectPanel("panel-select-calendar-type"),
+ // This 'accept' is set dynamically when the calendar type is selected.
+ accept: null,
+ },
+ },
+};
+
+/** @type {calICalendar | null} */
+var gLocalCalendar = null;
+
+/**
+ * A type of calendar that can be created with this dialog.
+ *
+ * @typedef {CalendarType}
+ * @property {string} id A unique ID for this type, e.g. "local" or
+ * "network" for built-in types and
+ * "3" or "4" for add-on types.
+ * @property {boolean} builtIn Whether this is a built in type.
+ * @property {Function} onSelected The "accept" button handler to call when
+ * the type is selected.
+ * @property {string} [label] Text to use in calendar type selection UI.
+ * @property {string} [panelSrc] The "src" property for the <browser> for
+ * this type's settings panel, typically a
+ * path to an html document. Only needed
+ * for types registered by add-ons.
+ * @property {Function} [onCreated] The "accept" button handler for this
+ * type's settings panel. Only needed for
+ * types registered by add-ons.
+ */
+
+/**
+ * Registry of calendar types. The key should match the type's `id` property.
+ * Add-ons may register additional types.
+ *
+ * @type {Map<string, CalendarType>}
+ */
+var gCalendarTypes = new Map([
+ [
+ "local",
+ {
+ id: "local",
+ builtIn: true,
+ onSelected: () => {
+ // Create a local calendar to use, so we can share code with the calendar
+ // preferences dialog.
+ if (!gLocalCalendar) {
+ gLocalCalendar = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI("moz-storage-calendar://")
+ );
+
+ initMailIdentitiesRow(gLocalCalendar);
+ notifyOnIdentitySelection(gLocalCalendar);
+ }
+ selectPanel("panel-local-calendar-settings");
+ },
+ },
+ ],
+ [
+ "network",
+ {
+ id: "network",
+ builtIn: true,
+ onSelected: () => selectPanel("panel-network-calendar-settings"),
+ },
+ ],
+]);
+
+/** @type {CalendarType | null} */
+var gSelectedCalendarType = null;
+
+/**
+ * Register a calendar type to offer in the dialog. For add-ons to use. Add-on
+ * code should store the returned ID and use it for unregistering the type.
+ *
+ * @param {CalendarType} type - The type object to register.
+ * @returns {string} The generated ID for the type.
+ */
+function registerCalendarType(type) {
+ type.id = String(gCalendarTypes.size + 1);
+ type.builtIn = false;
+
+ if (!type.onSelected) {
+ type.onSelected = () => selectPanel("panel-addon-calendar-settings");
+ }
+ gCalendarTypes.set(type.id, type);
+
+ // Add an option for this type to the "select calendar type" panel.
+ let radiogroup = document.getElementById("calendar-type");
+ let radio = document.createXULElement("radio");
+ radio.setAttribute("value", type.id);
+ radio.setAttribute("label", type.label);
+ radiogroup.appendChild(radio);
+
+ return type.id;
+}
+
+/**
+ * Unregister a calendar type. For add-ons to use.
+ *
+ * @param {string} id - The ID of the type to unregister.
+ */
+function unregisterCalendarType(id) {
+ // Don't allow unregistration of built-in types.
+ if (gCalendarTypes.get(id)?.builtIn) {
+ cal.WARN(
+ `calendar creation dialog: unregistering calendar type "${id}"` +
+ " failed because it is a built in type"
+ );
+ return;
+ }
+ // We are using the size of gCalendarTypes to generate unique IDs for
+ // registered types, so don't fully remove the type.
+ gCalendarTypes.set(id, undefined);
+
+ // Remove the option for this type from the "select calendar type" panel.
+ let radiogroup = document.getElementById("calendar-type");
+ let radio = radiogroup.querySelector(`[value="${id}"]`);
+ if (radio) {
+ radiogroup.removeChild(radio);
+ }
+}
+
+/**
+ * Tools for managing how providers are used for calendar detection. May be used
+ * by add-ons to modify which providers are used and which results are preferred.
+ */
+var gProviderUsage = {
+ /**
+ * A function that returns a list of provider types to filter out and not use
+ * to detect calendars, for a given location and username. The providers are
+ * filtered out before calendar detection. For example, the "Provider for
+ * Google Calendar" add-on might filter out the "caldav" provider:
+ *
+ * (providers, location, username) => {
+ * domain = username.split("@")[1];
+ * if (providers.includes("gdata") && (domain == "googlemail.com" || domain == "gmail.com")) {
+ * return ["caldav"];
+ * }
+ * return [];
+ * }
+ *
+ * @callback ProviderFilter
+ * @param {string[]} providers - Array of provider types to be used (if not filtered out).
+ * @param {string} location - Location to use for calendar detection.
+ * @param {string} username - Username to use for calendar detection.
+ * @returns {string[]} Array of provider types to be filtered out.
+ */
+
+ /** @type {ProviderFilter[]} */
+ _preDetectFilters: [],
+
+ /**
+ * A mapping from a less preferred provider type to a set of more preferred
+ * provider types. Used after calendar detection to default to a more
+ * preferred provider when there are results from more than one provider.
+ *
+ * @typedef {Map<string, Set<string>>} ProviderPreferences
+ */
+
+ /**
+ * @type {ProviderPreferences}
+ */
+ _postDetectPreferences: new Map(),
+
+ get preDetectFilters() {
+ return this._preDetectFilters;
+ },
+
+ get postDetectPreferences() {
+ return this._postDetectPreferences;
+ },
+
+ /**
+ * Add a new provider filter function.
+ *
+ * @param {ProviderFilter} providerFilter
+ */
+ addPreDetectFilter(providerFilter) {
+ this._preDetectFilters.push(providerFilter);
+ },
+
+ /**
+ * Add a preference for one provider type over another provider type.
+ *
+ * @param {string} preferredType - The preferred provider type.
+ * @param {string} nonPreferredType - The non-preferred provider type.
+ */
+ addPostDetectPreference(preferredType, nonPreferredType) {
+ let prefs = this._postDetectPreferences;
+
+ if (this.detectPreferenceCycle(prefs, preferredType, nonPreferredType)) {
+ cal.WARN(
+ `Adding a preference for provider type "${preferredType}" over ` +
+ `type "${nonPreferredType}" would cause a preference cycle, ` +
+ `not adding this preference to prevent a cycle`
+ );
+ } else {
+ let current = prefs.get(nonPreferredType);
+ if (current) {
+ current.add(preferredType);
+ } else {
+ prefs.set(nonPreferredType, new Set([preferredType]));
+ }
+ }
+ },
+
+ /**
+ * Check whether adding a preference for one provider type over another would
+ * cause a cycle in the order of preferences. We assume that the preferences
+ * do not contain any cycles already.
+ *
+ * @param {ProviderPreferences} prefs - The current preferences.
+ * @param {string} preferred - Potential preferred provider.
+ * @param {string} nonPreferred - Potential non-preferred provider.
+ * @returns {boolean} True if it would cause a cycle.
+ */
+ detectPreferenceCycle(prefs, preferred, nonPreferred) {
+ let cycle = false;
+
+ let innerDetect = preferredSet => {
+ if (cycle) {
+ // Bail out, a cycle has already been detected.
+ return;
+ } else if (preferredSet.has(nonPreferred)) {
+ // A cycle! We have arrived back at the nonPreferred provider type.
+ cycle = true;
+ return;
+ }
+ // Recursively check each preferred type.
+ for (let item of preferredSet) {
+ let nextPreferredSet = prefs.get(item);
+ if (nextPreferredSet) {
+ innerDetect(nextPreferredSet);
+ }
+ }
+ };
+
+ innerDetect(new Set([preferred]));
+ return cycle;
+ },
+};
+
+// If both ics and caldav results exist, default to the caldav results.
+gProviderUsage.addPostDetectPreference("caldav", "ics");
+
+/**
+ * Select a specific panel in the dialog. Used to move from one panel to another.
+ *
+ * @param {string} id - The id of the panel node to select.
+ */
+function selectPanel(id) {
+ for (let element of document.getElementById("calendar-creation-dialog").children) {
+ element.hidden = element.id != id;
+ }
+ let panel = document.getElementById(id);
+ updateButton("accept", panel);
+ updateButton("extra2", panel);
+ selectNetworkStatus("none");
+ checkRequired();
+
+ let firstInput = panel.querySelector("input");
+ if (firstInput) {
+ firstInput.focus();
+ }
+}
+
+/**
+ * Set a specific network loading status for the network settings panel.
+ * See the CSS file for appropriate values to set.
+ *
+ * @param {string} status - The status to set.
+ */
+function selectNetworkStatus(status) {
+ for (let row of document.querySelectorAll(".network-status-row")) {
+ row.setAttribute("status", status);
+ }
+}
+
+/**
+ * Update the label, accesskey, and event listener for a dialog button.
+ *
+ * @param {string} name - The dialog button name, e.g. 'accept', 'extra2'.
+ * @param {Element} sourceNode - The source node to take attribute values from.
+ */
+function updateButton(name, sourceNode) {
+ let dialog = document.getElementById("calendar-creation-dialog");
+ let button = dialog.getButton(name);
+ let label = sourceNode.getAttribute("buttonlabel" + name);
+ let accesskey = sourceNode.getAttribute("buttonaccesskey" + name);
+
+ let handler = gButtonHandlers.forNodeId[sourceNode.id][name];
+
+ if (label) {
+ button.setAttribute("label", label);
+ button.hidden = false;
+ } else {
+ button.hidden = true;
+ }
+
+ button.setAttribute("accesskey", accesskey || "");
+
+ // 'dialogaccept', 'dialogextra2', etc.
+ let eventName = "dialog" + name;
+
+ document.removeEventListener(eventName, gButtonHandlers[name]);
+ if (handler) {
+ document.addEventListener(eventName, handler);
+ // Store a reference to the current handler, to allow removing it later.
+ gButtonHandlers[name] = handler;
+ }
+}
+
+/**
+ * Update the disabled state of the accept button by checking the values of
+ * required fields, based on the current panel.
+ */
+function checkRequired() {
+ let dialog = document.getElementById("calendar-creation-dialog");
+ let selectedPanel = null;
+ for (let element of dialog.children) {
+ if (!element.hidden) {
+ selectedPanel = element;
+ }
+ }
+ if (!selectedPanel) {
+ dialog.setAttribute("buttondisabledaccept", "true");
+ return;
+ }
+
+ let disabled = false;
+ switch (selectedPanel.id) {
+ case "panel-local-calendar-settings":
+ disabled = !selectedPanel.querySelector("form").checkValidity();
+ break;
+ case "panel-network-calendar-settings": {
+ let location = document.getElementById("network-location-input");
+ let username = document.getElementById("network-username-input");
+
+ disabled = !location.value && !username.value.split("@")[1];
+ break;
+ }
+ }
+
+ if (disabled) {
+ dialog.setAttribute("buttondisabledaccept", "true");
+ } else {
+ dialog.removeAttribute("buttondisabledaccept");
+ }
+}
+
+/**
+ * Update the placeholder text for the network location field. If the username
+ * is a valid email address use the domain part of the username, otherwise use
+ * the default placeholder.
+ */
+function fillLocationPlaceholder() {
+ let location = document.getElementById("network-location-input");
+ let userval = document.getElementById("network-username-input").value;
+ let parts = userval.split("@");
+ let domain = parts.length == 2 && parts[1] ? parts[1] : null;
+
+ if (domain) {
+ location.setAttribute("placeholder", domain);
+ } else {
+ location.setAttribute("placeholder", location.getAttribute("default-placeholder"));
+ }
+}
+
+/**
+ * Update the select network calendar panel to show or hide the provider
+ * selection dropdown.
+ *
+ * @param {boolean} isSingle - If true, there is just one matching provider.
+ */
+function setSingleProvider(isSingle) {
+ document.getElementById("network-selectcalendar-description-single").hidden = !isSingle;
+ document.getElementById("network-selectcalendar-description-multiple").hidden = isSingle;
+ document.getElementById("network-selectcalendar-providertype-box").hidden = isSingle;
+}
+
+/**
+ * Fill the providers menulist with the given provider types. The types must
+ * correspond to the providers that detected calendars.
+ *
+ * @param {string[]} providerTypes - An array of provider types.
+ * @returns {Element} The selected menuitem.
+ */
+function fillProviders(providerTypes) {
+ let menulist = document.getElementById("network-selectcalendar-providertype-menulist");
+ let popup = menulist.menupopup;
+ while (popup.lastChild) {
+ popup.removeChild(popup.lastChild);
+ }
+
+ let providers = cal.provider.detection.providers;
+
+ for (let type of providerTypes) {
+ let provider = providers.get(type);
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.value = type;
+ menuitem.setAttribute("label", provider.displayName || type);
+ popup.appendChild(menuitem);
+ }
+
+ // Select a provider menu item based on provider preferences.
+ let preferredTypes = new Set(providerTypes);
+
+ for (let [nonPreferred, preferredSet] of gProviderUsage.postDetectPreferences) {
+ if (preferredTypes.has(nonPreferred) && setsIntersect(preferredSet, preferredTypes)) {
+ preferredTypes.delete(nonPreferred);
+ }
+ }
+ let preferredIndex = providerTypes.findIndex(type => preferredTypes.has(type));
+ menulist.selectedIndex = preferredIndex == -1 ? 0 : preferredIndex;
+
+ return menulist.selectedItem;
+}
+
+/**
+ * Return true if the intersection of two sets contains at least one item.
+ *
+ * @param {Set} setA - A set.
+ * @param {Set} setB - A set.
+ * @returns {boolean}
+ */
+function setsIntersect(setA, setB) {
+ for (let item of setA) {
+ if (setB.has(item)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Select the given provider and update the calendar list to fill the
+ * corresponding calendars. Will use the results from the last findCalendars
+ * response.
+ *
+ * @param {string} type - The provider type to select.
+ */
+function selectProvider(type) {
+ let providerMap = findCalendars.lastResult;
+ let calendarList = document.getElementById("network-calendar-list");
+
+ let calendars = providerMap.get(type) || [];
+ renderCalendarList(calendarList, calendars);
+}
+
+/**
+ * Empty a calendar list and then fill it with calendars.
+ *
+ * @param {Element} calendarList - A richlistbox element for listing calendars.
+ * @param {calICalendar[]} calendars - An array of calendars to display in the list.
+ */
+function renderCalendarList(calendarList, calendars) {
+ while (calendarList.hasChildNodes()) {
+ calendarList.lastChild.remove();
+ }
+ let propertiesButtonLabel = calendarList.getAttribute("propertiesbuttonlabel");
+ calendars.forEach((calendar, index) => {
+ let item = document.createXULElement("richlistitem");
+ item.calendar = calendar;
+
+ let checkbox = document.createXULElement("checkbox");
+ let checkboxId = "checkbox" + index;
+ checkbox.id = checkboxId;
+ checkbox.classList.add("calendar-selected");
+ item.appendChild(checkbox);
+
+ let colorMarker = document.createElement("div");
+ colorMarker.classList.add("calendar-color");
+ colorMarker.style.backgroundColor = calendar.getProperty("color");
+ item.appendChild(colorMarker);
+
+ let label = document.createXULElement("label");
+ label.classList.add("calendar-name");
+ label.value = calendar.name;
+ label.control = checkboxId;
+ item.appendChild(label);
+
+ let propertiesButton = document.createXULElement("button");
+ propertiesButton.classList.add("calendar-edit-button");
+ propertiesButton.label = propertiesButtonLabel;
+ propertiesButton.addEventListener("command", openCalendarPropertiesFromEvent);
+ item.appendChild(propertiesButton);
+
+ if (calendar.getProperty("disabled")) {
+ item.disabled = true;
+ item.toggleAttribute("calendar-disabled", true);
+ checkbox.disabled = true;
+ propertiesButton.disabled = true;
+ } else {
+ checkbox.checked = true;
+ }
+ calendarList.appendChild(item);
+ });
+}
+
+/**
+ * Update dialog fields based on the value of the "no credentials" checkbox.
+ *
+ * @param {boolean} noCredentials - True, if "no credentials" is checked.
+ */
+function updateNoCredentials(noCredentials) {
+ if (noCredentials) {
+ document.getElementById("network-username-input").setAttribute("disabled", "true");
+ document.getElementById("network-username-input").value = "";
+ } else {
+ document.getElementById("network-username-input").removeAttribute("disabled");
+ }
+}
+
+/**
+ * The accept button event listener for the "select calendar type" panel.
+ *
+ * @param {Event} event
+ */
+function selectCalendarType(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ let radiogroup = document.getElementById("calendar-type");
+ let calendarType = gCalendarTypes.get(radiogroup.value);
+
+ if (!calendarType.builtIn && calendarType !== gSelectedCalendarType) {
+ setUpAddonCalendarSettingsPanel(calendarType);
+ }
+ gSelectedCalendarType = calendarType;
+ calendarType.onSelected();
+}
+
+/**
+ * Set up the settings panel for calendar types registered by addons.
+ *
+ * @param {CalendarType} calendarType - The calendar type.
+ */
+function setUpAddonCalendarSettingsPanel(calendarType) {
+ function setUpBrowser(browser, src) {
+ // Allow keeping dialog background color without jumping through hoops.
+ browser.setAttribute("transparent", "true");
+ browser.setAttribute("flex", "1");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("src", src);
+ }
+ let panel = document.getElementById("panel-addon-calendar-settings");
+ let browser = panel.lastElementChild;
+
+ if (browser) {
+ setUpBrowser(browser, calendarType.panelSrc);
+ } else {
+ browser = document.createXULElement("browser");
+ setUpBrowser(browser, calendarType.panelSrc);
+
+ panel.appendChild(browser);
+ // The following emit is needed for the browser to work with addon content.
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+ }
+
+ // Set up the accept button handler for the panel.
+ gButtonHandlers.forNodeId["panel-addon-calendar-settings"].accept = calendarType.onCreated;
+}
+
+/**
+ * Handle change of the email (identity) menu for local calendar creation.
+ * Show a notification when "none" is selected.
+ *
+ * @param {Event} event - The menu selection event.
+ */
+function onChangeIdentity(event) {
+ notifyOnIdentitySelection(gLocalCalendar);
+}
+
+/**
+ * Prepare the local storage calendar with the information from the dialog.
+ * This can be monkeypatched to add additional values.
+ *
+ * @param {calICalendar} calendar - The calendar to prepare.
+ * @returns {calICalendar} The same calendar, prepared with any
+ * extra values.
+ */
+function prepareLocalCalendar(calendar) {
+ calendar.name = document.getElementById("local-calendar-name-input").value;
+ calendar.setProperty("color", document.getElementById("local-calendar-color-picker").value);
+
+ if (!document.getElementById("local-fire-alarms-checkbox").checked) {
+ calendar.setProperty("suppressAlarms", true);
+ }
+
+ saveMailIdentitySelection(calendar);
+ return calendar;
+}
+
+/**
+ * The accept button event listener for the "local calendar settings" panel.
+ * Registers the local storage calendar and closes the dialog.
+ */
+function registerLocalCalendar() {
+ cal.manager.registerCalendar(prepareLocalCalendar(gLocalCalendar));
+}
+
+/**
+ * Start detection and find any calendars using the information from the
+ * network settings panel.
+ *
+ * @param {string} [password] - The password for this attempt, if any.
+ * @param {boolean} [savePassword] - Whether to save the password in the
+ * password manager.
+ */
+function findCalendars(password, savePassword = false) {
+ selectNetworkStatus("loading");
+ let username = document.getElementById("network-username-input");
+ let location = document.getElementById("network-location-input");
+ let locationValue = location.value || username.value.split("@")[1] || "";
+
+ // webcal(s): doesn't work with content principal.
+ locationValue = locationValue.replace(/^webcal(s)?(:.*)/, "http$1$2").trim();
+ cal.provider.detection
+ .detect(
+ username.value,
+ password,
+ locationValue,
+ savePassword,
+ gProviderUsage.preDetectFilters,
+ {}
+ )
+ .then(onDetectionSuccess, onDetectionError.bind(null, password, locationValue));
+}
+
+/**
+ * Called when detection successfully finds calendars. Displays the UI for
+ * selecting calendars to subscribe to.
+ *
+ * @param {Map<string, calICalendar[]>} providerMap Map from provider type
+ * (e.g. "ics", "caldav")
+ * to an array of calendars.
+ */
+function onDetectionSuccess(providerMap) {
+ // Disable the calendars the user has already subscribed to. In the future
+ // we should show a string when all calendars are already subscribed.
+ let existing = new Set(cal.manager.getCalendars({}).map(calendar => calendar.uri.spec));
+
+ let calendarsMap = new Map();
+ for (let [provider, calendars] of providerMap.entries()) {
+ let newCalendars = calendars.map(calendar => {
+ let newCalendar = prepareNetworkCalendar(calendar);
+ if (existing.has(calendar.uri.spec)) {
+ newCalendar.setProperty("disabled", true);
+ }
+ return newCalendar;
+ });
+
+ calendarsMap.set(provider.type, newCalendars);
+ }
+
+ if (!calendarsMap.size) {
+ selectNetworkStatus("notfound");
+ return;
+ }
+
+ // Update the panel with the results from the provider map.
+ setSingleProvider(calendarsMap.size <= 1);
+ findCalendars.lastResult = calendarsMap;
+
+ let selectedItem = fillProviders([...calendarsMap.keys()]);
+ selectProvider(selectedItem.value);
+
+ // Select the panel and validate the fields.
+ selectPanel("panel-select-calendars");
+ checkRequired();
+}
+
+/**
+ * Called when detection fails to find any calendars. Show an appropriate
+ * error message, or if the error is an authentication error and no password
+ * was entered for this attempt, prompt the user to enter a password.
+ *
+ * @param {string} [password] - The password entered, if any.
+ * @param {string} [location] - The location input from the dialog.
+ * @param {Error} error - An error object.
+ */
+function onDetectionError(password, location, error) {
+ if (error instanceof cal.provider.detection.AuthFailedError) {
+ if (password) {
+ selectNetworkStatus("authfail");
+ } else {
+ findCalendarsWithPassword(location);
+ return;
+ }
+ } else if (error instanceof cal.provider.detection.CanceledError) {
+ selectNetworkStatus("none");
+ } else {
+ selectNetworkStatus("notfound");
+ }
+ cal.ERROR(
+ "Error during calendar detection: " +
+ `${error.fileName || error.filename}:${error.lineNumber}: ${error}\n${error.stack}`
+ );
+}
+
+/**
+ * Prompt the user for a password and attempt to find calendars with it.
+ *
+ * @param {string} location - The location input from the dialog.
+ */
+function findCalendarsWithPassword(location) {
+ let password = { value: "" };
+ let savePassword = { value: 1 };
+
+ let okWasClicked = new MsgAuthPrompt().promptPassword2(
+ null,
+ cal.l10n.getAnyString("messenger-mapi", "mapi", "loginText", [location]),
+ password,
+ cal.l10n.getAnyString("passwordmgr", "passwordmgr", "rememberPassword"),
+ savePassword
+ );
+
+ if (okWasClicked) {
+ findCalendars(password.value, savePassword.value);
+ } else {
+ selectNetworkStatus("authfail");
+ }
+}
+
+/**
+ * Make preparations on the given calendar (a detected calendar). This
+ * function can be monkeypatched to make general preparations, e.g. for values
+ * from additional form fields.
+ *
+ * @param {calICalendar} calendar - The calendar to prepare.
+ * @returns {calICalendar} The same calendar, prepared with
+ * any extra values.
+ */
+function prepareNetworkCalendar(calendar) {
+ let cached = document.getElementById("network-cache-checkbox").checked;
+
+ if (!calendar.getProperty("cache.always")) {
+ let cacheSupported = calendar.getProperty("cache.supported") !== false;
+ calendar.setProperty("cache.enabled", cacheSupported ? cached : false);
+ }
+
+ return calendar;
+}
+
+/**
+ * The accept button handler for the 'select network calendars' panel.
+ * Subscribes to all of the selected network calendars and allows the dialog to
+ * close.
+ */
+function createNetworkCalendars() {
+ for (let listItem of document.getElementById("network-calendar-list").children) {
+ if (listItem.querySelector(".calendar-selected").checked) {
+ cal.manager.registerCalendar(listItem.calendar);
+ }
+ }
+}
+
+/**
+ * Open the calendar properties dialog for a calendar in the calendar list.
+ *
+ * @param {Event} event - The triggering event.
+ */
+function openCalendarPropertiesFromEvent(event) {
+ let listItem = event.target.closest("richlistitem");
+ if (listItem) {
+ let calendar = listItem.calendar;
+ if (calendar && !calendar.getProperty("disabled")) {
+ cal.window.openCalendarProperties(window, { calendar, canDisable: false });
+
+ // Update the calendar list item.
+ listItem.querySelector(".calendar-name").value = calendar.name;
+ listItem.querySelector(".calendar-color").style.backgroundColor =
+ calendar.getProperty("color");
+ }
+ }
+}
+
+window.addEventListener("load", () => {
+ fillLocationPlaceholder();
+ selectPanel("panel-select-calendar-type");
+ if (window.arguments[0]) {
+ let spec = window.arguments[0].spec;
+ if (/^webcals?:\/\//.test(spec)) {
+ selectPanel("panel-network-calendar-settings");
+ document.getElementById("network-location-input").value = spec;
+ checkRequired();
+ }
+ }
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-creation.xhtml b/comm/calendar/base/content/dialogs/calendar-creation.xhtml
new file mode 100644
index 0000000000..7ae66d3af9
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-creation.xhtml
@@ -0,0 +1,259 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-creation.css"?>
+
+<!-- TODO: messenger.dtd is used for the "Next" button; some relocation is needed. -->
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendarCreation.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%dtd2;
+<!ENTITY % dtd3 SYSTEM "chrome://lightning/locale/lightning.dtd" >
+%dtd3;
+<!ENTITY % dtd4 SYSTEM "chrome://messenger/locale/messenger.dtd" >
+%dtd4; ]>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="calendar-general-dialog"
+ lightweightthemes="true"
+ style="min-width: 500px; min-height: 380px"
+ scrolling="false"
+>
+ <head>
+ <title>&wizard.title;</title>
+ <link rel="localization" href="toolkit/global/wizard.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-identity-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-creation.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog
+ id="calendar-creation-dialog"
+ style="width: 100vw; height: 100vh"
+ buttons="accept,cancel"
+ >
+ <!-- Panel: Select Calendar Type -->
+ <vbox
+ id="panel-select-calendar-type"
+ buttonlabelaccept="&nextButton.label;"
+ buttonaccesskeyaccept=""
+ >
+ <description>&initialpage.description;</description>
+ <hbox class="indent">
+ <radiogroup id="calendar-type">
+ <radio value="local" label="&initialpage.computer.label;" selected="true" />
+ <radio value="network" label="&initialpage.network.label;" />
+ </radiogroup>
+ </hbox>
+ </vbox>
+
+ <!-- Panel: Local Calendar Settings -->
+ <vbox
+ id="panel-local-calendar-settings"
+ buttonlabelaccept="&buttons.create.label;"
+ buttonaccesskeyaccept="&buttons.create.accesskey;"
+ buttonlabelextra2="&buttons.back.label;"
+ buttonaccesskeyextra2="&buttons.back.accesskey;"
+ hidden="true"
+ >
+ <vbox id="no-identity-notification" class="notification-inline">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+ <html:form>
+ <html:table>
+ <html:tr>
+ <html:th>
+ <label
+ value="&calendar.server.dialog.name.label;"
+ control="local-calendar-name-input"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="local-calendar-name-input"
+ class="calendar-creation-text-input"
+ flex="1"
+ required="required"
+ oninput="checkRequired()"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label
+ value="&calendarproperties.color.label;"
+ control="local-calendar-color-picker"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="local-calendar-color-picker"
+ class="small-margin"
+ type="color"
+ value="#A8C2E1"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th> </html:th>
+ <html:td>
+ <checkbox
+ id="local-fire-alarms-checkbox"
+ label="&calendarproperties.firealarms.label;"
+ checked="true"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-email-identity-row">
+ <html:th>
+ <label
+ value="&lightning.calendarproperties.email.label;"
+ control="email-identity-menulist"
+ />
+ </html:th>
+ <html:td>
+ <menulist id="email-identity-menulist" oncommand="onChangeIdentity(event)">
+ <menupopup id="email-identity-menupopup" />
+ </menulist>
+ </html:td>
+ </html:tr>
+ </html:table>
+ </html:form>
+ </vbox>
+
+ <!-- Panel: Network Calendar Settings -->
+ <html:table
+ id="panel-network-calendar-settings"
+ flex="1"
+ buttonlabelaccept="&buttons.find.label;"
+ buttonaccesskeyaccept="&buttons.find.accesskey;"
+ buttonlabelextra2="&buttons.back.label;"
+ buttonaccesskeyextra2="&buttons.back.accesskey;"
+ hidden="true"
+ >
+ <html:tr id="network-username-row">
+ <html:th>
+ <label value="&locationpage.username.label;" control="network-username-input" />
+ </html:th>
+ <html:td>
+ <html:input
+ id="network-username-input"
+ class="calendar-creation-text-input"
+ flex="1"
+ oninput="fillLocationPlaceholder(); checkRequired()"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="network-location-row">
+ <html:th>
+ <label value="&location.label;" control="network-location-input" />
+ </html:th>
+ <html:td>
+ <html:input
+ id="network-location-input"
+ class="calendar-creation-text-input"
+ flex="1"
+ oninput="checkRequired()"
+ placeholder="&location.placeholder;"
+ default-placeholder="&location.placeholder;"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="network-nocredentials-row">
+ <html:th> </html:th>
+ <html:td>
+ <checkbox
+ id="network-nocredentials-checkbox"
+ label="&network.nocredentials.label;"
+ oncommand="updateNoCredentials(this.checked)"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="network-cache-row">
+ <html:th> </html:th>
+ <html:td>
+ <checkbox
+ id="network-cache-checkbox"
+ label="&calendarproperties.cache3.label;"
+ checked="true"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr class="network-status-row" status="none">
+ <html:th>
+ <html:img
+ class="network-status-image"
+ src="chrome://global/skin/icons/loading.png"
+ srcset="chrome://global/skin/icons/loading@2x.png 2x"
+ alt=""
+ />
+ </html:th>
+ <html:td>
+ <description class="status-label network-loading-label"
+ >&network.loading.description;</description
+ >
+ <description class="status-label network-notfound-label"
+ >&network.notfound.description;</description
+ >
+ <description class="status-label network-authfail-label"
+ >&network.authfail.description;</description
+ >
+ </html:td>
+ </html:tr>
+ </html:table>
+
+ <!-- Panel: Select Calendars -->
+ <vbox
+ id="panel-select-calendars"
+ flex="1"
+ buttonlabelaccept="&buttons.subscribe.label;"
+ buttonaccesskeyaccept="&buttons.subscribe.accesskey;"
+ buttonlabelextra2="&buttons.back.label;"
+ buttonaccesskeyextra2="&buttons.back.accesskey;"
+ hidden="true"
+ >
+ <description id="network-selectcalendar-description-single"
+ >&network.subscribe.single.description;</description
+ >
+ <description id="network-selectcalendar-description-multiple" hidden="true"
+ >&network.subscribe.multiple.description;</description
+ >
+ <hbox id="network-selectcalendar-providertype-box" align="center" hidden="true">
+ <label id="network-selectcalendar-providertype-label" value="&calendartype.label;" />
+ <menulist
+ id="network-selectcalendar-providertype-menulist"
+ flex="1"
+ onselect="selectProvider(this.selectedItem.value)"
+ >
+ <menupopup id="network-selectcalendar-providertype-menupopup" />
+ </menulist>
+ </hbox>
+ <richlistbox
+ id="network-calendar-list"
+ propertiesbuttonlabel="&calendar.context.properties.label;"
+ />
+ </vbox>
+
+ <!-- Panel: Add-on Calendar Settings -->
+ <!-- Populated dynamically by add-ons that need UI for a particular calendar type. -->
+ <vbox
+ id="panel-addon-calendar-settings"
+ buttonlabelaccept="&buttons.create.label;"
+ buttonaccesskeyaccept="&buttons.create.accesskey;"
+ buttonlabelextra2="&buttons.back.label;"
+ buttonaccesskeyextra2="&buttons.back.accesskey;"
+ hidden="true"
+ />
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-dialog-utils.js b/comm/calendar/base/content/dialogs/calendar-dialog-utils.js
new file mode 100644
index 0000000000..d002bcdf5c
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-dialog-utils.js
@@ -0,0 +1,662 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gInTab, gMainWindow, gTabmail, intializeTabOrWindowVariables,
+ * dispose, setDialogId, loadReminders, saveReminder,
+ * commonUpdateReminder, updateLink,
+ * adaptScheduleAgent, sendMailToOrganizer,
+ * openAttachmentFromItemSummary,
+ */
+
+/* import-globals-from ../item-editing/calendar-item-iframe.js */
+/* import-globals-from ../calendar-ui-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+// Variables related to whether we are in a tab or a window dialog.
+var gInTab = false;
+var gMainWindow = null;
+var gTabmail = null;
+
+/**
+ * Initialize variables for tab vs window.
+ */
+function intializeTabOrWindowVariables() {
+ let args = window.arguments[0];
+ gInTab = args.inTab;
+ if (gInTab) {
+ gTabmail = parent.document.getElementById("tabmail");
+ gMainWindow = parent;
+ } else {
+ gMainWindow = parent.opener;
+ }
+}
+
+/**
+ * Dispose of controlling operations of this event dialog. Uses
+ * window.arguments[0].job.dispose()
+ */
+function dispose() {
+ let args = window.arguments[0];
+ if (args.job && args.job.dispose) {
+ args.job.dispose();
+ }
+}
+
+/**
+ * Sets the id of a Dialog to another value to allow different CSS styles
+ * to be used.
+ *
+ * @param aDialog The Dialog to be changed.
+ * @param aNewId The new ID as String.
+ */
+function setDialogId(aDialog, aNewId) {
+ aDialog.setAttribute("id", aNewId);
+ applyPersistedProperties(aDialog);
+}
+
+/**
+ * Apply the persisted properties from xulstore.json on a dialog based on the current dialog id.
+ * This needs to be invoked after changing a dialog id while loading to apply the values for the
+ * new dialog id.
+ *
+ * @param aDialog The Dialog to apply the property values for
+ */
+function applyPersistedProperties(aDialog) {
+ let xulStore = Services.xulStore;
+ // first we need to detect which properties are persisted
+ let persistedProps = aDialog.getAttribute("persist") || "";
+ if (persistedProps == "") {
+ return;
+ }
+ let propNames = persistedProps.split(" ");
+ let { outerWidth: width, outerHeight: height } = aDialog;
+ let doResize = false;
+ // now let's apply persisted values if applicable
+ for (let propName of propNames) {
+ if (xulStore.hasValue(aDialog.baseURI, aDialog.id, propName)) {
+ let propValue = xulStore.getValue(aDialog.baseURI, aDialog.id, propName);
+ if (propName == "width") {
+ width = propValue;
+ doResize = true;
+ } else if (propName == "height") {
+ height = propValue;
+ doResize = true;
+ } else {
+ aDialog.setAttribute(propName, propValue);
+ }
+ }
+ }
+
+ if (doResize) {
+ aDialog.ownerGlobal.resizeTo(width, height);
+ }
+}
+
+/**
+ * Create a calIAlarm from the given menuitem. The menuitem must have the
+ * following attributes: unit, length, origin, relation.
+ *
+ * @param {Element} aMenuitem - The menuitem to create the alarm from.
+ * @param {calICalendar} aCalendar - The calendar for getting the default alarm type.
+ * @returns The calIAlarm with information from the menuitem.
+ */
+function createReminderFromMenuitem(aMenuitem, aCalendar) {
+ let reminder = aMenuitem.reminder || new CalAlarm();
+ // clone immutable reminders if necessary to set default values
+ let isImmutable = !reminder.isMutable;
+ if (isImmutable) {
+ reminder = reminder.clone();
+ }
+ let offset = cal.createDuration();
+ offset[aMenuitem.getAttribute("unit")] = aMenuitem.getAttribute("length");
+ offset.normalize();
+ offset.isNegative = aMenuitem.getAttribute("origin") == "before";
+ reminder.related =
+ aMenuitem.getAttribute("relation") == "START"
+ ? Ci.calIAlarm.ALARM_RELATED_START
+ : Ci.calIAlarm.ALARM_RELATED_END;
+ reminder.offset = offset;
+ reminder.action = getDefaultAlarmType(aCalendar);
+ // make reminder immutable in case it was before
+ if (isImmutable) {
+ reminder.makeImmutable();
+ }
+ return reminder;
+}
+
+/**
+ * This function opens the needed dialogs to edit the reminder. Note however
+ * that calling this function from an extension is not recommended. To allow an
+ * extension to open the reminder dialog, set the menulist "item-alarm" to the
+ * custom menuitem and call updateReminder().
+ *
+ * @param {Element} reminderList - The reminder menu element.
+ * @param {calIEvent | calIToDo} calendarItem - The calendar item.
+ * @param {number} lastAlarmSelection - Index of previously selected item in the menu.
+ * @param {calICalendar} calendar - The calendar to use.
+ * @param {calITimezone} [timezone] - Timezone to use.
+ */
+function editReminder(
+ reminderList,
+ calendarItem,
+ lastAlarmSelection,
+ calendar,
+ timezone = cal.dtz.defaultTimezone
+) {
+ let customItem = reminderList.querySelector(".reminder-custom-menuitem");
+
+ let args = {
+ reminders: customItem.reminders,
+ item: calendarItem,
+ timezone,
+ calendar,
+ // While these are "just" callbacks, the dialog is opened modally, so aside
+ // from what's needed to set up the reminders, nothing else needs to be done.
+ onOk(reminders) {
+ customItem.reminders = reminders;
+ },
+ onCancel() {
+ reminderList.selectedIndex = lastAlarmSelection;
+ },
+ };
+
+ window.setCursor("wait");
+
+ // open the dialog modally
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-reminder.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable,centerscreen",
+ args
+ );
+}
+
+/**
+ * Update the reminder details from the selected alarm. This shows a string
+ * describing the reminder set, or nothing in case a preselected reminder was
+ * chosen.
+ *
+ * @param {Element} reminderDetails - The reminder details element.
+ * @param {Element} reminderList - The reminder menu element.
+ * @param {calICalendar} calendar - The calendar.
+ */
+function updateReminderDetails(reminderDetails, reminderList, calendar) {
+ // find relevant elements in the document
+ let reminderMultipleLabel = reminderDetails.querySelector(".reminder-multiple-alarms-label");
+ let iconBox = reminderDetails.querySelector(".alarm-icons-box");
+ let reminderSingleLabel = reminderDetails.querySelector(".reminder-single-alarms-label");
+
+ let reminders = reminderList.querySelector(".reminder-custom-menuitem").reminders || [];
+
+ let actionValues = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"];
+ let actionMap = {};
+ for (let action of actionValues) {
+ actionMap[action] = true;
+ }
+
+ // Filter out any unsupported action types.
+ reminders = reminders.filter(x => x.action in actionMap);
+
+ if (reminderList.value == "custom") {
+ // Depending on how many alarms we have, show either the "Multiple Alarms"
+ // label or the single reminder label.
+ reminderMultipleLabel.hidden = reminders.length < 2;
+ reminderSingleLabel.hidden = reminders.length > 1;
+
+ cal.alarms.addReminderImages(iconBox, reminders);
+
+ // If there is only one reminder, display the reminder string
+ if (reminders.length == 1) {
+ reminderSingleLabel.value = reminders[0].toString(window.calendarItem);
+ }
+ } else {
+ reminderMultipleLabel.setAttribute("hidden", "true");
+ reminderSingleLabel.setAttribute("hidden", "true");
+ if (reminderList.value == "none") {
+ // No reminder selected means show no icons.
+ while (iconBox.lastChild) {
+ iconBox.lastChild.remove();
+ }
+ } else {
+ // This is one of the predefined dropdown items. We should show a
+ // single icon in the icons box to tell the user what kind of alarm
+ // this will be.
+ let mockAlarm = new CalAlarm();
+ mockAlarm.action = getDefaultAlarmType(calendar);
+ cal.alarms.addReminderImages(iconBox, [mockAlarm]);
+ }
+ }
+}
+
+/**
+ * Check whether a reminder matches one of the default menu items or not.
+ *
+ * @param {calIAlarm} reminder - The reminder to match to a menu item.
+ * @param {Element} reminderList - The reminder menu element.
+ * @param {calICalendar} calendar - The current calendar, to get the default alarm type.
+ * @returns {boolean} True if the reminder matches a menu item, false if not.
+ */
+function matchCustomReminderToMenuitem(reminder, reminderList, calendar) {
+ let defaultAlarmType = getDefaultAlarmType(calendar);
+ let reminderPopup = reminderList.menupopup;
+ if (
+ reminder.related != Ci.calIAlarm.ALARM_RELATED_ABSOLUTE &&
+ reminder.offset &&
+ reminder.action == defaultAlarmType
+ ) {
+ // Exactly one reminder that's not absolute, we may be able to match up
+ // popup items.
+ let relation = reminder.related == Ci.calIAlarm.ALARM_RELATED_START ? "START" : "END";
+
+ // If the time duration for offset is 0, means the reminder is '0 minutes before'
+ let origin = reminder.offset.inSeconds == 0 || reminder.offset.isNegative ? "before" : "after";
+
+ let unitMap = {
+ days: 86400,
+ hours: 3600,
+ minutes: 60,
+ };
+
+ for (let menuitem of reminderPopup.children) {
+ if (
+ menuitem.localName == "menuitem" &&
+ menuitem.hasAttribute("length") &&
+ menuitem.getAttribute("origin") == origin &&
+ menuitem.getAttribute("relation") == relation
+ ) {
+ let unitMult = unitMap[menuitem.getAttribute("unit")] || 1;
+ let length = menuitem.getAttribute("length") * unitMult;
+
+ if (Math.abs(reminder.offset.inSeconds) == length) {
+ menuitem.reminder = reminder.clone();
+ reminderList.selectedItem = menuitem;
+ // We've selected an item, so we are done here.
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Load an item's reminders into the dialog.
+ *
+ * @param {calIAlarm[]} reminders - An array of alarms to load.
+ * @param {Element} reminderList - The reminders menulist element.
+ * @param {calICalendar} calendar - The calendar the item belongs to.
+ * @returns {number} Index of the selected item in reminders menu.
+ */
+function loadReminders(reminders, reminderList, calendar) {
+ // Select 'no reminder' by default.
+ reminderList.selectedIndex = 0;
+
+ if (!reminders || !reminders.length) {
+ // No reminders selected, we are done
+ return reminderList.selectedIndex;
+ }
+
+ if (
+ reminders.length > 1 ||
+ !matchCustomReminderToMenuitem(reminders[0], reminderList, calendar)
+ ) {
+ // If more than one alarm is selected, or we didn't find a matching item
+ // above, then select the "custom" item and attach the item's reminders to
+ // it.
+ reminderList.value = "custom";
+ reminderList.querySelector(".reminder-custom-menuitem").reminders = reminders;
+ }
+
+ // Return the selected index so it can be remembered.
+ return reminderList.selectedIndex;
+}
+
+/**
+ * Save the selected reminder into the passed item.
+ *
+ * @param {calIEvent | calITodo} item The calendar item to save the reminder into.
+ * @param {calICalendar} calendar - The current calendar.
+ * @param {Element} reminderList - The reminder menu element.
+ */
+function saveReminder(item, calendar, reminderList) {
+ // We want to compare the old alarms with the new ones. If these are not
+ // the same, then clear the snooze/dismiss times
+ let oldAlarmMap = {};
+ for (let alarm of item.getAlarms()) {
+ oldAlarmMap[alarm.icalString] = true;
+ }
+
+ // Clear the alarms so we can add our new ones.
+ item.clearAlarms();
+
+ if (reminderList.value != "none") {
+ let menuitem = reminderList.selectedItem;
+ let reminders;
+
+ if (menuitem.reminders) {
+ // Custom reminder entries carry their own reminder object with
+ // them. Make sure to clone in case these are the original item's
+ // reminders.
+
+ // XXX do we need to clone here?
+ reminders = menuitem.reminders.map(x => x.clone());
+ } else {
+ // Pre-defined entries specify the necessary information
+ // as attributes attached to the menuitem elements.
+ reminders = [createReminderFromMenuitem(menuitem, calendar)];
+ }
+
+ let alarmCaps = item.calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"];
+ let alarmActions = {};
+ for (let action of alarmCaps) {
+ alarmActions[action] = true;
+ }
+
+ // Make sure only alarms are saved that work in the given calendar.
+ reminders.filter(x => x.action in alarmActions).forEach(item.addAlarm, item);
+ }
+
+ // Compare alarms to see if something changed.
+ for (let alarm of item.getAlarms()) {
+ let ics = alarm.icalString;
+ if (ics in oldAlarmMap) {
+ // The new alarm is also in the old set, remember this
+ delete oldAlarmMap[ics];
+ } else {
+ // The new alarm is not in the old set, this means the alarms
+ // differ and we can break out.
+ oldAlarmMap[ics] = true;
+ break;
+ }
+ }
+
+ // If the alarms differ, clear the snooze/dismiss properties
+ if (Object.keys(oldAlarmMap).length > 0) {
+ let cmp = "X-MOZ-SNOOZE-TIME";
+
+ // Recurring item alarms potentially have more snooze props, remove them
+ // all.
+ let propsToDelete = [];
+ for (let [name] of item.properties) {
+ if (name.startsWith(cmp)) {
+ propsToDelete.push(name);
+ }
+ }
+
+ item.alarmLastAck = null;
+ propsToDelete.forEach(item.deleteProperty, item);
+ }
+}
+
+/**
+ * Get the default alarm type for the currently selected calendar. If the
+ * calendar supports DISPLAY alarms, this is the default. Otherwise it is the
+ * first alarm action the calendar supports.
+ *
+ * @param {calICalendar} calendar - The calendar to use.
+ * @returns {string} The default alarm type.
+ */
+function getDefaultAlarmType(calendar) {
+ let alarmCaps = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"];
+ return alarmCaps.includes("DISPLAY") ? "DISPLAY" : alarmCaps[0];
+}
+
+/**
+ * Common update functions for both event dialogs. Called when a reminder has
+ * been selected from the menulist.
+ *
+ * @param {Element} reminderList - The reminders menu element.
+ * @param {calIEvent | calITodo} calendarItem - The calendar item.
+ * @param {number} lastAlarmSelection - Index of the previous selection in the reminders menu.
+ * @param {Element} reminderDetails - The reminder details element.
+ * @param {calITimezone} timezone - The relevant timezone.
+ * @param {boolean} suppressDialogs - If true, controls are updated without prompting
+ * for changes with the dialog
+ * @returns {number} Index of the item selected in the reminders menu.
+ */
+function commonUpdateReminder(
+ reminderList,
+ calendarItem,
+ lastAlarmSelection,
+ calendar,
+ reminderDetails,
+ timezone,
+ suppressDialogs
+) {
+ // if a custom reminder has been selected, we show the appropriate
+ // dialog in order to allow the user to specify the details.
+ // the result will be placed in the 'reminder-custom-menuitem' tag.
+ if (reminderList.value == "custom") {
+ // Clear the reminder icons first, this will make sure that while the
+ // dialog is open the default reminder image is not shown which may
+ // confuse users.
+ let iconBox = reminderDetails.querySelector(".alarm-icons-box");
+ while (iconBox.lastChild) {
+ iconBox.lastChild.remove();
+ }
+
+ // show the dialog. This call blocks until the dialog is closed. Don't
+ // pop up the dialog if aSuppressDialogs was specified or if this
+ // happens during initialization of the dialog
+ if (!suppressDialogs && reminderList.hasAttribute("last-value")) {
+ editReminder(reminderList, calendarItem, lastAlarmSelection, calendar, timezone);
+ }
+
+ if (reminderList.value == "custom") {
+ // Only do this if the 'custom' item is still selected. If the edit
+ // reminder dialog was canceled then the previously selected
+ // menuitem is selected, which may not be the custom menuitem.
+
+ // If one or no reminders were selected, we have a chance of mapping
+ // them to the existing elements in the dropdown.
+ let customItem = reminderList.selectedItem;
+ if (customItem.reminders.length == 0) {
+ // No reminder was selected
+ reminderList.value = "none";
+ } else if (customItem.reminders.length == 1) {
+ // We might be able to match the custom reminder with one of the
+ // default menu items.
+ matchCustomReminderToMenuitem(customItem.reminders[0], reminderList, calendar);
+ }
+ }
+ }
+
+ reminderList.setAttribute("last-value", reminderList.value);
+
+ // possibly the selected reminder conflicts with the item.
+ // for example an end-relation combined with a task without duedate
+ // is an invalid state we need to take care of. we take the same
+ // approach as with recurring tasks. in case the reminder is related
+ // to the entry date we check the entry date automatically and disable
+ // the checkbox. the same goes for end related reminder and the due date.
+ if (calendarItem.isTodo()) {
+ // In general, (re-)enable the due/entry checkboxes. This will be
+ // changed in case the alarms are related to START/END below.
+ enableElementWithLock("todo-has-duedate", "reminder-lock");
+ enableElementWithLock("todo-has-entrydate", "reminder-lock");
+
+ let menuitem = reminderList.selectedItem;
+ if (menuitem.value != "none") {
+ // In case a reminder is selected, retrieve the array of alarms from
+ // it, or create one from the currently selected menuitem.
+ let reminders = menuitem.reminders || [createReminderFromMenuitem(menuitem, calendar)];
+
+ // If a reminder is related to the entry date...
+ if (reminders.some(x => x.related == Ci.calIAlarm.ALARM_RELATED_START)) {
+ // ...automatically check 'has entrydate'.
+ if (!document.getElementById("todo-has-entrydate").checked) {
+ document.getElementById("todo-has-entrydate").checked = true;
+
+ // Make sure gStartTime is properly initialized
+ updateEntryDate();
+ }
+
+ // Disable the checkbox to indicate that we need the entry-date.
+ disableElementWithLock("todo-has-entrydate", "reminder-lock");
+ }
+
+ // If a reminder is related to the due date...
+ if (reminders.some(x => x.related == Ci.calIAlarm.ALARM_RELATED_END)) {
+ // ...automatically check 'has duedate'.
+ if (!document.getElementById("todo-has-duedate").checked) {
+ document.getElementById("todo-has-duedate").checked = true;
+
+ // Make sure gStartTime is properly initialized
+ updateDueDate();
+ }
+
+ // Disable the checkbox to indicate that we need the entry-date.
+ disableElementWithLock("todo-has-duedate", "reminder-lock");
+ }
+ }
+ }
+ updateReminderDetails(reminderDetails, reminderList, calendar);
+
+ // Return the current reminder drop down selection index so it can be remembered.
+ return reminderList.selectedIndex;
+}
+
+/**
+ * Updates the related link on the dialog. Currently only used by the
+ * read-only summary dialog.
+ *
+ * @param {string} itemUrlString - The calendar item URL as a string.
+ * @param {Element} linkRow - The row containing the link.
+ * @param {Element} urlLink - The link element itself.
+ */
+function updateLink(itemUrlString, linkRow, urlLink) {
+ let linkCommand = document.getElementById("cmd_toggle_link");
+
+ if (linkCommand) {
+ // Disable if there is no url.
+ linkCommand.disabled = !itemUrlString;
+ }
+
+ if ((linkCommand && linkCommand.getAttribute("checked") != "true") || !itemUrlString.length) {
+ // Hide if there is no url, or the menuitem was chosen so that the url
+ // should be hidden
+ linkRow.hidden = true;
+ } else {
+ let handler, uri;
+ try {
+ uri = Services.io.newURI(itemUrlString);
+ handler = Services.io.getProtocolHandler(uri.scheme);
+ } catch (e) {
+ // No protocol handler for the given protocol, or invalid uri
+ linkRow.hidden = true;
+ return;
+ }
+
+ // Only show if its either an internal protocol handler, or its external
+ // and there is an external app for the scheme
+ handler = cal.wrapInstance(handler, Ci.nsIExternalProtocolHandler);
+ let show = !handler || handler.externalAppExistsForScheme(uri.scheme);
+ linkRow.hidden = !show;
+
+ setTimeout(() => {
+ // HACK the url link doesn't crop when setting the value in onLoad
+ urlLink.setAttribute("value", itemUrlString);
+ urlLink.setAttribute("href", itemUrlString);
+ }, 0);
+ }
+}
+
+/**
+ * Adapts the scheduling responsibility for caldav servers according to RfC 6638
+ * based on forceEmailScheduling preference for the respective calendar
+ *
+ * @param {calIEvent|calIToDo} aItem - Item to apply the change on
+ */
+function adaptScheduleAgent(aItem) {
+ if (
+ aItem.calendar &&
+ aItem.calendar.type == "caldav" &&
+ aItem.calendar.getProperty("capabilities.autoschedule.supported")
+ ) {
+ let identity = aItem.calendar.getProperty("imip.identity");
+ let orgEmail = identity && identity.QueryInterface(Ci.nsIMsgIdentity).email;
+ let organizerAction = aItem.organizer && orgEmail && aItem.organizer.id == "mailto:" + orgEmail;
+ if (aItem.calendar.getProperty("forceEmailScheduling")) {
+ cal.LOG("Enforcing clientside email based scheduling.");
+ // for attendees, we change schedule-agent only in case of an
+ // organizer triggered action
+ if (organizerAction) {
+ aItem.getAttendees().forEach(aAttendee => {
+ // overwriting must always happen consistently for all
+ // attendees regarding SERVER or CLIENT but must not override
+ // e.g. NONE, so we only overwrite if the param is set to
+ // SERVER or doesn't exist
+ if (
+ aAttendee.getProperty("SCHEDULE-AGENT") == "SERVER" ||
+ !aAttendee.getProperty("SCHEDULE-AGENT")
+ ) {
+ aAttendee.setProperty("SCHEDULE-AGENT", "CLIENT");
+ aAttendee.deleteProperty("SCHEDULE-STATUS");
+ aAttendee.deleteProperty("SCHEDULE-FORCE-SEND");
+ }
+ });
+ } else if (
+ aItem.organizer &&
+ (aItem.organizer.getProperty("SCHEDULE-AGENT") == "SERVER" ||
+ !aItem.organizer.getProperty("SCHEDULE-AGENT"))
+ ) {
+ // for organizer, we change the schedule-agent only in case of
+ // an attendee triggered action
+ aItem.organizer.setProperty("SCHEDULE-AGENT", "CLIENT");
+ aItem.organizer.deleteProperty("SCHEDULE-STATUS");
+ aItem.organizer.deleteProperty("SCHEDULE-FORCE-SEND");
+ }
+ } else if (organizerAction) {
+ aItem.getAttendees().forEach(aAttendee => {
+ if (aAttendee.getProperty("SCHEDULE-AGENT") == "CLIENT") {
+ aAttendee.deleteProperty("SCHEDULE-AGENT");
+ }
+ });
+ } else if (aItem.organizer && aItem.organizer.getProperty("SCHEDULE-AGENT") == "CLIENT") {
+ aItem.organizer.deleteProperty("SCHEDULE-AGENT");
+ }
+ }
+}
+
+/**
+ * Extracts the item's organizer and opens a compose window to send the
+ * organizer an email.
+ *
+ * @param {calIEvent | calITodo} item - The calendar item.
+ */
+function sendMailToOrganizer(item) {
+ let organizer = item.organizer;
+ let email = cal.email.getAttendeeEmail(organizer, true);
+ let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]);
+ let identity = item.calendar.getProperty("imip.identity");
+ cal.email.sendTo(email, emailSubject, null, identity);
+}
+
+/**
+ * Opens an attachment.
+ *
+ * @param {AUTF8String} aAttachmentId The hashId of the attachment to open.
+ * @param {calIEvent | calITodo} item The calendar item.
+ */
+function openAttachmentFromItemSummary(aAttachmentId, item) {
+ if (!aAttachmentId) {
+ return;
+ }
+ let attachments = item
+ .getAttachments()
+ .filter(aAttachment => aAttachment.hashId == aAttachmentId);
+
+ if (attachments.length && attachments[0].uri && attachments[0].uri.spec != "about:blank") {
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(attachments[0].uri);
+ }
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-error-prompt.js b/comm/calendar/base/content/dialogs/calendar-error-prompt.js
new file mode 100644
index 0000000000..538d360a99
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-error-prompt.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+window.addEventListener("DOMContentLoaded", loadErrorPrompt);
+
+function loadErrorPrompt() {
+ let args = window.arguments[0].QueryInterface(Ci.nsIDialogParamBlock);
+ document.getElementById("general-text").value = args.GetString(0);
+ document.getElementById("error-code").value = args.GetString(1);
+ document.getElementById("error-description").value = args.GetString(2);
+}
+function toggleDetails() {
+ let options = document.getElementById("details-box");
+ options.collapsed = !options.collapsed;
+ // Grow the window height if the details overflow.
+ window.resizeTo(
+ window.outerWidth,
+ document.body.scrollHeight + window.outerHeight - window.innerHeight
+ );
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml b/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml
new file mode 100644
index 0000000000..8454809bd1
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-error-prompt.xhtml
@@ -0,0 +1,59 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%dtd2; ]>
+<html
+ id="calendar-error-prompt"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&calendar.error.title;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-error-prompt.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog buttons="accept" style="width: 500px">
+ <html:textarea
+ id="general-text"
+ class="plain"
+ readonly="readonly"
+ rows="3"
+ style="resize: none"
+ ></html:textarea>
+ <hbox>
+ <button id="details-button" label="&calendar.error.detail;" oncommand="toggleDetails()" />
+ <spacer flex="1" />
+ </hbox>
+ <vbox id="details-box" collapsed="true" persist="collapsed">
+ <hbox>
+ <label value="&calendar.error.code;" />
+ <label id="error-code" value="" />
+ </hbox>
+ <vbox>
+ <label value="&calendar.error.description;" control="error-description" />
+ <html:textarea
+ id="error-description"
+ class="plain"
+ readonly="readonly"
+ rows="5"
+ style="resize: none"
+ ></html:textarea>
+ </vbox>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js
new file mode 100644
index 0000000000..1efb94d446
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.js
@@ -0,0 +1,1601 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozXULElement */
+/* import-globals-from ../calendar-ui-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+var readOnly = false;
+
+// The UI elements in this dialog. Initialised in the DOMContentLoaded handler.
+var attendeeList;
+var dayHeaderInner;
+var dayHeaderOuter;
+var freebusyGrid;
+var freebusyGridBackground;
+var freebusyGridInner;
+
+// displayStartTime is midnight before the first displayed date, in the default timezone.
+// displayEndTime is midnight after the last displayed date, in the default timezone.
+// Initialised in the load event handler.
+var displayStartTime;
+var displayEndTime;
+var numberDaysDisplayed;
+var numberDaysDisplayedPref = Services.prefs.getIntPref("calendar.view.attendees.visibleDays", 16);
+var showOnlyWholeDays = Services.prefs.getBoolPref(
+ "calendar.view.attendees.showOnlyWholeDays",
+ false
+);
+var dayStartHour = Services.prefs.getIntPref("calendar.view.daystarthour", 8);
+var dayEndHour = Services.prefs.getIntPref("calendar.view.dayendhour", 17);
+
+var updateByFunction = false; // To avoid triggering eventListener on timePicker which would lead to an error when triggering.
+
+var previousStartTime;
+var previousEndTime;
+var previousTimezone;
+
+var displayStartHour = 0; // Display start hour.
+var displayEndHour = 24; // Display end hour.
+var showCompleteDay = true; // Display of the whole day.
+
+var defaultEventLength = Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+
+var zoom = {
+ zoomInButton: null,
+ zoomOutButton: null,
+ levels: [
+ {
+ // Total width in pixels of one day.
+ dayWidth: 360,
+ // Number of major columns a day is divided into. Each dividing line is labelled.
+ columnCount: 4,
+ // Duration of each major column.
+ columnDuration: cal.createDuration("PT6H"),
+ // The width in pixels of one column.
+ columnWidth: 90,
+ // The width in pixels of one second.
+ secondWidth: 360 / 24 / 3600,
+ // Which background grid to show.
+ gridClass: "threeMinorColumns",
+ },
+ {
+ dayWidth: 720,
+ columnCount: 8,
+ columnDuration: cal.createDuration("PT3H"),
+ columnWidth: 90,
+ secondWidth: 720 / 24 / 3600,
+ gridClass: "threeMinorColumns",
+ },
+ {
+ dayWidth: 1440,
+ columnCount: 24,
+ columnDuration: cal.createDuration("PT1H"),
+ columnWidth: 60,
+ secondWidth: 1440 / 24 / 3600,
+ gridClass: "twoMinorColumns",
+ },
+ {
+ dayWidth: 2880,
+ columnCount: 48,
+ columnDuration: cal.createDuration("PT30M"),
+ columnWidth: 60,
+ secondWidth: 2880 / 24 / 3600,
+ gridClass: "twoMinorColumns",
+ },
+ ],
+ currentLevel: null,
+
+ init() {
+ this.zoomInButton = document.getElementById("zoom-in-button");
+ this.zoomOutButton = document.getElementById("zoom-out-button");
+
+ this.zoomInButton.addEventListener("command", () => this.level++);
+ this.zoomOutButton.addEventListener("command", () => this.level--);
+ },
+ get level() {
+ return this.currentLevel;
+ },
+ set level(newZoomLevel) {
+ if (newZoomLevel < 0) {
+ newZoomLevel = 0;
+ } else if (newZoomLevel >= this.levels.length) {
+ newZoomLevel = this.levels.length - 1;
+ }
+ this.zoomInButton.disabled = newZoomLevel == this.levels.length - 1;
+ this.zoomOutButton.disabled = newZoomLevel == 0;
+
+ if (!showCompleteDay) {
+ // To block to be in max dezoom in reduced display mode.
+ this.zoomOutButton.disabled = newZoomLevel == 1;
+
+ if (
+ (dayEndHour - dayStartHour) % this.levels[this.currentLevel - 1].columnDuration.hours !=
+ 0
+ ) {
+ // To avoid being in zoom level where the interface is not adapted.
+ this.zoomOutButton.disabled = true;
+ }
+ }
+
+ if (newZoomLevel == this.currentLevel) {
+ return;
+ }
+ this.currentLevel = newZoomLevel;
+ displayEndTime = displayStartTime.clone();
+
+ emptyGrid();
+ for (let attendee of attendeeList.getElementsByTagName("event-attendee")) {
+ attendee.clearFreeBusy();
+ }
+
+ for (let gridClass of ["twoMinorColumns", "threeMinorColumns"]) {
+ if (this.levels[newZoomLevel].gridClass == gridClass) {
+ dayHeaderInner.classList.add(gridClass);
+ freebusyGridInner.classList.add(gridClass);
+ } else {
+ dayHeaderInner.classList.remove(gridClass);
+ freebusyGridInner.classList.remove(gridClass);
+ }
+ }
+ fillGrid();
+ eventBar.update(true);
+ },
+ get dayWidth() {
+ return this.levels[this.currentLevel].dayWidth;
+ },
+ get columnCount() {
+ return this.levels[this.currentLevel].columnCount;
+ },
+ get columnDuration() {
+ return this.levels[this.currentLevel].columnDuration;
+ },
+ get columnWidth() {
+ return this.levels[this.currentLevel].columnWidth;
+ },
+ get secondWidth() {
+ return this.levels[this.currentLevel].secondWidth;
+ },
+};
+
+var eventBar = {
+ dragDistance: 0,
+ dragStartX: null,
+ eventBarBottom: "event-bar-bottom",
+ eventBarTop: "event-bar-top",
+
+ init() {
+ this.eventBarBottom = document.getElementById("event-bar-bottom");
+ this.eventBarTop = document.getElementById("event-bar-top");
+
+ let outer = document.getElementById("outer");
+ outer.addEventListener("dragstart", this);
+ outer.addEventListener("dragover", this);
+ outer.addEventListener("dragend", this);
+ },
+ handleEvent(event) {
+ switch (event.type) {
+ case "dragstart": {
+ this.dragStartX = event.clientX + freebusyGrid.scrollLeft;
+ let img = document.createElement("img");
+ img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
+ event.dataTransfer.setDragImage(img, 0, 0);
+ event.dataTransfer.effectAllowed = "move";
+ break;
+ }
+ case "dragover": {
+ // Snap dragging movements to half of a minor column width.
+ this.dragDistance =
+ Math.round((event.clientX + freebusyGrid.scrollLeft - this.dragStartX) / 15) * 15;
+
+ // Prevent the event from being dragged outside the grid.
+ if (
+ this.eventBarBottom.offsetLeft + this.dragDistance >= freebusyGrid.scrollLeft &&
+ // We take the size of the event not to exceed on the right side.
+ this.eventBarBottom.offsetLeft + this.eventBarBottom.offsetWidth + this.dragDistance <=
+ zoom.levels[zoom.currentLevel].dayWidth * numberDaysDisplayed
+ ) {
+ this.eventBarTop.style.transform =
+ this.eventBarBottom.style.transform = `translateX(${this.dragDistance}px)`;
+ }
+ break;
+ }
+ case "dragend": {
+ updateByFunction = true;
+ let positionFromStart = this.eventBarBottom.offsetLeft + this.dragDistance;
+ this.dragStartX = null;
+ this.eventBarTop.style.transform = this.eventBarBottom.style.transform = null;
+
+ let { startValue, endValue } = dateTimePickerUI;
+ let durationEvent;
+
+ // If the user goes into the past, the user will be able to use part of the hour before the beginning of the day.
+ // Ex: Start time of the day: 8am, End time of the day: 5:00 pm
+ // If the user moves the slot in the past but does not go to the end of the day time, they will be able to use the 7am to 8am time (except for the first shift corresponding to the minimum travel time).
+ // There is the same principle for the end of the day, but it will be for the hour following the end of the day.
+
+ // If we go back in time, we will have to calculate with endValue.
+ if (this.dragDistance < 0) {
+ durationEvent = startValue.subtractDate(endValue);
+
+ endValue = this.getDateFromPosition(
+ positionFromStart + this.eventBarBottom.offsetWidth,
+ startValue.timezone
+ );
+
+ startValue = endValue.clone();
+ startValue.addDuration(durationEvent);
+ // If you move backwards, you have to check again. Otherwise a move to the last hour of the day will date the previous hour of the start of the day.
+ // We will do our tests with the calendar timezone and not the event timezone.
+ let startValueDefaultTimezone = startValue.getInTimezone(cal.dtz.defaultTimezone);
+ if (!showCompleteDay) {
+ if (
+ !(
+ (startValueDefaultTimezone.hour >= displayStartHour ||
+ (startValueDefaultTimezone.hour == displayStartHour - 1 &&
+ startValueDefaultTimezone.minute > 0)) &&
+ startValueDefaultTimezone.hour < displayEndHour
+ )
+ ) {
+ let hoursHidden = 24 - displayEndHour + displayStartHour;
+ let reducDayDuration = cal.createDuration("-PT" + hoursHidden + "H");
+ startValue.addDuration(reducDayDuration);
+ endValue.addDuration(reducDayDuration);
+ }
+ }
+
+ if (dateTimePickerUI.allDay.checked) {
+ // BUG in icaljs
+ startValue.hour = 0;
+ startValue.minute = 0;
+ dateTimePickerUI.startValue = startValue;
+ endValue.hour = 0;
+ endValue.minute = 0;
+ dateTimePickerUI.endValue = endValue;
+ dateTimePickerUI.saveOldValues();
+ endValue.day++; // For display only.
+ } else {
+ dateTimePickerUI.startValue = startValue;
+ dateTimePickerUI.endValue = endValue;
+ }
+ } else {
+ // If we go forward in time, we will have to calculate with startValue.
+ durationEvent = endValue.subtractDate(startValue);
+
+ startValue = this.getDateFromPosition(positionFromStart, startValue.timezone);
+ endValue = startValue.clone();
+
+ if (dateTimePickerUI.allDay.checked) {
+ // BUG in icaljs
+ startValue.hour = 0;
+ startValue.minute = 0;
+ dateTimePickerUI.startValue = startValue;
+ endValue.addDuration(durationEvent);
+ endValue.hour = 0;
+ endValue.minute = 0;
+ dateTimePickerUI.endValue = endValue;
+ dateTimePickerUI.saveOldValues();
+ endValue.day++; // For display only.
+ } else {
+ dateTimePickerUI.startValue = startValue;
+ endValue.addDuration(durationEvent);
+ dateTimePickerUI.endValue = endValue;
+ }
+ }
+
+ updateChange();
+ updateByFunction = false;
+ setLeftAndWidth(this.eventBarTop, startValue, endValue);
+ setLeftAndWidth(this.eventBarBottom, startValue, endValue);
+
+ updatePreviousValues();
+ updateRange();
+ break;
+ }
+ }
+ },
+ update(shouldScroll) {
+ let { startValueForDisplay, endValueForDisplay } = dateTimePickerUI;
+ if (dateTimePickerUI.allDay.checked) {
+ endValueForDisplay.day++;
+ }
+ setLeftAndWidth(this.eventBarTop, startValueForDisplay, endValueForDisplay);
+ setLeftAndWidth(this.eventBarBottom, startValueForDisplay, endValueForDisplay);
+
+ if (shouldScroll) {
+ let scrollPoint =
+ this.eventBarBottom.offsetLeft -
+ (dayHeaderOuter.clientWidthDouble - this.eventBarBottom.clientWidthDouble) / 2;
+ if (scrollPoint < 0) {
+ scrollPoint = 0;
+ }
+ dayHeaderOuter.scrollTo(scrollPoint, 0);
+ freebusyGrid.scrollTo(scrollPoint, freebusyGrid.scrollTop);
+ }
+ },
+ getDateFromPosition(posX, timezone) {
+ let numberOfDays = Math.floor(posX / zoom.dayWidth);
+ let remainingOffset = posX - numberOfDays * zoom.dayWidth;
+
+ let duration = cal.createDuration();
+ duration.inSeconds = numberOfDays * 60 * 60 * 24 + remainingOffset / zoom.secondWidth;
+
+ let date = displayStartTime.clone();
+ // In case of full display, do not keep the fact that displayStartTime is allDay.
+ if (showCompleteDay) {
+ date.isDate = false;
+ date.hour = 0;
+ date.minute = 0;
+ }
+ date = date.getInTimezone(timezone); // We reapply the time zone of the event.
+ date.addDuration(duration);
+ return date;
+ },
+};
+
+var dateTimePickerUI = {
+ allDay: "all-day",
+ start: "event-starttime",
+ startZone: "timezone-starttime",
+ end: "event-endtime",
+ endZone: "timezone-endtime",
+
+ init() {
+ for (let key of ["allDay", "start", "startZone", "end", "endZone"]) {
+ this[key] = document.getElementById(this[key]);
+ }
+ },
+ addListeners() {
+ this.allDay.addEventListener("command", () => this.changeAllDay());
+ this.start.addEventListener("change", () => eventBar.update(false));
+ this.startZone.addEventListener("click", () => this.editTimezone(this.startZone));
+ this.endZone.addEventListener("click", () => this.editTimezone(this.endZone));
+ },
+
+ get startValue() {
+ return cal.dtz.jsDateToDateTime(this.start.value, this.startZone._zone);
+ },
+ set startValue(value) {
+ // Set the zone first, because the change in time will trigger an update.
+ this.startZone._zone = value.timezone;
+ this.startZone.value = value.timezone.displayName || value.timezone.tzid;
+ this.start.value = cal.dtz.dateTimeToJsDate(value.getInTimezone(cal.dtz.floating));
+ },
+ get startValueForDisplay() {
+ return this.startValue.getInTimezone(cal.dtz.defaultTimezone);
+ },
+ get endValue() {
+ return cal.dtz.jsDateToDateTime(this.end.value, this.endZone._zone);
+ },
+ set endValue(value) {
+ // Set the zone first, because the change in time will trigger an update.
+ this.endZone._zone = value.timezone;
+ this.endZone.value = value.timezone.displayName || value.timezone.tzid;
+ this.end.value = cal.dtz.dateTimeToJsDate(value.getInTimezone(cal.dtz.floating));
+ },
+ get endValueForDisplay() {
+ return this.endValue.getInTimezone(cal.dtz.defaultTimezone);
+ },
+
+ changeAllDay() {
+ updateByFunction = true;
+ let allDay = this.allDay.checked;
+ if (allDay) {
+ document.getElementById("event-starttime").setAttribute("timepickerdisabled", true);
+ document.getElementById("event-endtime").setAttribute("timepickerdisabled", true);
+ } else {
+ document.getElementById("event-starttime").removeAttribute("timepickerdisabled");
+ document.getElementById("event-endtime").removeAttribute("timepickerdisabled");
+ }
+
+ if (allDay) {
+ previousTimezone = this.startValue.timezone;
+ // Store date-times and related timezones so we can restore
+ // if the user unchecks the "all day" checkbox.
+ this.saveOldValues();
+
+ let { startValue, endValue } = this;
+
+ // When events that end at 0:00 become all-day events, we need to
+ // subtract a day from the end date because the real end is midnight.
+ if (endValue.hour == 0 && endValue.minute == 0) {
+ let tempStartValue = startValue.clone();
+ let tempEndValue = endValue.clone();
+ tempStartValue.isDate = true;
+ tempEndValue.isDate = true;
+ tempStartValue.day++;
+ if (tempEndValue.compare(tempStartValue) >= 0) {
+ endValue.day--;
+ }
+ }
+
+ // In order not to have an event on the day shifted because of the timezone applied to the event, we pass the event in the current timezone.
+ endValue = endValue.getInTimezone(cal.dtz.defaultTimezone);
+ startValue = startValue.getInTimezone(cal.dtz.defaultTimezone);
+ startValue.isDate = true;
+ endValue.isDate = true;
+ this.endValue = endValue;
+ this.startValue = startValue;
+ zoom.level = 0;
+ } else if (this.start._oldValue && this.end._oldValue) {
+ // Restore date-times previously stored.
+
+ // Case of all day events that lasts several days or that has been changed to another day
+ if (
+ this.start._oldValue.getHours() == 0 &&
+ this.start._oldValue.getMinutes() == 0 &&
+ this.end._oldValue.getHours() == 0 &&
+ this.end._oldValue.getMinutes() == 0
+ ) {
+ let saveMinutes = this.end._oldValue.getMinutes();
+ this.start._oldValue.setHours(
+ cal.dtz.getDefaultStartDate(window.arguments[0].startTime).hour
+ );
+ this.end._oldValue.setHours(
+ cal.dtz.getDefaultStartDate(window.arguments[0].startTime).hour
+ );
+ let minutes = saveMinutes + defaultEventLength;
+ this.end._oldValue.setMinutes(minutes);
+ }
+
+ // Restoration of the old time zone.
+ if (previousTimezone) {
+ this.startZone._zone = previousTimezone;
+ this.startZone.value = previousTimezone.displayName || previousTimezone.tzid;
+ this.endZone._zone = previousTimezone;
+ this.endZone.value = previousTimezone.displayName || previousTimezone.tzid;
+ }
+
+ this.restoreOldValues();
+ if (this.start.value.getTime() == this.end.value.getTime()) {
+ // If you uncheck all day event, to avoid having an event with a duration of 0 minutes.
+ this.end.value = new Date(this.end.value.getTime() + defaultEventLength * 60000);
+ }
+ } else {
+ // The checkbox has been unchecked for the first time, the event
+ // was an "All day" type, so we have to set default values.
+ let startValue = cal.dtz.getDefaultStartDate(window.initialStartDateValue);
+ let endValue = startValue.clone();
+ endValue.minute += defaultEventLength;
+ this.startValue = startValue;
+ this.endValue = endValue;
+ }
+ updateByFunction = false;
+ updatePreviousValues();
+ updateRange();
+ },
+ editTimezone(target) {
+ let field = target == this.startZone ? "startValue" : "endValue";
+ let originalValue = this[field];
+
+ let args = {
+ calendar: window.arguments[0].calendar,
+ time: originalValue,
+ onOk: newValue => {
+ this[field] = newValue;
+ },
+ };
+
+ // Open the dialog modally
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-timezone.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ },
+ /**
+ * Store date-times and related timezones so we can restore.
+ * if the user unchecks the "all day" checkbox.
+ */
+ saveOldValues() {
+ this.start._oldValue = new Date(this.start.value);
+ this.end._oldValue = new Date(this.end.value);
+ },
+ restoreOldValues() {
+ this.end.value = this.end._oldValue;
+ this.start.value = this.start._oldValue;
+ },
+};
+
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ attendeeList = document.getElementById("attendee-list");
+ dayHeaderInner = document.getElementById("day-header-inner");
+ dayHeaderOuter = document.getElementById("day-header-outer");
+ freebusyGrid = document.getElementById("freebusy-grid");
+ freebusyGridBackground = document.getElementById("freebusy-grid-background");
+ freebusyGridInner = document.getElementById("freebusy-grid-inner");
+
+ if (numberDaysDisplayedPref < 5) {
+ Services.prefs.setIntPref("calendar.view.attendees.visibleDays", 16);
+ numberDaysDisplayedPref = 16;
+ }
+ numberDaysDisplayed = numberDaysDisplayedPref;
+
+ eventBar.init();
+ dateTimePickerUI.init();
+ zoom.init();
+
+ attendeeList.addEventListener("scroll", () => {
+ if (freebusyGrid._mouseIsOver) {
+ return;
+ }
+ freebusyGrid.scrollTop = attendeeList.scrollTop;
+ });
+ attendeeList.addEventListener("keypress", event => {
+ if (event.target.popupOpen) {
+ return;
+ }
+ let row = event.target.closest("event-attendee");
+ if (event.key == "ArrowUp" && row.previousElementSibling) {
+ event.preventDefault();
+ row.previousElementSibling.focus();
+ } else if (["ArrowDown", "Enter"].includes(event.key) && row.nextElementSibling) {
+ event.preventDefault();
+ row.nextElementSibling.focus();
+ }
+ });
+
+ freebusyGrid.addEventListener("mouseover", () => {
+ freebusyGrid._mouseIsOver = true;
+ });
+ freebusyGrid.addEventListener("mouseout", () => {
+ freebusyGrid._mouseIsOver = false;
+ });
+ freebusyGrid.addEventListener("scroll", () => {
+ if (!freebusyGrid._mouseIsOver) {
+ return;
+ }
+ dayHeaderOuter.scrollLeft = freebusyGrid.scrollLeft;
+ attendeeList.scrollTop = freebusyGrid.scrollTop;
+ });
+ },
+ { once: true }
+);
+
+window.addEventListener(
+ "load",
+ () => {
+ let [
+ { startTime, endTime, displayTimezone, calendar, organizer, attendees: existingAttendees },
+ ] = window.arguments;
+
+ if (startTime.isDate) {
+ // Shift in the display because of the timezone in case of an all day event when the interface is launched.
+ startTime = startTime.getInTimezone(cal.dtz.defaultTimezone);
+ endTime = endTime.getInTimezone(cal.dtz.defaultTimezone);
+ }
+
+ dateTimePickerUI.allDay.checked = startTime.isDate;
+ if (dateTimePickerUI.allDay.checked) {
+ document.getElementById("event-starttime").setAttribute("timepickerdisabled", true);
+ document.getElementById("event-endtime").setAttribute("timepickerdisabled", true);
+ }
+ dateTimePickerUI.startValue = startTime;
+
+ // When events that end at 0:00 become all-day events, we need to
+ // subtract a day from the end date because the real end is midnight.
+ if (startTime.isDate && endTime.hour == 0 && endTime.minute == 0) {
+ let tempStartTime = startTime.clone();
+ let tempEndTime = endTime.clone();
+ tempStartTime.isDate = true;
+ tempEndTime.isDate = true;
+ tempStartTime.day++;
+ if (tempEndTime.compare(tempStartTime) >= 0) {
+ endTime.day--;
+ }
+ }
+ dateTimePickerUI.endValue = endTime;
+
+ previousStartTime = dateTimePickerUI.startValue;
+ previousEndTime = dateTimePickerUI.endValue;
+
+ if (dateTimePickerUI.allDay.checked) {
+ dateTimePickerUI.saveOldValues();
+ }
+
+ if (displayTimezone) {
+ dateTimePickerUI.startZone.parentNode.hidden = false;
+ dateTimePickerUI.endZone.parentNode.hidden = false;
+ }
+
+ displayStartTime = cal.dtz.now();
+ displayStartTime.isDate = true;
+ displayStartTime.icalString; // BUG in icaljs
+
+ // Choose the days to display. We always display at least 5 days, more if
+ // the window is large enough. If the event is in the past, use the day of
+ // the event as the first day. If it's today, tomorrow, or the next day,
+ // use today as the first day, otherwise show two days before the event
+ // (and therefore also two days after it).
+ let difference = startTime.subtractDate(displayStartTime);
+ if (difference.isNegative) {
+ displayStartTime = startTime.clone();
+ displayStartTime.isDate = true;
+ displayStartTime.icalString; // BUG in icaljs
+ } else if (difference.compare(cal.createDuration("P2D")) > 0) {
+ displayStartTime = startTime.clone();
+ displayStartTime.isDate = true;
+ displayStartTime.icalString; // BUG in icaljs
+ displayStartTime.day -= 2;
+ }
+ displayStartTime = displayStartTime.getInTimezone(cal.dtz.defaultTimezone);
+ displayEndTime = displayStartTime.clone();
+
+ readOnly = calendar.isReadOnly;
+ zoom.level = 2;
+ layout();
+ eventBar.update(true);
+ dateTimePickerUI.addListeners();
+ addEventListener("resize", layout);
+
+ dateTimePickerUI.start.addEventListener("change", function (event) {
+ if (!updateByFunction) {
+ updateEndDate();
+ if (dateTimePickerUI.allDay.checked) {
+ dateTimePickerUI.saveOldValues();
+ }
+ updateRange();
+ }
+ });
+ dateTimePickerUI.end.addEventListener("change", function (event) {
+ if (!updateByFunction) {
+ checkDate();
+ dateTimePickerUI.saveOldValues();
+ updateChange();
+ updateRange();
+ }
+ });
+
+ const attendees = Array.from(existingAttendees);
+
+ // If there are no existing attendees, we assume that this is the first time
+ // others are being invited. By default, the organizer is added as an
+ // attendee, letting the organizer remove themselves if that isn't desired.
+ if (attendees.length == 0) {
+ if (organizer) {
+ attendees.push(organizer);
+ } else {
+ const organizerId = calendar.getProperty("organizerId");
+ if (organizerId) {
+ // We explicitly don't mark this attendee as organizer, as that has
+ // special meaning in ical.js. This represents the organizer as a
+ // potential attendee of the event and can be removed by the organizer
+ // through the interface if they do not plan on attending. By default,
+ // the organizer has accepted.
+ const organizerAsAttendee = new CalAttendee();
+ organizerAsAttendee.id = cal.email.removeMailTo(organizerId);
+ organizerAsAttendee.commonName = calendar.getProperty("organizerCN");
+ organizerAsAttendee.role = "REQ-PARTICIPANT";
+ organizerAsAttendee.participationStatus = "ACCEPTED";
+ attendees.push(organizerAsAttendee);
+ }
+ }
+ }
+
+ // Add all provided attendees to the attendee list.
+ for (let attendee of attendees) {
+ let attendeeElement = attendeeList.appendChild(document.createXULElement("event-attendee"));
+ attendeeElement.attendee = attendee;
+ }
+
+ // Add a final empty row for user input.
+ attendeeList.appendChild(document.createXULElement("event-attendee")).focus();
+ updateVerticalScrollbars();
+ },
+ { once: true }
+);
+
+window.addEventListener("dialogaccept", () => {
+ // Build the list of attendees which have been filled in.
+ let attendeeElements = attendeeList.getElementsByTagName("event-attendee");
+ const attendees = Array.from(attendeeElements)
+ .map(element => element.attendee)
+ .filter(attendee => !!attendee.id);
+
+ const [{ organizer: existingOrganizer, calendar, onOk }] = window.arguments;
+
+ // Determine the organizer of the event. If there are no attendees other than
+ // the organizer, we want to leave it as a personal event with no organizer.
+ // Only set that value if other attendees have been added.
+ let organizer;
+
+ const organizerId = existingOrganizer?.id ?? calendar.getProperty("organizerId");
+ if (organizerId) {
+ const nonOrganizerAttendees = attendees.filter(attendee => attendee.id != organizerId);
+ if (nonOrganizerAttendees.length != 0) {
+ if (existingOrganizer) {
+ organizer = existingOrganizer;
+ } else {
+ organizer = new CalAttendee();
+ organizer.id = cal.email.removeMailTo(organizerId);
+ organizer.commonName = calendar.getProperty("organizerCN");
+ organizer.isOrganizer = true;
+ }
+ } else {
+ // Since we don't set the organizer if the event is personal, don't add
+ // the organizer as an attendee either.
+ attendees.length = 0;
+ }
+ }
+
+ let { startValue, endValue } = dateTimePickerUI;
+ if (dateTimePickerUI.allDay.checked) {
+ startValue.isDate = true;
+ endValue.isDate = true;
+ }
+
+ onOk(attendees, organizer, startValue, endValue);
+});
+
+/**
+ * Passing the event change on dateTimePickerUI.end.addEventListener in function, to limit the creations of interface
+ * in case of change of day + hour (example at the time of a drag and drop), it was going to trigger the event 2 times: once for the hour and once the day
+ */
+function updateChange() {
+ if (
+ previousStartTime.getInTimezone(cal.dtz.defaultTimezone).day ==
+ dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).day &&
+ previousStartTime.getInTimezone(cal.dtz.defaultTimezone).month ==
+ dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).month &&
+ previousStartTime.getInTimezone(cal.dtz.defaultTimezone).year ==
+ dateTimePickerUI.endValue.getInTimezone(cal.dtz.defaultTimezone).year &&
+ previousEndTime.getInTimezone(cal.dtz.defaultTimezone).day ==
+ dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).day &&
+ previousEndTime.getInTimezone(cal.dtz.defaultTimezone).month ==
+ dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone).month &&
+ previousEndTime.getInTimezone(cal.dtz.defaultTimezone).year ==
+ dateTimePickerUI.endValue.getInTimezone(cal.dtz.defaultTimezone).year
+ ) {
+ eventBar.update(false);
+ } else {
+ displayStartTime = dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone);
+
+ displayStartTime.day -= 1;
+ displayStartTime.isDate = true;
+
+ displayEndTime = displayStartTime.clone();
+
+ emptyGrid();
+ for (let attendee of attendeeList.getElementsByTagName("event-attendee")) {
+ attendee.clearFreeBusy();
+ }
+
+ layout();
+ eventBar.update(true);
+ }
+ previousStartTime = dateTimePickerUI.startValue;
+ previousEndTime = dateTimePickerUI.endValue;
+}
+
+/**
+ * Handler function to be used when the Start time or End time of the event have
+ * changed.
+ * If the end date is earlier than the start date, an error is displayed and the user's modification is cancelled
+ */
+function checkDate() {
+ if (dateTimePickerUI.startValue && dateTimePickerUI.endValue) {
+ if (dateTimePickerUI.endValue.compare(dateTimePickerUI.startValue) > -1) {
+ updatePreviousValues();
+ } else {
+ // Don't allow for negative durations.
+ let callback = function () {
+ Services.prompt.alert(null, document.title, cal.l10n.getCalString("warningEndBeforeStart"));
+ };
+ setTimeout(callback, 1);
+ dateTimePickerUI.endValue = previousEndTime;
+ dateTimePickerUI.startValue = previousStartTime;
+ }
+ }
+}
+
+/**
+ * Update the end date of the event if the user changes the start date via the timepicker.
+ */
+function updateEndDate() {
+ let duration = previousEndTime.subtractDate(previousStartTime);
+
+ let endDatePrev = dateTimePickerUI.startValue.clone();
+ endDatePrev.addDuration(duration);
+
+ updateByFunction = true;
+
+ dateTimePickerUI.endValue = endDatePrev;
+
+ updateChange();
+ updatePreviousValues();
+
+ updateByFunction = false;
+}
+
+/**
+ * Updated previous values that are used to return to the previous state if the end date is before the start date
+ */
+function updatePreviousValues() {
+ previousStartTime = dateTimePickerUI.startValue;
+ previousEndTime = dateTimePickerUI.endValue;
+}
+
+/**
+ * Lays out the window on load or resize. Fills the grid and sets the size of some elements that
+ * can't easily be done with a stylesheet.
+ */
+function layout() {
+ fillGrid();
+ let spacer = document.getElementById("spacer");
+ spacer.style.height = `${dayHeaderOuter.clientHeight + 1}px`;
+ freebusyGridInner.style.minHeight = freebusyGrid.clientHeight + "px";
+ updateVerticalScrollbars();
+}
+
+/**
+ * Checks if the grid has a vertical scrollbar and updates the header to match.
+ */
+function updateVerticalScrollbars() {
+ if (freebusyGrid.scrollHeight > freebusyGrid.clientHeight) {
+ dayHeaderOuter.style.overflowY = "scroll";
+ dayHeaderInner.style.overflowY = "scroll";
+ } else {
+ dayHeaderOuter.style.overflowY = null;
+ dayHeaderInner.style.overflowY = null;
+ }
+}
+
+/**
+ * Clears the grid.
+ */
+function emptyGrid() {
+ while (dayHeaderInner.lastChild) {
+ dayHeaderInner.lastChild.remove();
+ }
+}
+
+/**
+ * Ensures at least five days are represented on the grid. If the window is wide enough, more days
+ * are shown.
+ */
+function fillGrid() {
+ setTimeRange();
+
+ if (!showCompleteDay) {
+ displayEndTime.isDate = false;
+ displayEndTime.hour = dayStartHour;
+ displayEndTime.minute = 0;
+ displayStartTime.isDate = false;
+ displayStartTime.hour = dayStartHour;
+ displayStartTime.minute = 0;
+ } else {
+ // BUG in icaljs
+ displayEndTime.isDate = true;
+ displayEndTime.hour = 0;
+ displayEndTime.minute = 0;
+ displayStartTime.isDate = true;
+ displayStartTime.hour = 0;
+ displayStartTime.minute = 0;
+ }
+
+ let oldEndTime = displayEndTime.clone();
+
+ while (
+ dayHeaderInner.childElementCount < numberDaysDisplayed ||
+ dayHeaderOuter.scrollWidth < dayHeaderOuter.clientWidth
+ ) {
+ dayHeaderInner.appendChild(document.createXULElement("calendar-day")).date = displayEndTime;
+ displayEndTime.addDuration(cal.createDuration("P1D"));
+ }
+
+ freebusyGridInner.style.width = dayHeaderInner.childElementCount * zoom.dayWidth + "px";
+ if (displayEndTime.compare(oldEndTime) > 0) {
+ for (let attendee of attendeeList.getElementsByTagName("event-attendee")) {
+ attendee.updateFreeBusy(oldEndTime, displayEndTime);
+ }
+ }
+}
+
+/**
+ * Aligns element horizontally on the grid to match the time period it represents.
+ *
+ * @param {Element} element - The element to align.
+ * @param {calIDateTime} startTime - The start time to be represented.
+ * @param {calIDateTime} endTime - The end time to be represented.
+ */
+function setLeftAndWidth(element, startTime, endTime) {
+ element.style.left = getOffsetLeft(startTime) + "px";
+ element.style.width = getOffsetLeft(endTime) - getOffsetLeft(startTime) + "px";
+}
+
+/**
+ * Determines the offset in pixels from the first day displayed.
+ *
+ * @param {calIDateTime} startTime - The start time to be represented.
+ */
+function getOffsetLeft(startTime) {
+ let coordinates = 0;
+ startTime = startTime.getInTimezone(cal.dtz.defaultTimezone);
+
+ let difference = startTime.subtractDate(displayStartTime);
+
+ if (displayStartTime.timezoneOffset != startTime.timezoneOffset) {
+ // Time changes.
+ let diffTimezone = cal.createDuration();
+ diffTimezone.inSeconds = startTime.timezoneOffset - displayStartTime.timezoneOffset;
+ // We add the difference to the date difference otherwise the following calculations will be incorrect.
+ difference.addDuration(diffTimezone);
+ }
+
+ if (!showCompleteDay) {
+ // Start date of the day displayed for the date of the object being processed.
+ let currentDateStartHour = startTime.clone();
+ currentDateStartHour.hour = displayStartHour;
+ currentDateStartHour.minute = 0;
+
+ let dayToDayDuration = currentDateStartHour.subtractDate(displayStartTime);
+ if (currentDateStartHour.timezoneOffset != displayStartTime.timezoneOffset) {
+ // Time changes.
+ let diffTimezone = cal.createDuration();
+ diffTimezone.inSeconds =
+ currentDateStartHour.timezoneOffset - displayStartTime.timezoneOffset;
+ // We add the difference to the date difference otherwise the following calculations will be incorrect.
+ dayToDayDuration.addDuration(diffTimezone);
+ }
+
+ if (startTime.hour < displayStartHour) {
+ // The date starts before the start time of the day, we do not take into consideration the time before the start of the day.
+ coordinates = (dayToDayDuration.weeks * 7 + dayToDayDuration.days) * zoom.dayWidth;
+ } else if (startTime.hour >= displayEndHour) {
+ // The event starts after the end of the day, we do not take into consideration the time before the following day.
+ coordinates = (dayToDayDuration.weeks * 7 + dayToDayDuration.days + 1) * zoom.dayWidth;
+ } else {
+ coordinates =
+ (difference.weeks * 7 + difference.days) * zoom.dayWidth +
+ (difference.hours * 60 * 60 + difference.minutes * 60 + difference.seconds) *
+ zoom.secondWidth;
+ }
+ } else {
+ coordinates = difference.inSeconds * zoom.secondWidth;
+ }
+
+ return coordinates;
+}
+
+/**
+ * Set the time range, setting the start and end hours from the prefs, or
+ * to 24 hrs if the event is outside the range from the prefs.
+ */
+function setTimeRange() {
+ let dateStart = dateTimePickerUI.startValue;
+ let dateEnd = dateTimePickerUI.endValue;
+
+ let dateStartDefaultTimezone = dateStart.getInTimezone(cal.dtz.defaultTimezone);
+ let dateEndDefaultTimezone = dateEnd.getInTimezone(cal.dtz.defaultTimezone);
+
+ if (
+ showOnlyWholeDays ||
+ dateTimePickerUI.allDay.checked ||
+ dateStartDefaultTimezone.hour < dayStartHour ||
+ (dateStartDefaultTimezone.hour == dayEndHour && dateStartDefaultTimezone.minute > 0) ||
+ dateStartDefaultTimezone.hour > dayEndHour ||
+ (dateEndDefaultTimezone.hour == dayEndHour && dateEndDefaultTimezone.minute > 0) ||
+ dateEndDefaultTimezone.hour > dayEndHour ||
+ dateStartDefaultTimezone.day != dateEndDefaultTimezone.day
+ ) {
+ if (!showCompleteDay) {
+ // We modify the levels to readapt them.
+ for (let i = 0; i < zoom.levels.length; i++) {
+ zoom.levels[i].columnCount =
+ zoom.levels[i].columnCount * (24 / (dayEndHour - dayStartHour));
+ zoom.levels[i].dayWidth = zoom.levels[i].columnCount * zoom.levels[i].columnWidth;
+ }
+ }
+ displayStartHour = 0;
+ displayEndHour = 24;
+ showCompleteDay = true;
+
+ // To reactivate the dezoom button if you were in dezoom max for a reduced display.
+ zoom.zoomOutButton.disabled = zoom.currentLevel == 0;
+ } else {
+ if (zoom.currentLevel == 0) {
+ // To avoid being in max dezoom in the reduced display mode.
+ zoom.currentLevel++;
+ }
+ zoom.zoomOutButton.disabled = zoom.currentLevel == 1;
+
+ if (zoom.currentLevel == 1 && (dayEndHour - dayStartHour) % zoom.columnDuration.hours != 0) {
+ // To avoid being in zoom level where the interface is not adapted.
+ zoom.currentLevel++;
+ // Otherwise the class of the grid is not updated.
+ for (let gridClass of ["twoMinorColumns", "threeMinorColumns"]) {
+ if (zoom.levels[zoom.currentLevel].gridClass == gridClass) {
+ dayHeaderInner.classList.add(gridClass);
+ freebusyGridInner.classList.add(gridClass);
+ } else {
+ dayHeaderInner.classList.remove(gridClass);
+ freebusyGridInner.classList.remove(gridClass);
+ }
+ }
+ }
+
+ if (
+ (dayEndHour - dayStartHour) % zoom.levels[zoom.currentLevel - 1].columnDuration.hours !=
+ 0
+ ) {
+ zoom.zoomOutButton.disabled = true;
+ }
+
+ if (showCompleteDay) {
+ // We modify the levels to readapt them.
+ for (let i = 0; i < zoom.levels.length; i++) {
+ zoom.levels[i].columnCount =
+ zoom.levels[i].columnCount / (24 / (dayEndHour - dayStartHour));
+ zoom.levels[i].dayWidth = zoom.levels[i].columnCount * zoom.levels[i].columnWidth;
+ }
+ }
+ displayStartHour = dayStartHour;
+ displayEndHour = dayEndHour;
+ showCompleteDay = false;
+ }
+}
+
+/**
+ * Function to trigger a change of display type (reduced or full).
+ */
+function updateRange() {
+ let dateStart = dateTimePickerUI.startValue;
+ let dateEnd = dateTimePickerUI.endValue;
+
+ let dateStartDefaultTimezone = dateStart.getInTimezone(cal.dtz.defaultTimezone);
+ let dateEndDefaultTimezone = dateEnd.getInTimezone(cal.dtz.defaultTimezone);
+
+ let durationEvent = dateEnd.subtractDate(dateStart);
+
+ if (
+ // Reduced -> Full.
+ (!showCompleteDay &&
+ (dateTimePickerUI.allDay.checked ||
+ (dateStartDefaultTimezone.hour == displayEndHour && dateStartDefaultTimezone.minute > 0) ||
+ dateStartDefaultTimezone.hour > displayEndHour ||
+ (dateEndDefaultTimezone.hour == displayEndHour && dateEndDefaultTimezone.minute > 0) ||
+ dateStartDefaultTimezone.hour < dayStartHour ||
+ dateEndDefaultTimezone.hour > displayEndHour ||
+ dateStartDefaultTimezone.day != dateEndDefaultTimezone.day)) ||
+ // Full -> Reduced.
+ (showCompleteDay &&
+ dateStartDefaultTimezone.hour >= dayStartHour &&
+ dateStartDefaultTimezone.hour < dayEndHour &&
+ (dateEndDefaultTimezone.hour < dayEndHour ||
+ (dateEndDefaultTimezone.hour == dayEndHour && dateEndDefaultTimezone.minute == 0)) &&
+ dateStartDefaultTimezone.day == dateEndDefaultTimezone.day) ||
+ durationEvent.days > numberDaysDisplayedPref ||
+ (numberDaysDisplayed > numberDaysDisplayedPref && durationEvent.days < numberDaysDisplayedPref)
+ ) {
+ // We redo the grid if we change state (reduced -> full, full -> reduced or if you need to change the number of days displayed).
+ displayStartTime = dateTimePickerUI.startValue.getInTimezone(cal.dtz.defaultTimezone);
+ displayStartTime.isDate = true;
+ displayStartTime.day--;
+
+ displayEndTime = displayStartTime.clone();
+
+ emptyGrid();
+ for (let attendee of attendeeList.getElementsByTagName("event-attendee")) {
+ attendee.clearFreeBusy();
+ }
+
+ if (durationEvent.days > numberDaysDisplayedPref) {
+ numberDaysDisplayed = durationEvent.days + 2;
+ } else {
+ numberDaysDisplayed = numberDaysDisplayedPref;
+ }
+ layout();
+ eventBar.update(true);
+ }
+}
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * Represents a row on the grid for a single attendee. The element itself is the row header, and
+ * this class holds reference to any elements on the grid itself that represent the free/busy
+ * status for this row's attendee. The free/busy elements are removed automatically if this
+ * element is removed.
+ */
+ class EventAttendee extends MozXULElement {
+ static #DEFAULT_ROLE = "REQ-PARTICIPANT";
+ static #DEFAULT_USER_TYPE = "INDIVIDUAL";
+
+ static #roleCycle = ["REQ-PARTICIPANT", "OPT-PARTICIPANT", "NON-PARTICIPANT", "CHAIR"];
+ static #userTypeCycle = ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM"];
+
+ #attendee = null;
+ #roleIcon = null;
+ #userTypeIcon = null;
+ #input = null;
+
+ // Because these divs have no reference back to the corresponding attendee,
+ // we currently have to expose this in order to test that free/busy updates
+ // happen appropriately.
+ _freeBusyDiv = null;
+
+ connectedCallback() {
+ // Initialize a default attendee.
+ this.#attendee = new CalAttendee();
+ this.#attendee.role = EventAttendee.#DEFAULT_ROLE;
+ this.#attendee.userType = EventAttendee.#DEFAULT_USER_TYPE;
+
+ // Set up participation role icon. Its image is a grid of icons, the
+ // display of which is determined by CSS rules defined in
+ // calendar-attendees.css based on its class and "attendeerole" attribute.
+ this.#roleIcon = this.appendChild(document.createElement("img"));
+ this.#roleIcon.classList.add("role-icon");
+ this.#roleIcon.setAttribute(
+ "src",
+ "chrome://calendar/skin/shared/calendar-event-dialog-attendees.png"
+ );
+ this.#updateRoleIcon();
+ this.#roleIcon.addEventListener("click", this);
+
+ // Set up calendar user type icon. Its image is a grid of icons, the
+ // display of which is determined by CSS rules defined in
+ // calendar-attendees.css based on its class and "usertype" attribute.
+ this.#userTypeIcon = this.appendChild(document.createElement("img"));
+ this.#userTypeIcon.classList.add("usertype-icon");
+ this.#userTypeIcon.setAttribute("src", "chrome://calendar/skin/shared/attendee-icons.png");
+ this.#updateUserTypeIcon();
+ this.#userTypeIcon.addEventListener("click", this);
+
+ this.#input = this.appendChild(document.createElement("input", { is: "autocomplete-input" }));
+ this.#input.classList.add("plain");
+ this.#input.setAttribute("autocompletesearch", "addrbook ldap");
+ this.#input.setAttribute("autocompletesearchparam", "{}");
+ this.#input.setAttribute("forcecomplete", "true");
+ this.#input.setAttribute("timeout", "200");
+ this.#input.setAttribute("completedefaultindex", "true");
+ this.#input.setAttribute("completeselectedindex", "true");
+ this.#input.setAttribute("minresultsforpopup", "1");
+ this.#input.addEventListener("change", this);
+ this.#input.addEventListener("keydown", this);
+ this.#input.addEventListener("input", this);
+ this.#input.addEventListener("click", this);
+
+ this._freeBusyDiv = freebusyGridInner.appendChild(document.createElement("div"));
+ this._freeBusyDiv.classList.add("freebusy-row");
+ }
+
+ disconnectedCallback() {
+ this._freeBusyDiv.remove();
+ }
+
+ /**
+ * Get the attendee for this row. The attendee will be cloned to prevent
+ * accidental modification, which could cause the UI to fall out of sync.
+ *
+ * @returns {calIAttendee} - The attendee for this row.
+ */
+ get attendee() {
+ return this.#attendee.clone();
+ }
+
+ /**
+ * Set the attendee for this row.
+ *
+ * @param {calIAttendee} attendee - The new attendee for this row.
+ */
+ set attendee(attendee) {
+ this.#attendee = attendee.clone();
+
+ // Update display values of the icons and input box.
+ this.#updateRoleIcon();
+ this.#updateUserTypeIcon();
+
+ // If the attendee has a name set, build a display string from their name
+ // and email; otherwise, we can use the email address as is.
+ const attendeeEmail = cal.email.removeMailTo(this.#attendee.id);
+ if (this.#attendee.commonName) {
+ this.#input.value = MailServices.headerParser
+ .makeMailboxObject(this.#attendee.commonName, attendeeEmail)
+ .toString();
+ } else {
+ this.#input.value = attendeeEmail;
+ }
+
+ this.updateFreeBusy(displayStartTime, displayEndTime);
+ }
+
+ /** Removes all free/busy information from this row. */
+ clearFreeBusy() {
+ while (this._freeBusyDiv.lastChild) {
+ this._freeBusyDiv.lastChild.remove();
+ }
+ }
+
+ /**
+ * Queries the free/busy service for information about this row's attendee, and displays the
+ * information on the grid if there is any.
+ *
+ * @param {calIDateTime} from - The start of a time period to query.
+ * @param {calIDateTime} to - The end of a time period to query.
+ */
+ updateFreeBusy(from, to) {
+ let addresses = MailServices.headerParser.makeFromDisplayAddress(this.#input.value);
+ if (addresses.length === 0) {
+ return;
+ }
+
+ let calendar = cal.email.prependMailTo(addresses[0].email);
+
+ let pendingDiv = this._freeBusyDiv.appendChild(document.createElement("div"));
+ pendingDiv.classList.add("pending");
+ setLeftAndWidth(pendingDiv, from, to);
+
+ cal.freeBusyService.getFreeBusyIntervals(
+ calendar,
+ from,
+ to,
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ {
+ onResult: (operation, results) => {
+ for (let result of results) {
+ let freeBusyType = Number(result.freeBusyType); // For some reason this is a string.
+ if (freeBusyType == Ci.calIFreeBusyInterval.FREE) {
+ continue;
+ }
+
+ let block = this._freeBusyDiv.appendChild(document.createElement("div"));
+ switch (freeBusyType) {
+ case Ci.calIFreeBusyInterval.BUSY_TENTATIVE:
+ block.classList.add("tentative");
+ break;
+ case Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE:
+ block.classList.add("unavailable");
+ break;
+ case Ci.calIFreeBusyInterval.UNKNOWN:
+ block.classList.add("unknown");
+ break;
+ default:
+ block.classList.add("busy");
+ break;
+ }
+ setLeftAndWidth(block, result.interval.start, result.interval.end);
+ }
+ if (!operation.isPending) {
+ this.dispatchEvent(new CustomEvent("freebusy-update-finished"));
+ pendingDiv.remove();
+ }
+ },
+ }
+ );
+ this.dispatchEvent(new CustomEvent("freebusy-update-started"));
+ }
+
+ focus() {
+ this.scrollIntoView();
+ this.#input.focus();
+ }
+
+ handleEvent(event) {
+ if (
+ // Change, e.g. due to blur.
+ event.type == "change" ||
+ (event.type == "keydown" && event.key == "Enter") ||
+ // A click on the line of the input field.
+ (event.type == "click" && event.target.nodeName == "input") ||
+ // A click on an autocomplete suggestion.
+ (event.type == "input" &&
+ event.inputType == "insertReplacementText" &&
+ event.explicitOriginalTarget != event.originalTarget)
+ ) {
+ const nextElement = this.nextElementSibling;
+ if (this.#input.value) {
+ /**
+ * Given structured address data, build it into a collection of
+ * mailboxes, resolving any groups into individual mailboxes in the
+ * process.
+ *
+ * @param {Map<string, msgIAddressObject>} accumulatorMap - A map from
+ * attendee ID to the corresponding mailbox.
+ * @param {msgIAddressObject} address - Structured representation of
+ * an RFC 5322 address to resolve to one or more mailboxes.
+ * @returns {Map<string, msgIAddressObject>} - A map containing all
+ * entries from the provided map as well as any individual
+ * mailboxes resolved from the provided address.
+ */
+ function resolveAddressesToMailboxes(accumulatorMap, address) {
+ let list = MailUtils.findListInAddressBooks(address.name);
+ if (list) {
+ // If the address was for a group, collect each mailbox from that
+ // group, recursively if necessary.
+ return list.childCards
+ .map(card => {
+ card.QueryInterface(Ci.nsIAbCard);
+
+ return MailServices.headerParser.makeMailboxObject(
+ card.displayName,
+ card.primaryEmail
+ );
+ })
+ .reduce(resolveAddressesToMailboxes, accumulatorMap);
+ }
+
+ // The address data was a single mailbox; add it to the map.
+ return accumulatorMap.set(address.email, address);
+ }
+
+ // Take the addresses in the input and resolve them into individual
+ // mailboxes for attendees.
+ const attendeeAddresses = MailServices.headerParser.makeFromDisplayAddress(
+ this.#input.value
+ );
+ // Clear input so possible later events won't try to add again.
+ this.#input.value = "";
+ const resolvedMailboxes = attendeeAddresses.reduce(
+ resolveAddressesToMailboxes,
+ new Map()
+ );
+
+ // We want to ensure that this row and its attendee is preserved if
+ // the attendee is still in the list; otherwise, we may throw away
+ // what we already know about them (e.g., required vs. optional or
+ // RSVP status).
+ const attendeeEmail = this.#attendee.id && cal.email.removeMailTo(this.#attendee.id);
+ if (attendeeEmail && resolvedMailboxes.has(attendeeEmail)) {
+ // Update attendee name from mailbox and ensure we don't duplicate
+ // the row.
+ const mailbox = resolvedMailboxes.get(attendeeEmail);
+ this.#attendee.commonName = mailbox.name;
+ resolvedMailboxes.delete(attendeeEmail);
+ } else {
+ // The attendee for this row was not found in the revised list of
+ // mailboxes, so remove the row from the attendee list.
+ nextElement?.focus();
+ this.remove();
+ }
+
+ // For any mailboxes beyond that representing the current attendee,
+ // add a new row immediately following this one (or its previous
+ // location if removed).
+ for (const [email, mailbox] of resolvedMailboxes) {
+ const newAttendee = new CalAttendee();
+ newAttendee.id = cal.email.prependMailTo(email);
+ newAttendee.role = EventAttendee.#DEFAULT_ROLE;
+ newAttendee.userType = EventAttendee.#DEFAULT_USER_TYPE;
+
+ if (mailbox.name && mailbox.name != mailbox.email) {
+ newAttendee.commonName = mailbox.name;
+ }
+
+ const newRow = attendeeList.insertBefore(
+ document.createXULElement("event-attendee"),
+ nextElement
+ );
+ newRow.attendee = newAttendee;
+ }
+
+ // If there are no rows following, create an empty row for the next attendee.
+ if (!nextElement) {
+ attendeeList.appendChild(document.createXULElement("event-attendee")).focus();
+ freebusyGrid.scrollTop = attendeeList.scrollTop;
+ }
+ } else if (this.nextElementSibling) {
+ // This row is now empty, but there are additional rows (and thus an
+ // empty row for new entries). Remove this row and focus the next.
+ this.nextElementSibling.focus();
+ this.remove();
+ }
+
+ updateVerticalScrollbars();
+
+ if (this.parentNode) {
+ this.clearFreeBusy();
+ this.updateFreeBusy(displayStartTime, displayEndTime);
+ }
+ } else if (event.type == "click") {
+ if (event.button != 0 || readOnly) {
+ return;
+ }
+
+ const cycle = (values, current) => {
+ let nextIndex = (values.indexOf(current) + 1) % values.length;
+ return values[nextIndex];
+ };
+
+ let target = event.target;
+ if (target == this.#roleIcon) {
+ this.#attendee.role = cycle(EventAttendee.#roleCycle, this.#attendee.role);
+ this.#updateRoleIcon();
+ } else if (target == this.#userTypeIcon) {
+ if (!this.#attendee.isOrganizer) {
+ this.#attendee.userType = cycle(EventAttendee.#userTypeCycle, this.#attendee.userType);
+ this.#updateUserTypeIcon();
+ }
+ }
+ } else if (event.type == "keydown" && event.key == "ArrowRight") {
+ let nextElement = this.nextElementSibling;
+ if (this.#input.value) {
+ if (!nextElement) {
+ attendeeList.appendChild(document.createXULElement("event-attendee"));
+ }
+ } else if (this.nextElementSibling) {
+ // No value but not the last row? Remove.
+ this.remove();
+ }
+ }
+ }
+
+ /**
+ * Update the tooltip and icon of the role icon node to match the current
+ * role for this row's attendee.
+ */
+ #updateRoleIcon() {
+ const role = this.#attendee.role ?? EventAttendee.#DEFAULT_ROLE;
+ const roleValueToStringKeyMap = {
+ "REQ-PARTICIPANT": "event.attendee.role.required",
+ "OPT-PARTICIPANT": "event.attendee.role.optional",
+ "NON-PARTICIPANT": "event.attendee.role.nonparticipant",
+ CHAIR: "event.attendee.role.chair",
+ };
+
+ let tooltip;
+ if (role in roleValueToStringKeyMap) {
+ tooltip = cal.l10n.getString(
+ "calendar-event-dialog-attendees",
+ roleValueToStringKeyMap[role]
+ );
+ } else {
+ tooltip = cal.l10n.getString(
+ "calendar-event-dialog-attendees",
+ "event.attendee.role.unknown",
+ [role]
+ );
+ }
+
+ this.#roleIcon.setAttribute("attendeerole", role);
+ this.#roleIcon.setAttribute("title", tooltip);
+ }
+
+ /**
+ * Update the tooltip and icon of the user type icon node to match the
+ * current user type for this row's attendee.
+ */
+ #updateUserTypeIcon() {
+ const userType = this.#attendee.userType ?? EventAttendee.#DEFAULT_USER_TYPE;
+ const userTypeValueToStringKeyMap = {
+ INDIVIDUAL: "event.attendee.usertype.individual",
+ GROUP: "event.attendee.usertype.group",
+ RESOURCE: "event.attendee.usertype.resource",
+ ROOM: "event.attendee.usertype.room",
+ // UNKNOWN and any unrecognized user types are handled below.
+ };
+
+ let tooltip;
+ if (userType in userTypeValueToStringKeyMap) {
+ tooltip = cal.l10n.getString(
+ "calendar-event-dialog-attendees",
+ userTypeValueToStringKeyMap[userType]
+ );
+ } else {
+ tooltip = cal.l10n.getString(
+ "calendar-event-dialog-attendees",
+ "event.attendee.usertype.unknown",
+ [userType]
+ );
+ }
+
+ this.#userTypeIcon.setAttribute("usertype", userType);
+ this.#userTypeIcon.setAttribute("title", tooltip);
+ }
+ }
+ customElements.define("event-attendee", EventAttendee);
+
+ /**
+ * Represents a group of columns for a single day on the grid. The element itself is the column
+ * header, and this class holds reference to elements on the grid that provide the background
+ * coloring for the day. The elements are removed automatically if this element is removed.
+ */
+ class CalendarDay extends MozXULElement {
+ connectedCallback() {
+ let dayLabelContainer = this.appendChild(document.createXULElement("box"));
+ dayLabelContainer.setAttribute("pack", "center");
+
+ this.dayLabel = dayLabelContainer.appendChild(document.createXULElement("label"));
+ this.dayLabel.classList.add("day-label");
+
+ let columnContainer = this.appendChild(document.createXULElement("box"));
+
+ // A half-column-wide spacer to align labels with the dividing grid lines.
+ columnContainer.appendChild(document.createXULElement("box")).style.width =
+ zoom.columnWidth / 2 + "px";
+
+ let column = displayEndTime.clone();
+ column.isDate = false;
+ for (let i = 1; i < zoom.columnCount; i++) {
+ column.addDuration(zoom.columnDuration);
+
+ let columnBox = columnContainer.appendChild(document.createXULElement("box"));
+ columnBox.style.width = zoom.columnWidth + "px";
+ columnBox.setAttribute("align", "center");
+
+ let columnLabel = columnBox.appendChild(document.createXULElement("label"));
+ columnLabel.classList.add("hour-label");
+ columnLabel.setAttribute("flex", "1");
+ columnLabel.setAttribute("value", cal.dtz.formatter.formatTime(column));
+ }
+
+ // A half-column-wide (minus 1px) spacer to align labels with the dividing grid lines.
+ columnContainer.appendChild(document.createXULElement("box")).style.width =
+ zoom.columnWidth / 2 - 1 + "px";
+ }
+
+ disconnectedCallback() {
+ if (this.dayColumn) {
+ this.dayColumn.remove();
+ }
+ }
+
+ /** @returns {calIDateTime} - The day this group of columns represents. */
+ get date() {
+ return this.mDate;
+ }
+ /** @param {calIDateTime} value - The day this group of columns represents. */
+ set date(value) {
+ this.mDate = value.clone();
+ this.dayLabel.value = cal.dtz.formatter.formatDateShort(this.mDate);
+
+ let datePlus1 = value.clone();
+ if (!showCompleteDay) {
+ // To avoid making a 24 hour day in reduced display.
+ let hoursToShow = dayEndHour - dayStartHour;
+ datePlus1.addDuration(cal.createDuration("PT" + hoursToShow + "H"));
+ } else {
+ datePlus1.addDuration(cal.createDuration("P1D"));
+ }
+
+ let dayOffPref = [
+ "calendar.week.d0sundaysoff",
+ "calendar.week.d1mondaysoff",
+ "calendar.week.d2tuesdaysoff",
+ "calendar.week.d3wednesdaysoff",
+ "calendar.week.d4thursdaysoff",
+ "calendar.week.d5fridaysoff",
+ "calendar.week.d6saturdaysoff",
+ ][this.mDate.weekday];
+
+ this.dayColumn = freebusyGridBackground.appendChild(document.createElement("div"));
+ this.dayColumn.classList.add("day-column");
+ setLeftAndWidth(this.dayColumn, this.mDate, datePlus1);
+ if (Services.prefs.getBoolPref(dayOffPref)) {
+ this.dayColumn.classList.add("day-off");
+ }
+
+ if (dayStartHour > 0) {
+ let dayStart = value.clone();
+ dayStart.isDate = false;
+ dayStart.hour = dayStartHour;
+ let beforeStartDiv = this.dayColumn.appendChild(document.createElement("div"));
+ beforeStartDiv.classList.add("time-off");
+ setLeftAndWidth(beforeStartDiv, this.mDate, dayStart);
+ beforeStartDiv.style.left = "0";
+ }
+ if (dayEndHour < 24) {
+ let dayEnd = value.clone();
+ dayEnd.isDate = false;
+ dayEnd.hour = dayEndHour;
+ let afterEndDiv = this.dayColumn.appendChild(document.createElement("div"));
+ afterEndDiv.classList.add("time-off");
+ setLeftAndWidth(afterEndDiv, dayEnd, datePlus1);
+ afterEndDiv.style.left = null;
+ afterEndDiv.style.right = "0";
+ }
+ }
+ }
+ customElements.define("calendar-day", CalendarDay);
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml
new file mode 100644
index 0000000000..964dd050c5
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-attendees.xhtml
@@ -0,0 +1,227 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-views.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/shared/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" >
+%dtd2; ]>
+<html
+ id="calendar-event-dialog-attendees-v2"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:EventDialog:Attendees"
+ orient="vertical"
+ lightweightthemes="true"
+ scrolling="false"
+ style="min-width: 800px; min-height: 500px"
+>
+ <head>
+ <title>&invite.title.label;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script>
+ <script
+ defer="defer"
+ src="chrome://calendar/content/calendar-event-dialog-attendees.js"
+ ></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog defaultButton="none">
+ <hbox align="center" pack="end">
+ <spacer flex="1" />
+ <label value="&event.freebusy.zoom;" control="zoom-menulist" />
+ <toolbarbutton id="zoom-out-button" class="zoom-out-icon" />
+ <toolbarbutton id="zoom-in-button" class="zoom-in-icon" />
+ </hbox>
+ <hbox id="outer" flex="1">
+ <vbox class="attendee-list-container">
+ <box id="spacer"></box>
+ <vbox id="attendee-list" flex="1"></vbox>
+ </vbox>
+ <splitter />
+ <vbox flex="1" class="attendees-grid-container">
+ <box id="day-header-outer">
+ <stack>
+ <hbox id="day-header-inner"></hbox>
+ <html:div id="event-bar-top" draggable="true"></html:div>
+ </stack>
+ </box>
+ <vbox id="freebusy-grid" flex="1">
+ <stack>
+ <html:div id="freebusy-grid-background"></html:div>
+ <html:div id="freebusy-grid-inner"></html:div>
+ <html:div id="event-bar-bottom" draggable="true"></html:div>
+ </stack>
+ </vbox>
+ </vbox>
+ </hbox>
+ <hbox>
+ <hbox flex="1">
+ <hbox flex="1">
+ <table xmlns="http://www.w3.org/1999/xhtml">
+ <tr>
+ <td>
+ <img
+ class="role-icon"
+ attendeerole="REQ-PARTICIPANT"
+ src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.role.required;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="role-icon"
+ attendeerole="OPT-PARTICIPANT"
+ src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.role.optional;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="role-icon"
+ attendeerole="NON-PARTICIPANT"
+ src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.role.nonparticipant;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="role-icon"
+ attendeerole="CHAIR"
+ src="chrome://calendar/skin/shared/calendar-event-dialog-attendees.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.role.chair;</td>
+ </tr>
+ </table>
+ </hbox>
+ <hbox flex="1">
+ <table xmlns="http://www.w3.org/1999/xhtml">
+ <tr>
+ <td>
+ <img
+ class="usertype-icon"
+ usertype="INDIVIDUAL"
+ src="chrome://calendar/skin/shared/attendee-icons.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.usertype.individual;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="usertype-icon"
+ usertype="GROUP"
+ src="chrome://calendar/skin/shared/attendee-icons.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.usertype.group;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="usertype-icon"
+ usertype="RESOURCE"
+ src="chrome://calendar/skin/shared/attendee-icons.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.usertype.resource;</td>
+ </tr>
+ <tr>
+ <td>
+ <img
+ class="usertype-icon"
+ usertype="ROOM"
+ src="chrome://calendar/skin/shared/attendee-icons.png"
+ alt=""
+ />
+ </td>
+ <td>&event.attendee.usertype.room;</td>
+ </tr>
+ </table>
+ </hbox>
+ <hbox flex="1">
+ <table xmlns="http://www.w3.org/1999/xhtml">
+ <tr>
+ <td><xul:box class="legend" status="BUSY_TENTATIVE" /></td>
+ <td>&event.freebusy.legend.busy_tentative;</td>
+ </tr>
+ <tr>
+ <td><xul:box class="legend" status="BUSY" /></td>
+ <td>&event.freebusy.legend.busy;</td>
+ </tr>
+ <tr>
+ <td><xul:box class="legend" status="BUSY_UNAVAILABLE" /></td>
+ <td>&event.freebusy.legend.busy_unavailable;</td>
+ </tr>
+ <tr>
+ <td><xul:box class="legend" status="UNKNOWN" /></td>
+ <td>&event.freebusy.legend.unknown;</td>
+ </tr>
+ </table>
+ </hbox>
+ </hbox>
+ <vbox>
+ <table xmlns="http://www.w3.org/1999/xhtml">
+ <tr>
+ <td></td>
+ <td>
+ <xul:checkbox id="all-day" label="&event.alldayevent.label;" />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <xul:label control="event-starttime" value="&newevent.from.label;" />
+ </td>
+ <td>
+ <xul:datetimepicker id="event-starttime" />
+ </td>
+ <td id="timezone-starttime-cell" hidden="true">
+ <xul:label id="timezone-starttime" class="text-link" hyperlink="true" />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <xul:label control="event-endtime" value="&newevent.to.label;" />
+ </td>
+ <td>
+ <xul:datetimepicker id="event-endtime" />
+ </td>
+ <td id="timezone-endtime-cell" hidden="true">
+ <xul:label id="timezone-endtime" class="text-link" hyperlink="true" />
+ </td>
+ </tr>
+ </table>
+ </vbox>
+ </hbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js
new file mode 100644
index 0000000000..379e5e387e
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js
@@ -0,0 +1,1237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { splitRecurrenceRules } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+var gIsReadOnly = false;
+var gStartTime = null;
+var gEndTime = null;
+var gUntilDate = null;
+
+window.addEventListener("load", onLoad);
+
+/**
+ * Object wrapping the methods and properties of recurrencePreview binding.
+ */
+const RecurrencePreview = {
+ /**
+ * Initializes some properties and adds event listener to the #recurrencePreview node.
+ */
+ init() {
+ this.node = document.getElementById("recurrencePreview");
+ this.mRecurrenceInfo = null;
+ this.mResizeHandler = null;
+ this.mDateTime = null;
+ document.getElementById("recurrencePrevious").addEventListener("click", () => {
+ this.showPreviousMonth();
+ });
+ document.getElementById("recurrenceNext").addEventListener("click", () => {
+ this.showNextMonth();
+ });
+ document.getElementById("recurrenceToday").addEventListener("click", () => {
+ this.jumpToToday();
+ });
+ this.togglePreviousMonthButton();
+ },
+ /**
+ * Setter for mDateTime property.
+ *
+ * @param {Date} val - The date value that is to be set.
+ */
+ set dateTime(val) {
+ this.mDateTime = val.clone();
+ },
+ /**
+ * Getter for mDateTime property.
+ */
+ get dateTime() {
+ if (this.mDateTime == null) {
+ this.mDateTime = cal.dtz.now();
+ }
+ return this.mDateTime;
+ },
+ /**
+ * Updates content of #recurrencePreview node.
+ */
+ updateContent() {
+ let date = cal.dtz.dateTimeToJsDate(this.dateTime);
+ for (let minimonth of this.node.children) {
+ minimonth.showMonth(date);
+ date.setMonth(date.getMonth() + 1);
+ }
+ },
+ /**
+ * Updates preview of #recurrencePreview node.
+ */
+ updatePreview(recurrenceInfo) {
+ let minimonth = this.node.querySelector("calendar-minimonth");
+ this.node.style.minHeight = minimonth.getBoundingClientRect().height + "px";
+
+ this.mRecurrenceInfo = recurrenceInfo;
+ let start = this.dateTime.clone();
+ start.day = 1;
+ start.hour = 0;
+ start.minute = 0;
+ start.second = 0;
+ let end = start.clone();
+ end.month++;
+
+ for (let minimonth of this.node.children) {
+ // we now have one of the minimonth controls while 'start'
+ // and 'end' are set to the interval this minimonth shows.
+ minimonth.showMonth(cal.dtz.dateTimeToJsDate(start));
+ if (recurrenceInfo) {
+ // retrieve an array of dates that represents all occurrences
+ // that fall into this time interval [start,end[.
+ // note: the following loop assumes that this array contains
+ // dates that are strictly monotonically increasing.
+ // should getOccurrenceDates() not enforce this assumption we
+ // need to fall back to some different algorithm.
+ let dates = recurrenceInfo.getOccurrenceDates(start, end, 0);
+
+ // now run through all days of this month and set the
+ // 'busy' attribute with respect to the occurrence array.
+ let index = 0;
+ let occurrence = null;
+ if (index < dates.length) {
+ occurrence = dates[index++].getInTimezone(start.timezone);
+ }
+ let current = start.clone();
+ while (current.compare(end) < 0) {
+ let box = minimonth.getBoxForDate(current);
+ if (box) {
+ if (
+ occurrence &&
+ occurrence.day == current.day &&
+ occurrence.month == current.month &&
+ occurrence.year == current.year
+ ) {
+ box.setAttribute("busy", 1);
+ if (index < dates.length) {
+ occurrence = dates[index++].getInTimezone(start.timezone);
+ // take into account that the very next occurrence
+ // can happen at the same day as the previous one.
+ if (
+ occurrence.day == current.day &&
+ occurrence.month == current.month &&
+ occurrence.year == current.year
+ ) {
+ continue;
+ }
+ } else {
+ occurrence = null;
+ }
+ } else {
+ box.removeAttribute("busy");
+ }
+ }
+ current.day++;
+ }
+ }
+ start.month++;
+ end.month++;
+ }
+ },
+ /**
+ * Shows the previous month in the recurrence preview.
+ */
+ showPreviousMonth() {
+ let prevMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`);
+
+ let activeDate = this.previousMonthDate(
+ prevMinimonth.getAttribute("year"),
+ prevMinimonth.getAttribute("month")
+ );
+
+ if (activeDate) {
+ this.resetDisplayOfMonths();
+ this.displayCurrentMonths(activeDate);
+ this.togglePreviousMonthButton();
+ }
+ },
+ /**
+ * Shows the next month in the recurrence preview.
+ */
+ showNextMonth() {
+ let prevMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`);
+
+ let activeDate = this.nextMonthDate(
+ prevMinimonth.getAttribute("year"),
+ prevMinimonth.getAttribute("month")
+ );
+
+ if (activeDate) {
+ this.resetDisplayOfMonths();
+ this.displayCurrentMonths(activeDate);
+ this.togglePreviousMonthButton();
+ }
+ },
+ /**
+ * Shows the current day's month in the recurrence preview.
+ */
+ jumpToToday() {
+ let activeDate = new Date();
+ this.resetDisplayOfMonths();
+ this.displayCurrentMonths(activeDate);
+ this.togglePreviousMonthButton();
+ },
+ /**
+ * Selects the minimonth element belonging to a year and month.
+ */
+ selectMinimonth(year, month) {
+ let minimonthIdentifier = `calendar-minimonth[year="${year}"][month="${month}"]`;
+ let selectedMinimonth = this.node.querySelector(minimonthIdentifier);
+
+ if (selectedMinimonth) {
+ return selectedMinimonth;
+ }
+
+ selectedMinimonth = document.createXULElement("calendar-minimonth");
+ this.node.appendChild(selectedMinimonth);
+
+ selectedMinimonth.setAttribute("readonly", "true");
+ selectedMinimonth.setAttribute("month", month);
+ selectedMinimonth.setAttribute("year", year);
+ selectedMinimonth.hidden = true;
+
+ if (this.mRecurrenceInfo) {
+ this.updatePreview(this.mRecurrenceInfo);
+ }
+
+ return selectedMinimonth;
+ },
+ /**
+ * Returns the next month's first day when given a year and month.
+ */
+ nextMonthDate(currentYear, currentMonth) {
+ // If month is December, select first day of January
+ if (currentMonth == 11) {
+ return new Date(parseInt(currentYear) + 1, 0, 1);
+ }
+ return new Date(parseInt(currentYear), parseInt(currentMonth) + 1, 1);
+ },
+ /**
+ * Returns the previous month's first day when given a year and month.
+ */
+ previousMonthDate(currentYear, currentMonth) {
+ // If month is January, select first day of December.
+ if (currentMonth == 0) {
+ return new Date(parseInt(currentYear) - 1, 11, 1);
+ }
+ return new Date(parseInt(currentYear), parseInt(currentMonth) - 1, 1);
+ },
+ /**
+ * Reset the recurrence preview months, making all hidden and none set to active.
+ */
+ resetDisplayOfMonths() {
+ let calContainer = this.node;
+ for (let minimonth of calContainer.children) {
+ minimonth.hidden = true;
+ minimonth.setAttribute("active-month", false);
+ }
+ },
+ /**
+ * Display the active month and the next two months in the recurrence preview.
+ */
+ displayCurrentMonths(activeDate) {
+ let activeMonth = activeDate.getMonth();
+ let activeYear = activeDate.getFullYear();
+
+ let month1Date = this.nextMonthDate(activeYear, activeMonth);
+ let month2Date = this.nextMonthDate(month1Date.getFullYear(), month1Date.getMonth());
+
+ let activeMinimonth = this.selectMinimonth(activeYear, activeMonth);
+ let minimonth1 = this.selectMinimonth(month1Date.getFullYear(), month1Date.getMonth());
+ let minimonth2 = this.selectMinimonth(month2Date.getFullYear(), month2Date.getMonth());
+
+ activeMinimonth.setAttribute("active-month", true);
+ activeMinimonth.removeAttribute("hidden");
+ minimonth1.removeAttribute("hidden");
+ minimonth2.removeAttribute("hidden");
+ },
+ /**
+ * Disable previous month button when the active month is the first month of the event.
+ */
+ togglePreviousMonthButton() {
+ let activeMinimonth = this.node.querySelector(`calendar-minimonth[active-month="true"]`);
+
+ if (activeMinimonth.getAttribute("initial-month") == "true") {
+ document.getElementById("recurrencePrevious").setAttribute("disabled", "true");
+ } else {
+ document.getElementById("recurrencePrevious").removeAttribute("disabled");
+ }
+ },
+};
+
+/**
+ * An object containing the daypicker-weekday binding functionalities.
+ */
+const DaypickerWeekday = {
+ /**
+ * Method intitializing DaypickerWeekday.
+ */
+ init() {
+ this.weekStartOffset = Services.prefs.getIntPref("calendar.week.start", 0);
+
+ let mainbox = document.getElementById("daypicker-weekday");
+ let numChilds = mainbox.children.length;
+ for (let i = 0; i < numChilds; i++) {
+ let child = mainbox.children[i];
+ let dow = i + this.weekStartOffset;
+ if (dow >= 7) {
+ dow -= 7;
+ }
+ let day = cal.l10n.getString("dateFormat", `day.${dow + 1}.Mmm`);
+ child.label = day;
+ child.calendar = mainbox;
+ }
+ },
+ /**
+ * Getter for days property.
+ */
+ get days() {
+ let mainbox = document.getElementById("daypicker-weekday");
+ let numChilds = mainbox.children.length;
+ let days = [];
+ for (let i = 0; i < numChilds; i++) {
+ let child = mainbox.children[i];
+ if (child.getAttribute("checked") == "true") {
+ let index = i + this.weekStartOffset;
+ if (index >= 7) {
+ index -= 7;
+ }
+ days.push(index + 1);
+ }
+ }
+ return days;
+ },
+ /**
+ * The weekday-picker manages an array of selected days of the week and
+ * the 'days' property is the interface to this array. the expected argument is
+ * an array containing integer elements, where each element represents a selected
+ * day of the week, starting with SUNDAY=1.
+ */
+ set days(val) {
+ let mainbox = document.getElementById("daypicker-weekday");
+ for (let child of mainbox.children) {
+ child.removeAttribute("checked");
+ }
+ for (let i in val) {
+ let index = val[i] - 1 - this.weekStartOffset;
+ if (index < 0) {
+ index += 7;
+ }
+ mainbox.children[index].setAttribute("checked", "true");
+ }
+ },
+};
+
+/**
+ * An object containing the daypicker-monthday binding functionalities.
+ */
+const DaypickerMonthday = {
+ /**
+ * Method intitializing DaypickerMonthday.
+ */
+ init() {
+ let mainbox = document.querySelector(".daypicker-monthday-mainbox");
+ let child = null;
+ for (let row of mainbox.children) {
+ for (child of row.children) {
+ child.calendar = mainbox;
+ }
+ }
+ let labelLastDay = cal.l10n.getString(
+ "calendar-event-dialog",
+ "eventRecurrenceMonthlyLastDayLabel"
+ );
+ child.setAttribute("label", labelLastDay);
+ },
+ /**
+ * Setter for days property.
+ */
+ set days(val) {
+ let mainbox = document.querySelector(".daypicker-monthday-mainbox");
+ let days = [];
+ for (let row of mainbox.children) {
+ for (let child of row.children) {
+ child.removeAttribute("checked");
+ days.push(child);
+ }
+ }
+ for (let i in val) {
+ let lastDayOffset = val[i] == -1 ? 0 : -1;
+ let index = val[i] < 0 ? val[i] + days.length + lastDayOffset : val[i] - 1;
+ days[index].setAttribute("checked", "true");
+ }
+ },
+ /**
+ * Getter for days property.
+ */
+ get days() {
+ let mainbox = document.querySelector(".daypicker-monthday-mainbox");
+ let days = [];
+ for (let row of mainbox.children) {
+ for (let child of row.children) {
+ if (child.getAttribute("checked") == "true") {
+ days.push(Number(child.label) ? Number(child.label) : -1);
+ }
+ }
+ }
+ return days;
+ },
+ /**
+ * Disables daypicker elements.
+ */
+ disable() {
+ let mainbox = document.querySelector(".daypicker-monthday-mainbox");
+ for (let row of mainbox.children) {
+ for (let child of row.children) {
+ child.setAttribute("disabled", "true");
+ }
+ }
+ },
+ /**
+ * Enables daypicker elements.
+ */
+ enable() {
+ let mainbox = document.querySelector(".daypicker-monthday-mainbox");
+ for (let row of mainbox.children) {
+ for (let child of row.children) {
+ child.removeAttribute("disabled");
+ }
+ }
+ },
+};
+
+/**
+ * Sets up the recurrence dialog from the window arguments. Takes care of filling
+ * the dialog controls with the recurrence information for this window.
+ */
+function onLoad() {
+ RecurrencePreview.init();
+ DaypickerWeekday.init();
+ DaypickerMonthday.init();
+ changeWidgetsOrder();
+
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ let calendar = item.calendar;
+ let recinfo = args.recurrenceInfo;
+
+ gStartTime = args.startTime;
+ gEndTime = args.endTime;
+ RecurrencePreview.dateTime = gStartTime.getInTimezone(cal.dtz.defaultTimezone);
+
+ onChangeCalendar(calendar);
+
+ // Set starting value for 'repeat until' rule and highlight the start date.
+ let repeatDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating));
+ document.getElementById("repeat-until-date").value = repeatDate;
+ document.getElementById("repeat-until-date").extraDate = repeatDate;
+
+ if (item.parentItem != item) {
+ item = item.parentItem;
+ }
+ let rule = null;
+ if (recinfo) {
+ // Split out rules and exceptions
+ try {
+ let rrules = splitRecurrenceRules(recinfo);
+ let rules = rrules[0];
+ // Deal with the rules
+ if (rules.length > 0) {
+ // We only handle 1 rule currently
+ rule = cal.wrapInstance(rules[0], Ci.calIRecurrenceRule);
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ if (!rule) {
+ rule = cal.createRecurrenceRule();
+ rule.type = "DAILY";
+ rule.interval = 1;
+ rule.count = -1;
+
+ // We don't let the user set the week start day for a given rule, but we
+ // want to default to the user's week start so rules behave as expected
+ let weekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ rule.weekStart = weekStart;
+ }
+ initializeControls(rule);
+
+ // Update controls
+ updateRecurrenceBox();
+
+ opener.setCursor("auto");
+ self.focus();
+}
+
+/**
+ * Initialize the dialog controls according to the passed rule
+ *
+ * @param rule The recurrence rule to parse.
+ */
+function initializeControls(rule) {
+ function getOrdinalAndWeekdayOfRule(aByDayRuleComponent) {
+ return {
+ ordinal: (aByDayRuleComponent - (aByDayRuleComponent % 8)) / 8,
+ weekday: Math.abs(aByDayRuleComponent % 8),
+ };
+ }
+
+ function setControlsForByMonthDay_YearlyRule(aDate, aByMonthDay) {
+ if (aByMonthDay == -1) {
+ // The last day of the month.
+ document.getElementById("yearly-group").selectedIndex = 1;
+ document.getElementById("yearly-ordinal").value = -1;
+ document.getElementById("yearly-weekday").value = -1;
+ } else {
+ if (aByMonthDay < -1) {
+ // The UI doesn't manage negative days apart from -1 but we can
+ // display in the controls the day from the start of the month.
+ aByMonthDay += aDate.endOfMonth.day + 1;
+ }
+ document.getElementById("yearly-group").selectedIndex = 0;
+ document.getElementById("yearly-days").value = aByMonthDay;
+ }
+ }
+
+ function everyWeekDay(aByDay) {
+ // Checks if aByDay contains only values from 1 to 7 with any order.
+ let mask = aByDay.reduce((value, item) => value | (1 << item), 1);
+ return aByDay.length == 7 && mask == Math.pow(2, 8) - 1;
+ }
+
+ document.getElementById("week-start").value = rule.weekStart;
+
+ switch (rule.type) {
+ case "DAILY":
+ document.getElementById("period-list").selectedIndex = 0;
+ document.getElementById("daily-days").value = rule.interval;
+ break;
+ case "WEEKLY":
+ document.getElementById("weekly-weeks").value = rule.interval;
+ document.getElementById("period-list").selectedIndex = 1;
+ break;
+ case "MONTHLY":
+ document.getElementById("monthly-interval").value = rule.interval;
+ document.getElementById("period-list").selectedIndex = 2;
+ break;
+ case "YEARLY":
+ document.getElementById("yearly-interval").value = rule.interval;
+ document.getElementById("period-list").selectedIndex = 3;
+ break;
+ default:
+ document.getElementById("period-list").selectedIndex = 0;
+ dump("unable to handle your rule type!\n");
+ break;
+ }
+
+ let byDayRuleComponent = rule.getComponent("BYDAY");
+ let byMonthDayRuleComponent = rule.getComponent("BYMONTHDAY");
+ let byMonthRuleComponent = rule.getComponent("BYMONTH");
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ let startDate = gStartTime.getInTimezone(kDefaultTimezone);
+
+ // "DAILY" ruletype
+ // byDayRuleComponents may have been set priorily by "MONTHLY"- ruletypes
+ // where they have a different context-
+ // that's why we also query the current rule-type
+ if (byDayRuleComponent.length == 0 || rule.type != "DAILY") {
+ document.getElementById("daily-group").selectedIndex = 0;
+ } else {
+ document.getElementById("daily-group").selectedIndex = 1;
+ }
+
+ // "WEEKLY" ruletype
+ if (byDayRuleComponent.length == 0 || rule.type != "WEEKLY") {
+ DaypickerWeekday.days = [startDate.weekday + 1];
+ } else {
+ DaypickerWeekday.days = byDayRuleComponent;
+ }
+
+ // "MONTHLY" ruletype
+ let ruleComponentsEmpty = byDayRuleComponent.length == 0 && byMonthDayRuleComponent.length == 0;
+ if (ruleComponentsEmpty || rule.type != "MONTHLY") {
+ document.getElementById("monthly-group").selectedIndex = 1;
+ DaypickerMonthday.days = [startDate.day];
+ let day = Math.floor((startDate.day - 1) / 7) + 1;
+ document.getElementById("monthly-ordinal").value = day;
+ document.getElementById("monthly-weekday").value = startDate.weekday + 1;
+ } else if (everyWeekDay(byDayRuleComponent)) {
+ // Every day of the month.
+ document.getElementById("monthly-group").selectedIndex = 0;
+ document.getElementById("monthly-ordinal").value = 0;
+ document.getElementById("monthly-weekday").value = -1;
+ } else if (byDayRuleComponent.length > 0) {
+ // One of the first five days or weekdays of the month.
+ document.getElementById("monthly-group").selectedIndex = 0;
+ let ruleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]);
+ document.getElementById("monthly-ordinal").value = ruleInfo.ordinal;
+ document.getElementById("monthly-weekday").value = ruleInfo.weekday;
+ } else if (byMonthDayRuleComponent.length == 1 && byMonthDayRuleComponent[0] == -1) {
+ // The last day of the month.
+ document.getElementById("monthly-group").selectedIndex = 0;
+ document.getElementById("monthly-ordinal").value = byMonthDayRuleComponent[0];
+ document.getElementById("monthly-weekday").value = byMonthDayRuleComponent[0];
+ } else if (byMonthDayRuleComponent.length > 0) {
+ document.getElementById("monthly-group").selectedIndex = 1;
+ DaypickerMonthday.days = byMonthDayRuleComponent;
+ }
+
+ // "YEARLY" ruletype
+ if (byMonthRuleComponent.length == 0 || rule.type != "YEARLY") {
+ document.getElementById("yearly-month-rule").value = startDate.month + 1;
+ document.getElementById("yearly-month-ordinal").value = startDate.month + 1;
+ if (byMonthDayRuleComponent.length > 0) {
+ setControlsForByMonthDay_YearlyRule(startDate, byMonthDayRuleComponent[0]);
+ } else {
+ document.getElementById("yearly-days").value = startDate.day;
+ let ordinalDay = Math.floor((startDate.day - 1) / 7) + 1;
+ document.getElementById("yearly-ordinal").value = ordinalDay;
+ document.getElementById("yearly-weekday").value = startDate.weekday + 1;
+ }
+ } else {
+ document.getElementById("yearly-month-rule").value = byMonthRuleComponent[0];
+ document.getElementById("yearly-month-ordinal").value = byMonthRuleComponent[0];
+ if (byMonthDayRuleComponent.length > 0) {
+ let date = startDate.clone();
+ date.month = byMonthRuleComponent[0] - 1;
+ setControlsForByMonthDay_YearlyRule(date, byMonthDayRuleComponent[0]);
+ } else if (byDayRuleComponent.length > 0) {
+ document.getElementById("yearly-group").selectedIndex = 1;
+ if (everyWeekDay(byDayRuleComponent)) {
+ // Every day of the month.
+ document.getElementById("yearly-ordinal").value = 0;
+ document.getElementById("yearly-weekday").value = -1;
+ } else {
+ let yearlyRuleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]);
+ document.getElementById("yearly-ordinal").value = yearlyRuleInfo.ordinal;
+ document.getElementById("yearly-weekday").value = yearlyRuleInfo.weekday;
+ }
+ } else if (byMonthRuleComponent.length > 0) {
+ document.getElementById("yearly-group").selectedIndex = 0;
+ document.getElementById("yearly-days").value = startDate.day;
+ }
+ }
+
+ /* load up the duration of the event radiogroup */
+ if (rule.isByCount) {
+ if (rule.count == -1) {
+ document.getElementById("recurrence-duration").value = "forever";
+ } else {
+ document.getElementById("recurrence-duration").value = "ntimes";
+ document.getElementById("repeat-ntimes-count").value = rule.count;
+ }
+ } else {
+ let untilDate = rule.untilDate;
+ if (untilDate) {
+ gUntilDate = untilDate.getInTimezone(gStartTime.timezone); // calIRecurrenceRule::untilDate is always UTC or floating
+ // Change the until date to start date if the rule has a forbidden
+ // value (earlier than the start date).
+ if (gUntilDate.compare(gStartTime) < 0) {
+ gUntilDate = gStartTime.clone();
+ }
+ let repeatDate = cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating));
+ document.getElementById("recurrence-duration").value = "until";
+ document.getElementById("repeat-until-date").value = repeatDate;
+ } else {
+ document.getElementById("recurrence-duration").value = "forever";
+ }
+ }
+}
+
+/**
+ * Save the recurrence information selected in the dialog back to the given
+ * item.
+ *
+ * @param item The item to save back to.
+ * @returns The saved recurrence info.
+ */
+function onSave(item) {
+ // Always return 'null' if this item is an occurrence.
+ if (!item || item.parentItem != item) {
+ return null;
+ }
+
+ // This works, but if we ever support more complex recurrence,
+ // e.g. recurrence for Martians, then we're going to want to
+ // not clone and just recreate the recurrenceInfo each time.
+ // The reason is that the order of items (rules/dates/datesets)
+ // matters, so we can't always just append at the end. This
+ // code here always inserts a rule first, because all our
+ // exceptions should come afterward.
+ let periodNumber = Number(document.getElementById("period-list").value);
+
+ let args = window.arguments[0];
+ let recurrenceInfo = args.recurrenceInfo;
+ if (recurrenceInfo) {
+ recurrenceInfo = recurrenceInfo.clone();
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ if (rrules[0].length > 0) {
+ recurrenceInfo.deleteRecurrenceItem(rrules[0][0]);
+ }
+ recurrenceInfo.item = item;
+ } else {
+ recurrenceInfo = new CalRecurrenceInfo(item);
+ }
+
+ let recRule = cal.createRecurrenceRule();
+
+ // We don't let the user edit the start of the week for a given rule, but we
+ // want to preserve the value set
+ let weekStart = Number(document.getElementById("week-start").value);
+ recRule.weekStart = weekStart;
+
+ const ALL_WEEKDAYS = [2, 3, 4, 5, 6, 7, 1]; // The sequence MO,TU,WE,TH,FR,SA,SU.
+ switch (periodNumber) {
+ case 0: {
+ recRule.type = "DAILY";
+ let dailyGroup = document.getElementById("daily-group");
+ if (dailyGroup.selectedIndex == 0) {
+ let ndays = Math.max(1, Number(document.getElementById("daily-days").value));
+ recRule.interval = ndays;
+ } else {
+ recRule.interval = 1;
+ let onDays = [2, 3, 4, 5, 6];
+ recRule.setComponent("BYDAY", onDays);
+ }
+ break;
+ }
+ case 1: {
+ recRule.type = "WEEKLY";
+ let ndays = Number(document.getElementById("weekly-weeks").value);
+ recRule.interval = ndays;
+ let onDays = DaypickerWeekday.days;
+ if (onDays.length > 0) {
+ recRule.setComponent("BYDAY", onDays);
+ }
+ break;
+ }
+ case 2: {
+ recRule.type = "MONTHLY";
+ let monthInterval = Number(document.getElementById("monthly-interval").value);
+ recRule.interval = monthInterval;
+ let monthlyGroup = document.getElementById("monthly-group");
+ if (monthlyGroup.selectedIndex == 0) {
+ let monthlyOrdinal = Number(document.getElementById("monthly-ordinal").value);
+ let monthlyDOW = Number(document.getElementById("monthly-weekday").value);
+ if (monthlyDOW < 0) {
+ if (monthlyOrdinal == 0) {
+ // Monthly rule "Every day of the month".
+ recRule.setComponent("BYDAY", ALL_WEEKDAYS);
+ } else {
+ // One of the first five days or the last day of the month.
+ recRule.setComponent("BYMONTHDAY", [monthlyOrdinal]);
+ }
+ } else {
+ let sign = monthlyOrdinal < 0 ? -1 : 1;
+ let onDays = [(Math.abs(monthlyOrdinal) * 8 + monthlyDOW) * sign];
+ recRule.setComponent("BYDAY", onDays);
+ }
+ } else {
+ let monthlyDays = DaypickerMonthday.days;
+ if (monthlyDays.length > 0) {
+ recRule.setComponent("BYMONTHDAY", monthlyDays);
+ }
+ }
+ break;
+ }
+ case 3: {
+ recRule.type = "YEARLY";
+ let yearInterval = Number(document.getElementById("yearly-interval").value);
+ recRule.interval = yearInterval;
+ let yearlyGroup = document.getElementById("yearly-group");
+ if (yearlyGroup.selectedIndex == 0) {
+ let yearlyByMonth = [Number(document.getElementById("yearly-month-ordinal").value)];
+ recRule.setComponent("BYMONTH", yearlyByMonth);
+ let yearlyByDay = [Number(document.getElementById("yearly-days").value)];
+ recRule.setComponent("BYMONTHDAY", yearlyByDay);
+ } else {
+ let yearlyByMonth = [Number(document.getElementById("yearly-month-rule").value)];
+ recRule.setComponent("BYMONTH", yearlyByMonth);
+ let yearlyOrdinal = Number(document.getElementById("yearly-ordinal").value);
+ let yearlyDOW = Number(document.getElementById("yearly-weekday").value);
+ if (yearlyDOW < 0) {
+ if (yearlyOrdinal == 0) {
+ // Yearly rule "Every day of a month".
+ recRule.setComponent("BYDAY", ALL_WEEKDAYS);
+ } else {
+ // One of the first five days or the last of a month.
+ recRule.setComponent("BYMONTHDAY", [yearlyOrdinal]);
+ }
+ } else {
+ let sign = yearlyOrdinal < 0 ? -1 : 1;
+ let onDays = [(Math.abs(yearlyOrdinal) * 8 + yearlyDOW) * sign];
+ recRule.setComponent("BYDAY", onDays);
+ }
+ }
+ break;
+ }
+ }
+
+ // Figure out how long this event is supposed to last
+ switch (document.getElementById("recurrence-duration").selectedItem.value) {
+ case "forever": {
+ recRule.count = -1;
+ break;
+ }
+ case "ntimes": {
+ recRule.count = Math.max(1, document.getElementById("repeat-ntimes-count").value);
+ break;
+ }
+ case "until": {
+ let untilDate = cal.dtz.jsDateToDateTime(
+ document.getElementById("repeat-until-date").value,
+ gStartTime.timezone
+ );
+ untilDate.isDate = gStartTime.isDate; // enforce same value type as DTSTART
+ if (!gStartTime.isDate) {
+ // correct UNTIL to exactly match start date's hour, minute, second:
+ untilDate.hour = gStartTime.hour;
+ untilDate.minute = gStartTime.minute;
+ untilDate.second = gStartTime.second;
+ }
+ recRule.untilDate = untilDate;
+ break;
+ }
+ }
+
+ if (recRule.interval < 1) {
+ return null;
+ }
+
+ recurrenceInfo.insertRecurrenceItemAt(recRule, 0);
+ return recurrenceInfo;
+}
+
+/**
+ * Handler function to be called when the accept button is pressed.
+ */
+document.addEventListener("dialogaccept", event => {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ args.onOk(onSave(item));
+ // Don't close the dialog if a warning must be showed.
+ if (checkUntilDate.warning) {
+ event.preventDefault();
+ }
+});
+
+/**
+ * Handler function to be called when the Cancel button is pressed.
+ */
+document.addEventListener("dialogcancel", () => {
+ // Don't show any warning if the dialog must be closed.
+ checkUntilDate.warning = false;
+});
+
+/**
+ * Handler function called when the calendar is changed (also for initial
+ * setup).
+ *
+ * XXX we don't change the calendar in this dialog, this function should be
+ * consolidated or renamed.
+ *
+ * @param calendar The calendar to use for setup.
+ */
+function onChangeCalendar(calendar) {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+
+ // Set 'gIsReadOnly' if the calendar is read-only
+ gIsReadOnly = false;
+ if (calendar && calendar.readOnly) {
+ gIsReadOnly = true;
+ }
+
+ // Disable or enable controls based on a set or rules
+ // - whether this item is a stand-alone item or an occurrence
+ // - whether or not this item is read-only
+ // - whether or not the state of the item allows recurrence rules
+ // - tasks without an entrydate are invalid
+ disableOrEnable(item);
+
+ updateRecurrenceControls();
+}
+
+/**
+ * Disable or enable certain controls based on the given item:
+ * Uses the following attribute:
+ *
+ * - disable-on-occurrence
+ * - disable-on-readonly
+ *
+ * A task without a start time is also considered readonly.
+ *
+ * @param item The item to check.
+ */
+function disableOrEnable(item) {
+ if (item.parentItem != item) {
+ disableRecurrenceFields("disable-on-occurrence");
+ } else if (gIsReadOnly) {
+ disableRecurrenceFields("disable-on-readonly");
+ } else if (item.isTodo() && !gStartTime) {
+ disableRecurrenceFields("disable-on-readonly");
+ } else {
+ enableRecurrenceFields("disable-on-readonly");
+ }
+}
+
+/**
+ * Disables all fields that have an attribute that matches the argument and is
+ * set to "true".
+ *
+ * @param aAttributeName The attribute to search for.
+ */
+function disableRecurrenceFields(aAttributeName) {
+ let disableElements = document.getElementsByAttribute(aAttributeName, "true");
+ for (let i = 0; i < disableElements.length; i++) {
+ disableElements[i].setAttribute("disabled", "true");
+ }
+}
+
+/**
+ * Enables all fields that have an attribute that matches the argument and is
+ * set to "true".
+ *
+ * @param aAttributeName The attribute to search for.
+ */
+function enableRecurrenceFields(aAttributeName) {
+ let enableElements = document.getElementsByAttribute(aAttributeName, "true");
+ for (let i = 0; i < enableElements.length; i++) {
+ enableElements[i].removeAttribute("disabled");
+ }
+}
+
+/**
+ * Handler function to update the period-box when an item from the period-list
+ * is selected. Also updates the controls on that period-box.
+ */
+function updateRecurrenceBox() {
+ let periodBox = document.getElementById("period-box");
+ let periodNumber = Number(document.getElementById("period-list").value);
+ for (let i = 0; i < periodBox.children.length; i++) {
+ periodBox.children[i].hidden = i != periodNumber;
+ }
+ updateRecurrenceControls();
+}
+
+/**
+ * Updates the controls regarding ranged controls (i.e repeat forever, repeat
+ * until, repeat n times...)
+ */
+function updateRecurrenceRange() {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ if (item.parentItem != item || gIsReadOnly) {
+ return;
+ }
+
+ let radioRangeForever = document.getElementById("recurrence-range-forever");
+ let radioRangeFor = document.getElementById("recurrence-range-for");
+ let radioRangeUntil = document.getElementById("recurrence-range-until");
+ let rangeTimesCount = document.getElementById("repeat-ntimes-count");
+ let rangeUntilDate = document.getElementById("repeat-until-date");
+ let rangeAppointmentsLabel = document.getElementById("repeat-appointments-label");
+
+ radioRangeForever.removeAttribute("disabled");
+ radioRangeFor.removeAttribute("disabled");
+ radioRangeUntil.removeAttribute("disabled");
+ rangeAppointmentsLabel.removeAttribute("disabled");
+
+ let durationSelection = document.getElementById("recurrence-duration").selectedItem.value;
+
+ if (durationSelection == "ntimes") {
+ rangeTimesCount.removeAttribute("disabled");
+ } else {
+ rangeTimesCount.setAttribute("disabled", "true");
+ }
+
+ if (durationSelection == "until") {
+ rangeUntilDate.removeAttribute("disabled");
+ } else {
+ rangeUntilDate.setAttribute("disabled", "true");
+ }
+}
+
+/**
+ * Updates the recurrence preview calendars using the window's item.
+ */
+function updatePreview() {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ if (item.parentItem != item) {
+ item = item.parentItem;
+ }
+
+ // TODO: We should better start the whole dialog with a newly cloned item
+ // and always pump changes immediately into it. This would eliminate the
+ // need to break the encapsulation, as we do it here. But we need the item
+ // to contain the startdate in order to calculate the recurrence preview.
+ item = item.clone();
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ if (item.isEvent()) {
+ let startDate = gStartTime.getInTimezone(kDefaultTimezone);
+ let endDate = gEndTime.getInTimezone(kDefaultTimezone);
+ if (startDate.isDate) {
+ endDate.day--;
+ }
+
+ item.startDate = startDate;
+ item.endDate = endDate;
+ }
+ if (item.isTodo()) {
+ let entryDate = gStartTime;
+ if (entryDate) {
+ entryDate = entryDate.getInTimezone(kDefaultTimezone);
+ } else {
+ item.recurrenceInfo = null;
+ }
+ item.entryDate = entryDate;
+ let dueDate = gEndTime;
+ if (dueDate) {
+ dueDate = dueDate.getInTimezone(kDefaultTimezone);
+ }
+ item.dueDate = dueDate;
+ }
+
+ let recInfo = onSave(item);
+ RecurrencePreview.updatePreview(recInfo);
+}
+
+/**
+ * Checks the until date just entered in the datepicker in order to avoid
+ * setting a date earlier than the start date.
+ * Restores the previous correct date, shows a warning and prevents to close the
+ * dialog when the user enters a wrong until date.
+ */
+function checkUntilDate() {
+ if (!gStartTime) {
+ // This function shouldn't run before onLoad.
+ return;
+ }
+
+ let untilDate = cal.dtz.jsDateToDateTime(
+ document.getElementById("repeat-until-date").value,
+ gStartTime.timezone
+ );
+ let startDate = gStartTime.clone();
+ startDate.isDate = true;
+ if (untilDate.compare(startDate) < 0) {
+ let repeatDate = cal.dtz.dateTimeToJsDate(
+ (gUntilDate || gStartTime).getInTimezone(cal.dtz.floating)
+ );
+ document.getElementById("repeat-until-date").value = repeatDate;
+ checkUntilDate.warning = true;
+ let callback = function () {
+ // No warning when the dialog is being closed with the Cancel button.
+ if (!checkUntilDate.warning) {
+ return;
+ }
+ Services.prompt.alert(
+ null,
+ document.title,
+ cal.l10n.getCalString("warningUntilDateBeforeStart")
+ );
+ checkUntilDate.warning = false;
+ };
+ setTimeout(callback, 1);
+ } else {
+ gUntilDate = untilDate;
+ updateRecurrenceControls();
+ }
+}
+
+/**
+ * Checks the date entered for a yearly absolute rule (i.e. every 12 of January)
+ * in order to avoid creating a rule on an invalid date.
+ */
+function checkYearlyAbsoluteDate() {
+ if (!gStartTime) {
+ // This function shouldn't run before onLoad.
+ return;
+ }
+
+ const MONTH_LENGTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ let dayOfMonth = document.getElementById("yearly-days").value;
+ let month = document.getElementById("yearly-month-ordinal").value;
+ document.getElementById("yearly-days").max = MONTH_LENGTHS[month - 1];
+ // Check if the day value is too high.
+ if (dayOfMonth > MONTH_LENGTHS[month - 1]) {
+ document.getElementById("yearly-days").value = MONTH_LENGTHS[month - 1];
+ } else {
+ updateRecurrenceControls();
+ }
+ // Check if the day value is too low.
+ if (dayOfMonth < 1) {
+ document.getElementById("yearly-days").value = 1;
+ } else {
+ updateRecurrenceControls();
+ }
+}
+
+/**
+ * Update all recurrence controls on the dialog.
+ */
+function updateRecurrenceControls() {
+ updateRecurrencePattern();
+ updateRecurrenceRange();
+ updatePreview();
+ window.sizeToContent();
+}
+
+/**
+ * Disables/enables controls related to the recurrence pattern.
+ * the status of the controls depends on which period entry is selected
+ * and which form of pattern rule is selected.
+ */
+function updateRecurrencePattern() {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ if (item.parentItem != item || gIsReadOnly) {
+ return;
+ }
+
+ switch (Number(document.getElementById("period-list").value)) {
+ // daily
+ case 0: {
+ let dailyGroup = document.getElementById("daily-group");
+ let dailyDays = document.getElementById("daily-days");
+ dailyDays.removeAttribute("disabled");
+ if (dailyGroup.selectedIndex == 1) {
+ dailyDays.setAttribute("disabled", "true");
+ }
+ break;
+ }
+ // weekly
+ case 1: {
+ break;
+ }
+ // monthly
+ case 2: {
+ let monthlyGroup = document.getElementById("monthly-group");
+ let monthlyOrdinal = document.getElementById("monthly-ordinal");
+ let monthlyWeekday = document.getElementById("monthly-weekday");
+ let monthlyDays = DaypickerMonthday;
+ monthlyOrdinal.removeAttribute("disabled");
+ monthlyWeekday.removeAttribute("disabled");
+ monthlyDays.enable();
+ if (monthlyGroup.selectedIndex == 0) {
+ monthlyDays.disable();
+ } else {
+ monthlyOrdinal.setAttribute("disabled", "true");
+ monthlyWeekday.setAttribute("disabled", "true");
+ }
+ break;
+ }
+ // yearly
+ case 3: {
+ let yearlyGroup = document.getElementById("yearly-group");
+ let yearlyDays = document.getElementById("yearly-days");
+ let yearlyMonthOrdinal = document.getElementById("yearly-month-ordinal");
+ let yearlyPeriodOfMonthLabel = document.getElementById("yearly-period-of-month-label");
+ let yearlyOrdinal = document.getElementById("yearly-ordinal");
+ let yearlyWeekday = document.getElementById("yearly-weekday");
+ let yearlyMonthRule = document.getElementById("yearly-month-rule");
+ let yearlyPeriodOfLabel = document.getElementById("yearly-period-of-label");
+ yearlyDays.removeAttribute("disabled");
+ yearlyMonthOrdinal.removeAttribute("disabled");
+ yearlyOrdinal.removeAttribute("disabled");
+ yearlyWeekday.removeAttribute("disabled");
+ yearlyMonthRule.removeAttribute("disabled");
+ yearlyPeriodOfLabel.removeAttribute("disabled");
+ yearlyPeriodOfMonthLabel.removeAttribute("disabled");
+ if (yearlyGroup.selectedIndex == 0) {
+ yearlyOrdinal.setAttribute("disabled", "true");
+ yearlyWeekday.setAttribute("disabled", "true");
+ yearlyMonthRule.setAttribute("disabled", "true");
+ yearlyPeriodOfLabel.setAttribute("disabled", "true");
+ } else {
+ yearlyDays.setAttribute("disabled", "true");
+ yearlyMonthOrdinal.setAttribute("disabled", "true");
+ yearlyPeriodOfMonthLabel.setAttribute("disabled", "true");
+ }
+ break;
+ }
+ }
+}
+
+/**
+ * This function changes the order for certain elements using a locale string.
+ * This is needed for some locales that expect a different wording order.
+ *
+ * @param aPropKey The locale property key to get the order from
+ * @param aPropParams An array of ids to be passed to the locale property.
+ * These should be the ids of the elements to change
+ * the order for.
+ */
+function changeOrderForElements(aPropKey, aPropParams) {
+ let localeOrder;
+ let parents = {};
+
+ for (let key in aPropParams) {
+ // Save original parents so that the nodes to reorder get appended to
+ // the correct parent nodes.
+ parents[key] = document.getElementById(aPropParams[key]).parentNode;
+ }
+
+ try {
+ localeOrder = cal.l10n.getString("calendar-event-dialog", aPropKey, aPropParams).split(" ");
+ } catch (ex) {
+ let msg =
+ "The key " +
+ aPropKey +
+ " in calendar-event-dialog.prop" +
+ "erties has incorrect number of params. Expected " +
+ aPropParams.length +
+ " params.";
+ console.error(msg + " " + ex);
+ return;
+ }
+
+ // Add elements in the right order, removing them from their old parent
+ for (let i = 0; i < aPropParams.length; i++) {
+ let newEl = document.getElementById(localeOrder[i]);
+ if (newEl) {
+ parents[i].appendChild(newEl);
+ } else {
+ cal.ERROR(
+ "Localization error, could not find node '" +
+ localeOrder[i] +
+ "'. Please have your localizer check the string '" +
+ aPropKey +
+ "'"
+ );
+ }
+ }
+}
+
+/**
+ * Change locale-specific widget order for Edit Recurrence window
+ */
+function changeWidgetsOrder() {
+ changeOrderForElements("monthlyOrder", ["monthly-ordinal", "monthly-weekday"]);
+ changeOrderForElements("yearlyOrder", [
+ "yearly-days",
+ "yearly-period-of-month-label",
+ "yearly-month-ordinal",
+ ]);
+ changeOrderForElements("yearlyOrder2", [
+ "yearly-ordinal",
+ "yearly-weekday",
+ "yearly-period-of-label",
+ "yearly-month-rule",
+ ]);
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml
new file mode 100644
index 0000000000..f762a3b3ef
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xhtml
@@ -0,0 +1,1077 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-daypicker.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+%dialogDTD; ]>
+<html
+ id="calendar-event-dialog-recurrence"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:EventDialog:Recurrence"
+ persist="screenX screenY width height"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&recurrence.title.label;</title>
+ <link rel="localization" href="calendar/calendar-recurrence-dialog.ftl" />
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script>
+ <script
+ defer="defer"
+ src="chrome://calendar/content/calendar-event-dialog-recurrence.js"
+ ></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog>
+ <!-- recurrence pattern -->
+ <html:fieldset id="recurrence-pattern-groupbox">
+ <html:legend id="recurrence-pattern-caption">&event.recurrence.pattern.label;</html:legend>
+ <hbox flex="1" id="recurrence-pattern-hbox">
+ <vbox>
+ <label
+ value="&event.recurrence.occurs.label;"
+ class="recurrence-pattern-hbox-label"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ control="period-list"
+ />
+ </vbox>
+ <vbox flex="1">
+ <menulist
+ id="period-list"
+ oncommand="updateRecurrenceBox();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="period-list-menupopup">
+ <menuitem
+ id="period-list-day-menuitem"
+ label="&event.recurrence.day.label;"
+ value="0"
+ />
+ <menuitem
+ id="period-list-week-menuitem"
+ label="&event.recurrence.week.label;"
+ value="1"
+ />
+ <menuitem
+ id="period-list-month-menuitem"
+ label="&event.recurrence.month.label;"
+ value="2"
+ />
+ <menuitem
+ id="period-list-year-menuitem"
+ label="&event.recurrence.year.label;"
+ value="3"
+ />
+ </menupopup>
+ </menulist>
+ <html:input id="week-start" type="hidden" value="1" />
+ <hbox id="period-box" oncommand="updateRecurrenceControls();">
+ <!-- Daily -->
+ <box id="period-box-daily-box" orient="vertical" align="start">
+ <radiogroup id="daily-group">
+ <box id="daily-period-every-box" orient="horizontal" align="center">
+ <radio
+ id="daily-group-every-radio"
+ label="&event.recurrence.pattern.every.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ selected="true"
+ />
+ <html:input
+ id="daily-days"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ max="32767"
+ value="1"
+ oninput="updateRecurrenceControls();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label
+ id="daily-group-every-units-label"
+ value="&repeat.units.days.both;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <spacer id="daily-group-spacer" flex="1" />
+ </box>
+ <radio
+ id="daily-group-weekday-radio"
+ label="&event.recurrence.pattern.every.weekday.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ </radiogroup>
+ </box>
+ <!-- Weekly -->
+ <vbox id="period-box-weekly-box" hidden="true">
+ <hbox id="weekly-period-every-box" align="center">
+ <label
+ id="weekly-period-every-label"
+ value="&event.recurrence.pattern.weekly.every.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ control="weekly-weeks"
+ />
+ <html:input
+ id="weekly-weeks"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ max="32767"
+ value="1"
+ oninput="updateRecurrenceControls();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label
+ id="weekly-period-units-label"
+ value="&repeat.units.weeks.both;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ </hbox>
+ <separator class="thin" />
+ <hbox align="center">
+ <label
+ id="weekly-period-on-label"
+ value="&event.recurrence.on.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ control="daypicker-weekday"
+ />
+ <hbox
+ id="daypicker-weekday"
+ flex="1"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ mode="daypicker-weekday"
+ />
+ </hbox>
+ </hbox>
+ </vbox>
+
+ <!-- Monthly -->
+ <vbox id="period-box-monthly-box" hidden="true">
+ <hbox id="montly-period-every-box" align="center">
+ <label
+ id="monthly-period-every-label"
+ value="&event.recurrence.pattern.monthly.every.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ control="monthly-interval"
+ />
+ <html:input
+ id="monthly-interval"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ max="32767"
+ value="1"
+ oninput="updateRecurrenceControls();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label
+ id="monthly-period-units-label"
+ value="&repeat.units.months.both;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ </hbox>
+ <radiogroup id="monthly-group">
+ <box id="monthly-period-relative-date-box" orient="horizontal" align="center">
+ <radio
+ id="montly-period-relative-date-radio"
+ selected="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <menulist
+ id="monthly-ordinal"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="montly-ordinal-menupopup">
+ <menuitem
+ id="monthly-ordinal-every-label"
+ label="&event.recurrence.monthly.every.label;"
+ value="0"
+ />
+ <menuitem
+ id="monthly-ordinal-first-label"
+ label="&event.recurrence.monthly.first.label;"
+ value="1"
+ />
+ <menuitem
+ id="monthly-ordinal-second-label"
+ label="&event.recurrence.monthly.second.label;"
+ value="2"
+ />
+ <menuitem
+ id="monthly-ordinal-third-label"
+ label="&event.recurrence.monthly.third.label;"
+ value="3"
+ />
+ <menuitem
+ id="monthly-ordinal-fourth-label"
+ label="&event.recurrence.monthly.fourth.label;"
+ value="4"
+ />
+ <menuitem
+ id="monthly-ordinal-fifth-label"
+ label="&event.recurrence.monthly.fifth.label;"
+ value="5"
+ />
+ <menuitem
+ id="monthly-ordinal-last-label"
+ label="&event.recurrence.monthly.last.label;"
+ value="-1"
+ />
+ </menupopup>
+ </menulist>
+ <menulist
+ id="monthly-weekday"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="monthly-weekday-menupopup">
+ <menuitem
+ id="monthly-weekday-1"
+ label="&event.recurrence.pattern.monthly.week.1.label;"
+ value="1"
+ />
+ <menuitem
+ id="monthly-weekday-2"
+ label="&event.recurrence.pattern.monthly.week.2.label;"
+ value="2"
+ />
+ <menuitem
+ id="monthly-weekday-3"
+ label="&event.recurrence.pattern.monthly.week.3.label;"
+ value="3"
+ />
+ <menuitem
+ id="monthly-weekday-4"
+ label="&event.recurrence.pattern.monthly.week.4.label;"
+ value="4"
+ />
+ <menuitem
+ id="monthly-weekday-5"
+ label="&event.recurrence.pattern.monthly.week.5.label;"
+ value="5"
+ />
+ <menuitem
+ id="monthly-weekday-6"
+ label="&event.recurrence.pattern.monthly.week.6.label;"
+ value="6"
+ />
+ <menuitem
+ id="monthly-weekday-7"
+ label="&event.recurrence.pattern.monthly.week.7.label;"
+ value="7"
+ />
+ <menuitem
+ id="monthly-weekday-dayofmonth"
+ label="&event.recurrence.repeat.dayofmonth.label;"
+ value="-1"
+ />
+ </menupopup>
+ </menulist>
+ </box>
+ <separator class="thin" />
+ <box id="monthly-period-specific-date-box" orient="horizontal" align="center">
+ <radio
+ id="montly-period-specific-date-radio"
+ label="&event.recurrence.repeat.recur.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <hbox id="monthly-days" class="daypicker-monthday">
+ <vbox class="daypicker-monthday-mainbox" flex="1">
+ <hbox class="daypicker-row" flex="1">
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="1"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="2"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="3"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="4"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="5"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="6"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="7"
+ mode="monthly-days"
+ />
+ </hbox>
+ <hbox class="daypicker-row" flex="1">
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="8"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="9"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="10"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="11"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="12"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="13"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="14"
+ mode="monthly-days"
+ />
+ </hbox>
+ <hbox class="daypicker-row" flex="1">
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="15"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="16"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="17"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="18"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="19"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="20"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="21"
+ mode="monthly-days"
+ />
+ </hbox>
+ <hbox class="daypicker-row" flex="1">
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="22"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="23"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="24"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="25"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="26"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="27"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="28"
+ mode="monthly-days"
+ />
+ </hbox>
+ <hbox class="daypicker-row" flex="1">
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="29"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="30"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label="31"
+ mode="monthly-days"
+ />
+ <button
+ class="calendar-daypicker"
+ type="checkbox"
+ autoCheck="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ label=""
+ mode="monthly-days"
+ />
+ </hbox>
+ </vbox>
+ </hbox>
+ </box>
+ </radiogroup>
+ </vbox>
+
+ <!-- Yearly -->
+ <box id="period-box-yearly-box" orient="vertical" align="start" hidden="true">
+ <hbox id="yearly-period-every-box" align="center">
+ <label
+ id="yearly-period-every-label"
+ value="&event.recurrence.every.label;"
+ control="yearly-interval"
+ />
+ <html:input
+ id="yearly-interval"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ max="32767"
+ value="1"
+ oninput="updateRecurrenceControls();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label id="yearly-period-units-label" value="&repeat.units.years.both;" />
+ </hbox>
+ <radiogroup id="yearly-group">
+ <vbox>
+ <hbox>
+ <radio
+ id="yearly-period-absolute-radio"
+ label="&event.recurrence.pattern.yearly.every.month.label;"
+ selected="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <box id="yearly-period-absolute-controls" orient="horizontal" align="center">
+ <html:input
+ id="yearly-days"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ value="1"
+ oninput="checkYearlyAbsoluteDate();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label
+ id="yearly-period-of-month-label"
+ value="&event.recurrence.pattern.yearly.of.label;"
+ control="yearly-month-ordinal"
+ />
+ <menulist
+ id="yearly-month-ordinal"
+ onselect="checkYearlyAbsoluteDate()"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="yearly-month-ordinal-menupopup">
+ <menuitem
+ id="yearly-month-ordinal-1"
+ label="&event.recurrence.pattern.yearly.month.1.label;"
+ value="1"
+ />
+ <menuitem
+ id="yearly-month-ordinal-2"
+ label="&event.recurrence.pattern.yearly.month.2.label;"
+ value="2"
+ />
+ <menuitem
+ id="yearly-month-ordinal-3"
+ label="&event.recurrence.pattern.yearly.month.3.label;"
+ value="3"
+ />
+ <menuitem
+ id="yearly-month-ordinal-4"
+ label="&event.recurrence.pattern.yearly.month.4.label;"
+ value="4"
+ />
+ <menuitem
+ id="yearly-month-ordinal-5"
+ label="&event.recurrence.pattern.yearly.month.5.label;"
+ value="5"
+ />
+ <menuitem
+ id="yearly-month-ordinal-6"
+ label="&event.recurrence.pattern.yearly.month.6.label;"
+ value="6"
+ />
+ <menuitem
+ id="yearly-month-ordinal-7"
+ label="&event.recurrence.pattern.yearly.month.7.label;"
+ value="7"
+ />
+ <menuitem
+ id="yearly-month-ordinal-8"
+ label="&event.recurrence.pattern.yearly.month.8.label;"
+ value="8"
+ />
+ <menuitem
+ id="yearly-month-ordinal-9"
+ label="&event.recurrence.pattern.yearly.month.9.label;"
+ value="9"
+ />
+ <menuitem
+ id="yearly-month-ordinal-10"
+ label="&event.recurrence.pattern.yearly.month.10.label;"
+ value="10"
+ />
+ <menuitem
+ id="yearly-month-ordinal-11"
+ label="&event.recurrence.pattern.yearly.month.11.label;"
+ value="11"
+ />
+ <menuitem
+ id="yearly-month-ordinal-12"
+ label="&event.recurrence.pattern.yearly.month.12.label;"
+ value="12"
+ />
+ </menupopup>
+ </menulist>
+ </box>
+ </hbox>
+ <hbox>
+ <vbox>
+ <hbox>
+ <radio
+ id="yearly-period-relative-radio"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <box
+ id="yearly-period-relative-controls"
+ orient="horizontal"
+ align="center"
+ >
+ <menulist
+ id="yearly-ordinal"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="yearly-ordinal-menupopup">
+ <menuitem
+ id="yearly-ordinal-every"
+ label="&event.recurrence.yearly.every.label;"
+ value="0"
+ />
+ <menuitem
+ id="yearly-ordinal-first"
+ label="&event.recurrence.yearly.first.label;"
+ value="1"
+ />
+ <menuitem
+ id="yearly-ordinal-second"
+ label="&event.recurrence.yearly.second.label;"
+ value="2"
+ />
+ <menuitem
+ id="yearly-ordinal-third"
+ label="&event.recurrence.yearly.third.label;"
+ value="3"
+ />
+ <menuitem
+ id="yearly-ordinal-fourth"
+ label="&event.recurrence.yearly.fourth.label;"
+ value="4"
+ />
+ <menuitem
+ id="yearly-ordinal-fifth"
+ label="&event.recurrence.yearly.fifth.label;"
+ value="5"
+ />
+ <menuitem
+ id="yearly-ordinal-last"
+ label="&event.recurrence.yearly.last.label;"
+ value="-1"
+ />
+ </menupopup>
+ </menulist>
+ <menulist
+ id="yearly-weekday"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="yearly-weekday-menupopup">
+ <menuitem
+ id="yearly-weekday-1"
+ label="&event.recurrence.pattern.yearly.week.1.label;"
+ value="1"
+ />
+ <menuitem
+ id="yearly-weekday-2"
+ label="&event.recurrence.pattern.yearly.week.2.label;"
+ value="2"
+ />
+ <menuitem
+ id="yearly-weekday-3"
+ label="&event.recurrence.pattern.yearly.week.3.label;"
+ value="3"
+ />
+ <menuitem
+ id="yearly-weekday-4"
+ label="&event.recurrence.pattern.yearly.week.4.label;"
+ value="4"
+ />
+ <menuitem
+ id="yearly-weekday-5"
+ label="&event.recurrence.pattern.yearly.week.5.label;"
+ value="5"
+ />
+ <menuitem
+ id="yearly-weekday-6"
+ label="&event.recurrence.pattern.yearly.week.6.label;"
+ value="6"
+ />
+ <menuitem
+ id="yearly-weekday-7"
+ label="&event.recurrence.pattern.yearly.week.7.label;"
+ value="7"
+ />
+ <menuitem
+ id="yearly-weekday--1"
+ label="&event.recurrence.pattern.yearly.day.label;"
+ value="-1"
+ />
+ </menupopup>
+ </menulist>
+ </box>
+ </hbox>
+ <hbox>
+ <label
+ id="yearly-period-of-label"
+ class="recurrence-pattern-hbox-label"
+ value="&event.recurrence.of.label;"
+ control="yearly-month-rule"
+ />
+ <menulist
+ id="yearly-month-rule"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ >
+ <menupopup id="yearly-month-rule-menupopup">
+ <menuitem
+ id="yearly-month-rule-1"
+ label="&event.recurrence.pattern.yearly.month2.1.label;"
+ value="1"
+ />
+ <menuitem
+ id="yearly-month-rule-2"
+ label="&event.recurrence.pattern.yearly.month2.2.label;"
+ value="2"
+ />
+ <menuitem
+ id="yearly-month-rule-3"
+ label="&event.recurrence.pattern.yearly.month2.3.label;"
+ value="3"
+ />
+ <menuitem
+ id="yearly-month-rule-4"
+ label="&event.recurrence.pattern.yearly.month2.4.label;"
+ value="4"
+ />
+ <menuitem
+ id="yearly-month-rule-5"
+ label="&event.recurrence.pattern.yearly.month2.5.label;"
+ value="5"
+ />
+ <menuitem
+ id="yearly-month-rule-6"
+ label="&event.recurrence.pattern.yearly.month2.6.label;"
+ value="6"
+ />
+ <menuitem
+ id="yearly-month-rule-7"
+ label="&event.recurrence.pattern.yearly.month2.7.label;"
+ value="7"
+ />
+ <menuitem
+ id="yearly-month-rule-8"
+ label="&event.recurrence.pattern.yearly.month2.8.label;"
+ value="8"
+ />
+ <menuitem
+ id="yearly-month-rule-9"
+ label="&event.recurrence.pattern.yearly.month2.9.label;"
+ value="9"
+ />
+ <menuitem
+ id="yearly-month-rule-10"
+ label="&event.recurrence.pattern.yearly.month2.10.label;"
+ value="10"
+ />
+ <menuitem
+ id="yearly-month-rule-11"
+ label="&event.recurrence.pattern.yearly.month2.11.label;"
+ value="11"
+ />
+ <menuitem
+ id="yearly-month-rule-12"
+ label="&event.recurrence.pattern.yearly.month2.12.label;"
+ value="12"
+ />
+ </menupopup>
+ </menulist>
+ </hbox>
+ </vbox>
+ </hbox>
+ </vbox>
+ </radiogroup>
+ </box>
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+
+ <!-- range of recurrence -->
+ <html:fieldset id="recurrence-range-groupbox">
+ <html:legend id="recurrence-range-caption">&event.recurrence.range.label;</html:legend>
+ <vbox>
+ <radiogroup id="recurrence-duration" oncommand="updateRecurrenceControls()">
+ <radio
+ id="recurrence-range-forever"
+ label="&event.recurrence.forever.label;"
+ value="forever"
+ selected="true"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <box id="recurrence-range-count-box" orient="horizontal" align="center">
+ <radio
+ id="recurrence-range-for"
+ label="&event.recurrence.repeat.for.label;"
+ value="ntimes"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <html:input
+ id="repeat-ntimes-count"
+ type="number"
+ class="size3 input-inline"
+ min="1"
+ max="32767"
+ value="5"
+ oninput="updateRecurrenceControls();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ <label
+ id="repeat-appointments-label"
+ value="&event.recurrence.appointments.label;"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ </box>
+ <box id="recurrence-range-until-box" orient="horizontal" align="center">
+ <radio
+ id="recurrence-range-until"
+ label="&event.repeat.until.label;"
+ value="until"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ control="repeat-until-date"
+ />
+ <datepicker
+ id="repeat-until-date"
+ onchange="checkUntilDate();"
+ disable-on-readonly="true"
+ disable-on-occurrence="true"
+ />
+ </box>
+ </radiogroup>
+ </vbox>
+ </html:fieldset>
+
+ <!-- preview -->
+ <html:fieldset id="recurrencePreviewContainer">
+ <html:legend
+ id="recurrencePreviewLabel"
+ data-l10n-id="calendar-recurrence-preview-label"
+ ></html:legend>
+ <html:div id="recurrencePreviewNavigation">
+ <html:button
+ id="recurrencePrevious"
+ data-l10n-id="calendar-recurrence-previous"
+ ></html:button>
+ <html:button id="recurrenceToday" data-l10n-id="calendar-recurrence-today"></html:button>
+ <html:button id="recurrenceNext" data-l10n-id="calendar-recurrence-next"></html:button>
+ </html:div>
+ <html:div id="recurrencePreviewCalendars">
+ <html:div id="recurrencePreview">
+ <calendar-minimonth
+ readonly="true"
+ hidden="false"
+ active-month="true"
+ initial-month="true"
+ />
+ <calendar-minimonth readonly="true" hidden="false" />
+ <calendar-minimonth readonly="true" hidden="false" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ <calendar-minimonth readonly="true" hidden="true" />
+ </html:div>
+ </html:div>
+ </html:fieldset>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js
new file mode 100644
index 0000000000..3ee61c0e2f
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.js
@@ -0,0 +1,508 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported onLoad, onReminderSelected, updateReminder, onNewReminder, onRemoveReminder */
+
+/* global MozElements */
+
+/* import-globals-from ../calendar-ui-utils.js */
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+var allowedActionsMap = {};
+var suppressListUpdate = false;
+
+XPCOMUtils.defineLazyGetter(this, "gReminderNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ document.getElementById("reminder-notifications").append(element);
+ });
+});
+
+/**
+ * Sets up the reminder dialog.
+ */
+function onLoad() {
+ let calendar = window.arguments[0].calendar;
+
+ // Make sure the origin menulist uses the right labels, depending on if the
+ // dialog is showing an event or task.
+ function _sn(x) {
+ return cal.l10n.getString("calendar-alarms", getItemBundleStringName(x));
+ }
+
+ document.getElementById("reminder-before-start-menuitem").label = _sn(
+ "reminderCustomOriginBeginBefore"
+ );
+
+ document.getElementById("reminder-after-start-menuitem").label = _sn(
+ "reminderCustomOriginBeginAfter"
+ );
+
+ document.getElementById("reminder-before-end-menuitem").label = _sn(
+ "reminderCustomOriginEndBefore"
+ );
+
+ document.getElementById("reminder-after-end-menuitem").label = _sn(
+ "reminderCustomOriginEndAfter"
+ );
+
+ // Set up the action map
+ let supportedActions = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; // TODO email support, "EMAIL"
+ for (let action of supportedActions) {
+ allowedActionsMap[action] = true;
+ }
+
+ // Hide all actions that are not supported by this provider
+ let firstAvailableItem;
+ let actionNodes = document.getElementById("reminder-actions-menupopup").children;
+ for (let actionNode of actionNodes) {
+ let shouldHide =
+ !(actionNode.value in allowedActionsMap) ||
+ (actionNode.hasAttribute("provider") && actionNode.getAttribute("provider") != calendar.type);
+ actionNode.hidden = shouldHide;
+ if (!firstAvailableItem && !shouldHide) {
+ firstAvailableItem = actionNode;
+ }
+ }
+
+ // Correct the selected item on the supported actions list. This will be
+ // changed when reminders are loaded, but in case there are none we need to
+ // provide a sensible default.
+ if (firstAvailableItem) {
+ document.getElementById("reminder-actions-menulist").selectedItem = firstAvailableItem;
+ }
+
+ loadReminders();
+ opener.setCursor("auto");
+}
+
+/**
+ * Load Reminders from the window's arguments and set up dialog controls to
+ * their initial values.
+ */
+function loadReminders() {
+ let args = window.arguments[0];
+ let listbox = document.getElementById("reminder-listbox");
+ let reminders = args.reminders || args.item.getAlarms();
+
+ // This dialog should not be shown if the calendar doesn't support alarms at
+ // all, so the case of maxCount = 0 breaking this logic doesn't apply.
+ let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount");
+ let count = Math.min(reminders.length, maxReminders || reminders.length);
+ for (let i = 0; i < count; i++) {
+ if (reminders[i].action in allowedActionsMap) {
+ // Set up the listitem and add it to the listbox, but only if the
+ // action is actually supported by the calendar.
+ let listitem = setupListItem(null, reminders[i].clone(), args.item);
+ if (listitem) {
+ listbox.appendChild(listitem);
+ }
+ }
+ }
+
+ // Set up a default absolute date. This will be overridden if the selected
+ // alarm is absolute.
+ let absDate = document.getElementById("reminder-absolute-date");
+ absDate.value = cal.dtz.dateTimeToJsDate(cal.dtz.getDefaultStartDate());
+
+ if (listbox.children.length) {
+ // We have reminders, select the first by default. For some reason,
+ // setting the selected index in a load handler makes the selection
+ // break for the set item, therefore we need a setTimeout.
+ setupMaxReminders();
+ setTimeout(() => {
+ listbox.selectedIndex = 0;
+ }, 0);
+ } else {
+ // Make sure the fields are disabled if we have no alarms
+ setupRadioEnabledState(true);
+ }
+}
+
+/**
+ * Sets up the enabled state of the reminder details controls. Used when
+ * switching between absolute and relative alarms to disable and enable the
+ * needed controls.
+ *
+ * @param aDisableAll Disable all relation controls. Used when no alarms
+ * are added yet.
+ */
+function setupRadioEnabledState(aDisableAll) {
+ let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem;
+ let relativeDisabled, absoluteDisabled;
+
+ if (aDisableAll) {
+ relativeDisabled = true;
+ absoluteDisabled = true;
+ } else if (relationItem) {
+ // This is not a mistake, when this function is called from onselect,
+ // the value has not been set.
+ relativeDisabled = relationItem.value == "absolute";
+ absoluteDisabled = relationItem.value == "relative";
+ } else {
+ relativeDisabled = false;
+ absoluteDisabled = false;
+ }
+
+ document.getElementById("reminder-length").disabled = relativeDisabled;
+ document.getElementById("reminder-unit").disabled = relativeDisabled;
+ document.getElementById("reminder-relation-origin").disabled = relativeDisabled;
+
+ document.getElementById("reminder-absolute-date").setAttribute("disabled", !!absoluteDisabled);
+
+ document.getElementById("reminder-relative-radio").disabled = aDisableAll;
+ document.getElementById("reminder-absolute-radio").disabled = aDisableAll;
+ document.getElementById("reminder-actions-menulist").disabled = aDisableAll;
+}
+
+/**
+ * Sets up the max reminders notification. Shows or hides the notification
+ * depending on if the max reminders limit has been hit or not.
+ */
+function setupMaxReminders() {
+ let args = window.arguments[0];
+ let listbox = document.getElementById("reminder-listbox");
+ let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount");
+
+ let hitMaxReminders = maxReminders && listbox.children.length >= maxReminders;
+
+ // If we hit the maximum number of reminders, show the error box and
+ // disable the new button.
+ document.getElementById("reminder-new-button").disabled = hitMaxReminders;
+
+ let localeErrorString = cal.l10n.getString(
+ "calendar-alarms",
+ getItemBundleStringName("reminderErrorMaxCountReached"),
+ [maxReminders]
+ );
+ let pluralErrorLabel = PluralForm.get(maxReminders, localeErrorString).replace(
+ "#1",
+ maxReminders
+ );
+
+ if (hitMaxReminders) {
+ let notification = gReminderNotification.appendNotification(
+ "reminderNotification",
+ {
+ label: pluralErrorLabel,
+ priority: gReminderNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ notification.closeButton.hidden = true;
+ } else {
+ gReminderNotification.removeAllNotifications();
+ }
+}
+
+/**
+ * Sets up a reminder listitem for the list of reminders applied to this item.
+ *
+ * @param aListItem (optional) A reference listitem to set up. If not
+ * passed, a new listitem will be created.
+ * @param aReminder The calIAlarm to display in this listitem
+ * @param aItem The item the alarm is set up on.
+ * @returns The XUL listitem node showing the passed reminder, or
+ * null if no list item should be shown.
+ */
+function setupListItem(aListItem, aReminder, aItem) {
+ let src;
+ let l10nId;
+ switch (aReminder.action) {
+ case "DISPLAY":
+ src = "chrome://messenger/skin/icons/new/bell.svg";
+ l10nId = "calendar-event-reminder-icon-display";
+ break;
+ case "EMAIL":
+ src = "chrome://messenger/skin/icons/new/mail-sm.svg";
+ l10nId = "calendar-event-reminder-icon-email";
+ break;
+ case "AUDIO":
+ src = "chrome://messenger/skin/icons/new/bell-ring.svg";
+ l10nId = "calendar-event-reminder-icon-audio";
+ break;
+ default:
+ return null;
+ }
+
+ let listitem = aListItem || document.createXULElement("richlistitem");
+
+ // Create a random id to be used for accessibility
+ let reminderId = cal.getUUID();
+ let ariaLabel = "reminder-action-" + aReminder.action + " " + reminderId;
+
+ listitem.reminder = aReminder;
+ listitem.setAttribute("id", reminderId);
+ listitem.setAttribute("align", "center");
+ listitem.setAttribute("aria-labelledby", ariaLabel);
+ listitem.setAttribute("value", aReminder.action);
+
+ let image = listitem.querySelector("img");
+ if (!image) {
+ image = document.createElement("img");
+ image.setAttribute("class", "reminder-icon");
+ listitem.appendChild(image);
+ }
+ image.setAttribute("src", src);
+ // Sets alt.
+ document.l10n.setAttributes(image, l10nId);
+ image.setAttribute("value", aReminder.action);
+
+ let label = listitem.querySelector("label");
+ if (!label) {
+ label = document.createXULElement("label");
+ listitem.appendChild(label);
+ }
+ label.setAttribute("value", aReminder.toString(aItem));
+
+ return listitem;
+}
+
+/**
+ * Handler function to be called when a reminder is selected in the listbox.
+ * Sets up remaining controls to show the selected alarm.
+ */
+function onReminderSelected() {
+ let length = document.getElementById("reminder-length");
+ let unit = document.getElementById("reminder-unit");
+ let relationOrigin = document.getElementById("reminder-relation-origin");
+ let absDate = document.getElementById("reminder-absolute-date");
+ let actionType = document.getElementById("reminder-actions-menulist");
+ let relationType = document.getElementById("reminder-relation-radiogroup");
+
+ let listbox = document.getElementById("reminder-listbox");
+ let listitem = listbox.selectedItem;
+
+ if (listitem) {
+ try {
+ suppressListUpdate = true;
+ let reminder = listitem.reminder;
+
+ // Action
+ actionType.value = reminder.action;
+
+ // Absolute/relative things
+ if (reminder.related == Ci.calIAlarm.ALARM_RELATED_ABSOLUTE) {
+ relationType.value = "absolute";
+
+ // Date
+ absDate.value = cal.dtz.dateTimeToJsDate(
+ reminder.alarmDate || cal.dtz.getDefaultStartDate()
+ );
+ } else {
+ relationType.value = "relative";
+
+ // Unit and length
+ let alarmlen = Math.abs(reminder.offset.inSeconds / 60);
+ if (alarmlen % 1440 == 0) {
+ unit.value = "days";
+ length.value = alarmlen / 1440;
+ } else if (alarmlen % 60 == 0) {
+ unit.value = "hours";
+ length.value = alarmlen / 60;
+ } else {
+ unit.value = "minutes";
+ length.value = alarmlen;
+ }
+
+ // Relation
+ let relation = reminder.offset.isNegative ? "before" : "after";
+
+ // Origin
+ let origin;
+ if (reminder.related == Ci.calIAlarm.ALARM_RELATED_START) {
+ origin = "START";
+ } else if (reminder.related == Ci.calIAlarm.ALARM_RELATED_END) {
+ origin = "END";
+ }
+
+ relationOrigin.value = [relation, origin].join("-");
+ }
+ } finally {
+ suppressListUpdate = false;
+ }
+ } else {
+ // no list item is selected, disable elements
+ setupRadioEnabledState(true);
+ }
+}
+
+/**
+ * Handler function to be called when an aspect of the alarm has been changed
+ * using the dialog controls.
+ *
+ * @param event The DOM event caused by the change.
+ */
+function updateReminder(event) {
+ if (
+ suppressListUpdate ||
+ event.target.localName == "richlistitem" ||
+ event.target.parentNode.localName == "richlistitem" ||
+ event.target.id == "reminder-remove-button" ||
+ !document.commandDispatcher.focusedElement
+ ) {
+ // Do not set things if the select came from selecting or removing an
+ // alarm from the list, or from setting when the dialog initially loaded.
+ // XXX Quite fragile hack since radio/radiogroup doesn't have the
+ // supressOnSelect stuff.
+ return;
+ }
+ let listbox = document.getElementById("reminder-listbox");
+ let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem;
+ let listitem = listbox.selectedItem;
+ if (!listitem || !relationItem) {
+ return;
+ }
+ let reminder = listitem.reminder;
+ let length = document.getElementById("reminder-length");
+ let unit = document.getElementById("reminder-unit");
+ let relationOrigin = document.getElementById("reminder-relation-origin");
+ let [relation, origin] = relationOrigin.value.split("-");
+ let absDate = document.getElementById("reminder-absolute-date");
+ let action = document.getElementById("reminder-actions-menulist").selectedItem.value;
+
+ // Action
+ reminder.action = action;
+
+ if (relationItem.value == "relative") {
+ if (origin == "START") {
+ reminder.related = Ci.calIAlarm.ALARM_RELATED_START;
+ } else if (origin == "END") {
+ reminder.related = Ci.calIAlarm.ALARM_RELATED_END;
+ }
+
+ // Set up offset, taking units and before/after into account
+ let offset = cal.createDuration();
+ offset[unit.value] = length.value;
+ offset.normalize();
+ offset.isNegative = relation == "before";
+ reminder.offset = offset;
+ } else if (relationItem.value == "absolute") {
+ reminder.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+
+ if (absDate.value) {
+ reminder.alarmDate = cal.dtz.jsDateToDateTime(absDate.value, window.arguments[0].timezone);
+ } else {
+ reminder.alarmDate = null;
+ }
+ }
+
+ if (!setupListItem(listitem, reminder, window.arguments[0].item)) {
+ // Unexpected since this would mean switching to an unsupported type.
+ listitem.remove();
+ }
+}
+
+/**
+ * Gets the locale stringname that is dependent on the item type. This function
+ * appends the item type, i.e |aPrefix + "Event"|.
+ *
+ * @param aPrefix The prefix to prepend to the item type
+ * @returns The full string name.
+ */
+function getItemBundleStringName(aPrefix) {
+ if (window.arguments[0].item.isEvent()) {
+ return aPrefix + "Event";
+ }
+ return aPrefix + "Task";
+}
+
+/**
+ * Handler function to be called when the "new" button is pressed, to create a
+ * new reminder item.
+ */
+function onNewReminder() {
+ let itemType = window.arguments[0].item.isEvent() ? "event" : "todo";
+ let listbox = document.getElementById("reminder-listbox");
+
+ let reminder = new CalAlarm();
+ let alarmlen = Services.prefs.getIntPref("calendar.alarms." + itemType + "alarmlen", 15);
+ let alarmunit = Services.prefs.getStringPref(
+ "calendar.alarms." + itemType + "alarmunit",
+ "minutes"
+ );
+
+ // Default is a relative DISPLAY alarm, |alarmlen| minutes before the event.
+ // If DISPLAY is not supported by the provider, then pick the provider's
+ // first alarm type.
+ let offset = cal.createDuration();
+ if (alarmunit == "days") {
+ offset.days = alarmlen;
+ } else if (alarmunit == "hours") {
+ offset.hours = alarmlen;
+ } else {
+ offset.minutes = alarmlen;
+ }
+ offset.normalize();
+ offset.isNegative = true;
+ reminder.related = Ci.calIAlarm.ALARM_RELATED_START;
+ reminder.offset = offset;
+ if ("DISPLAY" in allowedActionsMap) {
+ reminder.action = "DISPLAY";
+ } else {
+ let calendar = window.arguments[0].calendar;
+ let actions = calendar.getProperty("capabilities.alarms.actionValues") || [];
+ reminder.action = actions[0];
+ }
+
+ // Set up the listbox
+ let listitem = setupListItem(null, reminder, window.arguments[0].item);
+ if (!listitem) {
+ return;
+ }
+ listbox.appendChild(listitem);
+ listbox.selectItem(listitem);
+
+ // Since we've added an item, its safe to always enable the button
+ document.getElementById("reminder-remove-button").removeAttribute("disabled");
+
+ // Set up the enabled state and max reminders
+ setupRadioEnabledState();
+ setupMaxReminders();
+}
+
+/**
+ * Handler function to be called when the "remove" button is pressed to remove
+ * the selected reminder item and advance the selection.
+ */
+function onRemoveReminder() {
+ let listbox = document.getElementById("reminder-listbox");
+ let listitem = listbox.selectedItem;
+ let newSelection = listitem
+ ? listitem.nextElementSibling || listitem.previousElementSibling
+ : null;
+
+ listbox.clearSelection();
+ listitem.remove();
+ listbox.selectItem(newSelection);
+
+ document.getElementById("reminder-remove-button").disabled = listbox.children.length < 1;
+ setupMaxReminders();
+}
+
+/**
+ * Handler function to be called when the accept button is pressed.
+ */
+document.addEventListener("dialogaccept", () => {
+ let listbox = document.getElementById("reminder-listbox");
+ let reminders = Array.from(listbox.children).map(node => node.reminder);
+ if (window.arguments[0].onOk) {
+ window.arguments[0].onOk(reminders);
+ }
+});
+
+/**
+ * Handler function to be called when the cancel button is pressed.
+ */
+document.addEventListener("dialogcancel", () => {
+ if (window.arguments[0].onCancel) {
+ window.arguments[0].onCancel();
+ }
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml
new file mode 100644
index 0000000000..64fe13ff77
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-reminder.xhtml
@@ -0,0 +1,148 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE window SYSTEM "chrome://calendar/locale/dialogs/calendar-event-dialog-reminder.dtd">
+
+<window
+ id="calendar-event-dialog-reminder"
+ title="&reminderdialog.title;"
+ windowtype="Calendar:EventDialog:Reminder"
+ onload="onLoad()"
+ persist="screenX screenY width height"
+ lightweightthemes="true"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog>
+ <linkset>
+ <html:link rel="localization" href="calendar/calendar-event-dialog-reminder.ftl" />
+ </linkset>
+
+ <!-- Javascript includes -->
+ <script src="chrome://calendar/content/calendar-event-dialog-reminder.js" />
+ <script src="chrome://calendar/content/calendar-ui-utils.js" />
+ <script src="chrome://calendar/content/widgets/calendar-minimonth.js" />
+ <script src="chrome://calendar/content/widgets/datetimepickers.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <vbox id="reminder-notifications" class="notification-inline">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+ <!-- Listbox with custom reminders -->
+ <vbox flex="1">
+ <richlistbox
+ id="reminder-listbox"
+ seltype="single"
+ class="event-dialog-listbox"
+ onselect="onReminderSelected()"
+ flex="1"
+ />
+ <hbox id="reminder-action-buttons-box" pack="end">
+ <button
+ id="reminder-new-button"
+ label="&reminder.add.label;"
+ accesskey="&reminder.add.accesskey;"
+ oncommand="onNewReminder()"
+ />
+ <button
+ id="reminder-remove-button"
+ label="&reminder.remove.label;"
+ accesskey="&reminder.remove.accesskey;"
+ oncommand="onRemoveReminder()"
+ />
+ </hbox>
+ </vbox>
+
+ <hbox id="reminder-details-caption" class="calendar-caption" align="center">
+ <label value="&reminder.reminderDetails.label;" class="header" />
+ <separator class="groove" flex="1" />
+ </hbox>
+ <radiogroup
+ id="reminder-relation-radiogroup"
+ onselect="setupRadioEnabledState(); updateReminder(event)"
+ >
+ <hbox id="reminder-relative-box" align="start" flex="1">
+ <radio
+ id="reminder-relative-radio"
+ value="relative"
+ aria-labelledby="reminder-length reminder-unit reminder-relation-origin"
+ />
+ <vbox id="reminder-relative-box" flex="1">
+ <hbox id="reminder-relative-length-unit-relation" align="center" flex="1">
+ <html:input
+ id="reminder-length"
+ class="input-inline"
+ type="number"
+ min="0"
+ oninput="updateReminder(event)"
+ />
+ <menulist id="reminder-unit" oncommand="updateReminder(event)" flex="1">
+ <menupopup id="reminder-unit-menupopup">
+ <menuitem
+ id="reminder-minutes-menuitem"
+ label="&alarm.units.minutes;"
+ value="minutes"
+ />
+ <menuitem id="reminder-hours-menuitem" label="&alarm.units.hours;" value="hours" />
+ <menuitem id="reminder-days-menuitem" label="&alarm.units.days;" value="days" />
+ </menupopup>
+ </menulist>
+ </hbox>
+ <menulist id="reminder-relation-origin" oncommand="updateReminder(event)">
+ <menupopup id="reminder-relation-origin-menupopup">
+ <!-- The labels here will be set in calendar-event-dialog-reminder.js -->
+ <menuitem id="reminder-before-start-menuitem" value="before-START" />
+ <menuitem id="reminder-after-start-menuitem" value="after-START" />
+ <menuitem id="reminder-before-end-menuitem" value="before-END" />
+ <menuitem id="reminder-after-end-menuitem" value="after-END" />
+ </menupopup>
+ </menulist>
+ </vbox>
+ </hbox>
+ <hbox id="reminder-absolute-box" flex="1">
+ <radio id="reminder-absolute-radio" control="reminder-absolute-date" value="absolute" />
+ <datetimepicker id="reminder-absolute-date" />
+ </hbox>
+ </radiogroup>
+
+ <hbox id="reminder-actions-caption" class="calendar-caption" align="center">
+ <label value="&reminder.action.label;" control="reminder-actions-menulist" class="header" />
+ <separator class="groove" flex="1" />
+ </hbox>
+ <menulist
+ id="reminder-actions-menulist"
+ oncommand="updateReminder(event)"
+ class="reminder-list-icon"
+ >
+ <!-- Make sure the id is formatted "reminder-action-<VALUE>", for accessibility -->
+ <!-- TODO provider specific -->
+ <menupopup id="reminder-actions-menupopup">
+ <menuitem
+ id="reminder-action-DISPLAY"
+ class="reminder-list-icon menuitem-iconic"
+ value="DISPLAY"
+ label="&reminder.action.alert.label;"
+ />
+ <menuitem
+ id="reminder-action-EMAIL"
+ class="reminder-list-icon menuitem-iconic"
+ value="EMAIL"
+ label="&reminder.action.email.label;"
+ />
+ </menupopup>
+ </menulist>
+ </dialog>
+</window>
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js
new file mode 100644
index 0000000000..620932dda3
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.js
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global addMenuItem */ // From ../calendar-ui-utils.js
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+window.addEventListener("load", onLoad);
+
+/**
+ * Sets up the timezone dialog from the window arguments, also setting up all
+ * dialog controls from the window's dates.
+ */
+function onLoad() {
+ let args = window.arguments[0];
+ window.time = args.time;
+ window.onAcceptCallback = args.onOk;
+
+ let menulist = document.getElementById("timezone-menulist");
+ let tzMenuPopup = document.getElementById("timezone-menupopup");
+
+ // floating and UTC (if supported) at the top:
+ if (args.calendar.getProperty("capabilities.timezones.floating.supported") !== false) {
+ addMenuItem(tzMenuPopup, cal.dtz.floating.displayName, cal.dtz.floating.tzid);
+ }
+ if (args.calendar.getProperty("capabilities.timezones.UTC.supported") !== false) {
+ addMenuItem(tzMenuPopup, cal.dtz.UTC.displayName, cal.dtz.UTC.tzid);
+ }
+
+ let tzids = {};
+ let displayNames = [];
+ for (let timezoneId of cal.timezoneService.timezoneIds) {
+ let timezone = cal.timezoneService.getTimezone(timezoneId);
+ if (timezone && !timezone.isFloating && !timezone.isUTC) {
+ let displayName = timezone.displayName;
+ displayNames.push(displayName);
+ tzids[displayName] = timezone.tzid;
+ }
+ }
+ // the display names need to be sorted
+ displayNames.sort((a, b) => a.localeCompare(b));
+ for (let i = 0; i < displayNames.length; ++i) {
+ let displayName = displayNames[i];
+ addMenuItem(tzMenuPopup, displayName, tzids[displayName]);
+ }
+
+ let index = findTimezone(window.time.timezone);
+ if (index < 0) {
+ index = findTimezone(cal.dtz.defaultTimezone);
+ if (index < 0) {
+ index = 0;
+ }
+ }
+
+ menulist = document.getElementById("timezone-menulist");
+ menulist.selectedIndex = index;
+
+ updateTimezone();
+
+ opener.setCursor("auto");
+}
+
+/**
+ * Find the index of the timezone menuitem corresponding to the given timezone.
+ *
+ * @param timezone The calITimezone to look for.
+ * @returns The index of the childnode below "timezone-menulist"
+ */
+function findTimezone(timezone) {
+ let tzid = timezone.tzid;
+ let menulist = document.getElementById("timezone-menulist");
+ let numChilds = menulist.children[0].children.length;
+ for (let i = 0; i < numChilds; i++) {
+ let menuitem = menulist.children[0].children[i];
+ if (menuitem.getAttribute("value") == tzid) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+/**
+ * Handler function to call when the timezone selection has changed. Updates the
+ * timezone-time field and the timezone-stack.
+ */
+function updateTimezone() {
+ let menulist = document.getElementById("timezone-menulist");
+ let menuitem = menulist.selectedItem;
+ let timezone = cal.timezoneService.getTimezone(menuitem.getAttribute("value"));
+
+ // convert the date/time to the currently selected timezone
+ // and display the result in the appropriate control.
+ // before feeding the date/time value into the control we need
+ // to set the timezone to 'floating' in order to avoid the
+ // automatic conversion back into the OS timezone.
+ let datetime = document.getElementById("timezone-time");
+ let time = window.time.getInTimezone(timezone);
+ time.timezone = cal.dtz.floating;
+ datetime.value = cal.dtz.dateTimeToJsDate(time);
+
+ // don't highlight any timezone in the map by default
+ let standardTZOffset = "none";
+ if (timezone.isUTC) {
+ standardTZOffset = "+0000";
+ } else if (!timezone.isFloating) {
+ let standard = timezone.icalComponent.getFirstSubcomponent("STANDARD");
+ // any reason why valueAsIcalString is used instead of plain value? xxx todo: ask mickey
+ standardTZOffset = standard.getFirstProperty("TZOFFSETTO").valueAsIcalString;
+ }
+
+ let image = document.getElementById("highlighter");
+ image.setAttribute("tzid", standardTZOffset);
+}
+
+/**
+ * Handler function to be called when the accept button is pressed.
+ */
+document.addEventListener("dialogaccept", () => {
+ let menulist = document.getElementById("timezone-menulist");
+ let menuitem = menulist.selectedItem;
+ let timezoneString = menuitem.getAttribute("value");
+ let timezone = cal.timezoneService.getTimezone(timezoneString);
+ let datetime = window.time.getInTimezone(timezone);
+ window.onAcceptCallback(datetime);
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml
new file mode 100644
index 0000000000..8ec36bfce8
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog-timezone.xhtml
@@ -0,0 +1,63 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-timezone-highlighter.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%dtd2;
+<!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" >
+%dtd3; ]>
+<html
+ id="calendar-event-dialog-timezone"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:EventDialog:Timezone"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&timezone.title.label;</title>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script>
+ <script
+ defer="defer"
+ src="chrome://calendar/content/calendar-event-dialog-timezone.js"
+ ></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog>
+ <hbox align="center">
+ <spacer flex="1" />
+ <datetimepicker id="timezone-time" disabled="true" />
+ </hbox>
+
+ <menulist id="timezone-menulist" oncommand="updateTimezone()">
+ <menupopup id="timezone-menupopup" style="height: 460px" />
+ </menulist>
+
+ <stack id="timezone-stack">
+ <html:img src="chrome://calendar/skin/shared/timezone_map.png" alt="" />
+ <html:img
+ id="highlighter"
+ src="chrome://calendar/skin/shared/timezones.png"
+ alt=""
+ class="timezone-highlight"
+ tzid="+0000"
+ />
+ </stack>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml
new file mode 100644
index 0000000000..8029ff3174
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-event-dialog.xhtml
@@ -0,0 +1,584 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+#ifdef MOZ_SUITE
+<?xml-stylesheet type="text/css" href="chrome://communicator/skin/communicator.css"?>
+#endif
+
+<!DOCTYPE window [
+ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+ <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+ <!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+ <!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+ %brandDTD;
+ %globalDTD;
+ %calendarDTD;
+ %eventDialogDTD;
+]>
+
+<!-- Dialog id is changed during execution to allow different Window-icons
+ on this dialog. document.loadOverlay() will not work on this one. -->
+<window id="calendar-event-window"
+ title="&event.title.label;"
+ icon="calendar-general-dialog"
+ windowtype="Calendar:EventDialog"
+ onload="onLoadCalendarItemPanel();"
+ onunload="onUnloadCalendarItemPanel();"
+ persist="screenX screenY width height"
+ lightweightthemes="true"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml">
+
+<dialog>
+
+ <!-- Javascript includes -->
+ <script src="chrome://calendar/content/calendar-item-panel.js"/>
+ <script src="chrome://calendar/content/calendar-dialog-utils.js"/>
+ <script src="chrome://calendar/content/calendar-ui-utils.js"/>
+ <script src="chrome://messenger/content/globalOverlay.js"/>
+ <script src="chrome://messenger/content/toolbarIconColor.js"/>
+ <script src="chrome://messenger/content/customizable-toolbar.js"/>
+
+ <stringbundle id="languageBundle" src="chrome://global/locale/languageNames.properties"/>
+
+ <!-- Command updater -->
+ <commandset id="globalEditMenuItems"
+ commandupdater="true"
+ events="focus"
+ oncommandupdate="goUpdateGlobalEditMenuItems()"/>
+ <commandset id="selectEditMenuItems"
+ commandupdater="true"
+ events="select"
+ oncommandupdate="goUpdateSelectEditMenuItems()"/>
+ <commandset id="undoEditMenuItems"
+ commandupdater="true"
+ events="undo"
+ oncommandupdate="goUpdateUndoEditMenuItems()"/>
+ <commandset id="clipboardEditMenuItems"
+ commandupdater="true"
+ events="clipboard"
+ oncommandupdate="goUpdatePasteMenuItems()"/>
+
+ <!-- Commands -->
+ <commandset id="itemCommands">
+
+ <!-- Item menu -->
+ <command id="cmd_item_new_event"
+ oncommand="openNewEvent()"/>
+ <command id="cmd_item_new_task"
+ oncommand="openNewTask()"/>
+ <command id="cmd_item_new_message"
+ oncommand="openNewMessage()"/>
+ <command id="cmd_item_close"
+ oncommand="cancelDialog()"/>
+ <command id="cmd_save"
+ disable-on-readonly="true"
+ oncommand="onCommandSave()"/>
+ <command id="cmd_item_delete"
+ disable-on-readonly="true"
+ oncommand="onCommandDeleteItem()"/>
+
+ <!-- Edit menu -->
+ <command id="cmd_undo"
+ disabled="true"
+ oncommand="goDoCommand('cmd_undo')"/>
+ <command id="cmd_redo"
+ disabled="true"
+ oncommand="goDoCommand('cmd_redo')"/>
+ <command id="cmd_cut"
+ disabled="true"
+ oncommand="goDoCommand('cmd_cut')"/>
+ <command id="cmd_copy"
+ disabled="true"
+ oncommand="goDoCommand('cmd_copy')"/>
+ <command id="cmd_paste"
+ disabled="true"
+ oncommand="goDoCommand('cmd_paste')"/>
+ <command id="cmd_selectAll"
+ disabled="true"
+ oncommand="goDoCommand('cmd_selectAll')"/>
+
+ <!-- View menu -->
+ <command id="cmd_toolbar"
+ oncommand="onCommandViewToolbar('event-toolbar',
+ 'view-toolbars-event-menuitem')"/>
+ <command id="cmd_customize"
+ oncommand="onCommandCustomize()"/>
+
+ <!-- status -->
+ <command id="cmd_status_none"
+ oncommand="editStatus(event.target)"
+ hidden="true"
+ value="NONE"/>
+ <command id="cmd_status_tentative"
+ oncommand="editStatus(event.target)"
+ value="TENTATIVE"/>
+ <command id="cmd_status_confirmed"
+ oncommand="editStatus(event.target)"
+ value="CONFIRMED"/>
+ <command id="cmd_status_cancelled"
+ oncommand="editStatus(event.target)"
+ value="CANCELLED"/>
+
+ <!-- priority -->
+ <command id="cmd_priority_none"
+ oncommand="editPriority(event.target)"
+ value="0"/>
+ <command id="cmd_priority_low"
+ oncommand="editPriority(event.target)"
+ value="9"/>
+ <command id="cmd_priority_normal"
+ oncommand="editPriority(event.target)"
+ value="5"/>
+ <command id="cmd_priority_high"
+ oncommand="editPriority(event.target)"
+ value="1"/>
+
+ <!-- freebusy -->
+ <command id="cmd_showtimeas_busy"
+ oncommand="editShowTimeAs(event.target)"
+ value="OPAQUE"/>
+ <command id="cmd_showtimeas_free"
+ oncommand="editShowTimeAs(event.target)"
+ value="TRANSPARENT"/>
+
+ <!-- attendees -->
+ <command id="cmd_attendees"
+ oncommand="editAttendees();"/>
+ <command id="cmd_email"
+ oncommand="sendMailToAttendees(window.attendees);"/>
+ <command id="cmd_email_undecided"
+ oncommand="sendMailToUndecidedAttendees(window.attendees);"/>
+
+ <!-- accept, attachments, timezone -->
+ <command id="cmd_accept"
+ disable-on-readonly="true"
+ oncommand="acceptDialog();"/>
+ <command id="cmd_attach_url"
+ disable-on-readonly="true"
+ oncommand="attachURL()"/>
+ <command id="cmd_attach_cloud"
+ disable-on-readonly="true"/>
+ <command id="cmd_timezone"
+ persist="checked"
+ checked="false"
+ oncommand="toggleTimezoneLinks()"/>
+ </commandset>
+
+ <keyset id="calendar-event-dialog-keyset">
+ <key id="new-message-key"
+ modifiers="accel"
+ key="&event.dialog.new.message.key2;"
+ command="cmd_item_new_message"/>
+ <key id="close-key"
+ modifiers="accel"
+ key="&event.dialog.close.key;"
+ command="cmd_item_close"/>
+ <key id="save-key"
+ modifiers="accel"
+ key="&event.dialog.save.key;"
+ command="cmd_save"/>
+ <key id="saveandclose-key"
+ modifiers="accel"
+ key="&event.dialog.saveandclose.key;"
+ command="cmd_accept"/>
+ <key id="undo-key"
+ modifiers="accel"
+ key="&event.dialog.undo.key;"
+ command="cmd_undo"/>
+ <key id="redo-key"
+ modifiers="accel"
+ key="&event.dialog.redo.key;"
+ command="cmd_redo"/>
+ <key id="cut-key"
+ modifiers="accel"
+ key="&event.dialog.cut.key;"
+ command="cmd_cut"/>
+ <key id="copy-key"
+ modifiers="accel"
+ key="&event.dialog.copy.key;"
+ command="cmd_copy"/>
+ <key id="paste-key"
+ modifiers="accel"
+ key="&event.dialog.paste.key;"
+ command="cmd_paste"/>
+ <key id="select-all-key"
+ modifiers="accel"
+ key="&event.dialog.select.all.key;"
+ command="cmd_selectAll"/>
+ </keyset>
+
+ <menupopup id="event-dialog-toolbar-context-menu">
+ <menuitem id="CustomizeDialogToolbar"
+ label="&event.menu.view.toolbars.customize.label;"
+ command="cmd_customize"/>
+ </menupopup>
+
+ <!-- Toolbox contains the menubar -->
+ <toolbox id="event-toolbox"
+ class="mail-toolbox"
+ mode="full"
+ defaultmode="full"
+ iconsize="small"
+ defaulticonsize="small"
+ labelalign="end"
+ defaultlabelalign="end">
+
+ <!-- Menubar -->
+ <toolbar type="menubar">
+ <menubar id="event-menubar">
+
+ <!-- Item menu -->
+ <!-- These 2 Strings are placeholders, values are set at runtime -->
+ <menu label="Item"
+ accesskey="I"
+ id="item-menu">
+ <menupopup id="item-menupopup">
+ <menu id="item-new-menu"
+ label="&event.menu.item.new.label;"
+ accesskey="&event.menu.item.new.accesskey;">
+ <menupopup id="item-new-menupopup" class="menulist-menupopup">
+ <menuitem id="item-new-message-menuitem"
+ label="&event.menu.item.new.message.label;"
+ accesskey="&event.menu.item.new.message.accesskey;"
+ key="new-message-key"
+ command="cmd_item_new_message"
+ disable-on-readonly="true"/>
+ <menuitem id="item-new-event-menuitem"
+ label="&event.menu.item.new.event.label;"
+ accesskey="&event.menu.item.new.event.accesskey;"
+ command="cmd_item_new_event"
+ disable-on-readonly="true"/>
+ <menuitem id="item-new-task-menuitem"
+ label="&event.menu.item.new.task.label;"
+ accesskey="&event.menu.item.new.task.accesskey;"
+ command="cmd_item_new_task"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="item-menuseparator1"/>
+ <menuitem id="item-save-menuitem"
+ label="&event.menu.item.save.label;"
+ accesskey="&event.menu.item.save.accesskey;"
+ key="save-key"
+ command="cmd_save"/>
+ <menuitem id="item-saveandclose-menuitem"
+ label="&event.menu.item.saveandclose.label;"
+ accesskey="&event.menu.item.saveandclose.accesskey;"
+ key="saveandclose-key"
+ command="cmd_accept"/>
+ <menuitem id="item-delete-menuitem"
+ label="&event.menu.item.delete.label;"
+ accesskey="&event.menu.item.delete.accesskey;"
+ command="cmd_item_delete"
+ disable-on-readonly="true"/>
+ <menuseparator id="item-menuseparator1"/>
+ <menuitem id="item-close-menuitem"
+ label="&event.menu.item.close.label;"
+ accesskey="&event.menu.item.close.accesskey;"
+ key="close-key"
+ command="cmd_item_close"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+
+ <!-- Edit menu -->
+ <menu id="edit-menu"
+ label="&event.menu.edit.label;"
+ accesskey="&event.menu.edit.accesskey;"
+ collapse-on-readonly="true">
+ <menupopup id="edit-menupopup">
+ <menuitem id="edit-undo-menuitem"
+ label="&event.menu.edit.undo.label;"
+ accesskey="&event.menu.edit.undo.accesskey;"
+ key="undo-key"
+ command="cmd_undo"/>
+ <menuitem id="edit-redo-menuitem"
+ label="&event.menu.edit.redo.label;"
+ accesskey="&event.menu.edit.redo.accesskey;"
+ key="redo-key"
+ command="cmd_redo"/>
+ <menuseparator id="edit-menuseparator1"/>
+ <menuitem id="edit-cut-menuitem"
+ label="&event.menu.edit.cut.label;"
+ accesskey="&event.menu.edit.cut.accesskey;"
+ key="cut-key"
+ command="cmd_cut"/>
+ <menuitem id="edit-copy-menuitem"
+ label="&event.menu.edit.copy.label;"
+ accesskey="&event.menu.edit.copy.accesskey;"
+ key="copy-key"
+ command="cmd_copy"/>
+ <menuitem id="edit-paste-menuitem"
+ label="&event.menu.edit.paste.label;"
+ accesskey="&event.menu.edit.paste.accesskey;"
+ key="paste-key"
+ command="cmd_paste"/>
+ <menuseparator id="edit-menuseparator2"/>
+ <menuitem id="edit-selectall-menuitem"
+ label="&event.menu.edit.select.all.label;"
+ accesskey="&event.menu.edit.select.all.accesskey;"
+ key="select-all-key"
+ command="cmd_selectAll"/>
+ </menupopup>
+ </menu>
+
+ <!-- View menu -->
+ <menu id="view-menu"
+ label="&event.menu.view.label;"
+ accesskey="&event.menu.view.accesskey;"
+ collapse-on-readonly="true">
+ <menupopup id="view-menupopup">
+ <menu id="view-toolbars-menu"
+ label="&event.menu.view.toolbars.label;"
+ accesskey="&event.menu.view.toolbars.accesskey;">
+ <menupopup id="view-toolbars-menupopup">
+ <menuitem id="view-toolbars-event-menuitem"
+ label="&event.menu.view.toolbars.event.label;"
+ accesskey="&event.menu.view.toolbars.event.accesskey;"
+ type="checkbox"
+ checked="true"
+ command="cmd_toolbar"/>
+ <menuseparator id="view-toolbars-menuseparator1"/>
+ <menuitem id="view-toolbars-customize-menuitem"
+ label="&event.menu.view.toolbars.customize.label;"
+ accesskey="&event.menu.view.toolbars.customize.accesskey;"
+ command="cmd_customize"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+
+ <!-- Options menu -->
+ <menu id="options-menu"
+ label="&event.menu.options.label;"
+ accesskey="&event.menu.options.accesskey;">
+ <menupopup id="options-menupopup">
+ <menuitem id="options-attendees-menuitem"
+ label="&event.menu.options.attendees.label;"
+ accesskey="&event.menu.options.attendees.accesskey;"
+ command="cmd_attendees"
+ disable-on-readonly="true"/>
+ <menu id="options-attachments-menu"
+ label="&event.attachments.menubutton.label;"
+ accesskey="&event.attachments.menubutton.accesskey;">
+ <menupopup id="options-attachments-menupopup">
+ <menuitem id="options-attachments-url-menuitem"
+ label="&event.attachments.url.label;"
+ accesskey="&event.attachments.url.accesskey;"
+ command="cmd_attach_url"/>
+ <!-- Additional items are added here in loadCloudProviders(). -->
+ </menupopup>
+ </menu>
+ <menuitem id="options-timezones-menuitem"
+ label="&event.menu.options.timezone2.label;"
+ accesskey="&event.menu.options.timezone2.accesskey;"
+ type="checkbox"
+ command="cmd_timezone"
+ disable-on-readonly="true"/>
+ <menuseparator id="options-menuseparator1"/>
+ <menu id="options-priority-menu"
+ label="&event.menu.options.priority2.label;"
+ accesskey="&event.menu.options.priority2.accesskey;"
+ disable-on-readonly="true">
+ <menupopup id="options-priority-menupopup">
+ <menuitem id="options-priority-none-menuitem"
+ label="&event.menu.options.priority.notspecified.label;"
+ accesskey="&event.menu.options.priority.notspecified.accesskey;"
+ type="radio"
+ command="cmd_priority_none"
+ disable-on-readonly="true"/>
+ <menuitem id="options-priority-low-menuitem"
+ label="&event.menu.options.priority.low.label;"
+ accesskey="&event.menu.options.priority.low.accesskey;"
+ type="radio"
+ command="cmd_priority_low"
+ disable-on-readonly="true"/>
+ <menuitem id="options-priority-normal-label"
+ label="&event.menu.options.priority.normal.label;"
+ accesskey="&event.menu.options.priority.normal.accesskey;"
+ type="radio"
+ command="cmd_priority_normal"
+ disable-on-readonly="true"/>
+ <menuitem id="options-priority-high-label"
+ label="&event.menu.options.priority.high.label;"
+ accesskey="&event.menu.options.priority.high.accesskey;"
+ type="radio"
+ command="cmd_priority_high"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+ <menu id="options-privacy-menu"
+ label="&event.menu.options.privacy.label;"
+ accesskey="&event.menu.options.privacy.accesskey;"
+ disable-on-readonly="true">
+ <menupopup id="options-privacy-menupopup">
+ <menuitem id="options-privacy-public-menuitem"
+ label="&event.menu.options.privacy.public.label;"
+ accesskey="&event.menu.options.privacy.public.accesskey;"
+ type="radio"
+ privacy="PUBLIC"
+ oncommand="editPrivacy(this, event)"
+ disable-on-readonly="true"/>
+ <menuitem id="options-privacy-confidential-menuitem"
+ label="&event.menu.options.privacy.confidential.label;"
+ accesskey="&event.menu.options.privacy.confidential.accesskey;"
+ type="radio"
+ privacy="CONFIDENTIAL"
+ oncommand="editPrivacy(this, event)"
+ disable-on-readonly="true"/>
+ <menuitem id="options-privacy-private-menuitem"
+ label="&event.menu.options.privacy.private.label;"
+ accesskey="&event.menu.options.privacy.private.accesskey;"
+ type="radio"
+ privacy="PRIVATE"
+ oncommand="editPrivacy(this, event)"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+ <menu id="options-status-menu"
+ label="&newevent.status.label;"
+ accesskey="&newevent.status.accesskey;"
+ class="event-only"
+ disable-on-readonly="true">
+ <menupopup id="options-status-menupopup">
+ <menuitem id="options-status-none-menuitem"
+ label="&newevent.eventStatus.none.label;"
+ accesskey="&newevent.eventStatus.none.accesskey;"
+ type="radio"
+ command="cmd_status_none"
+ disable-on-readonly="true"/>
+ <menuitem id="options-status-tentative-menuitem"
+ label="&newevent.status.tentative.label;"
+ accesskey="&newevent.status.tentative.accesskey;"
+ type="radio"
+ command="cmd_status_tentative"
+ disable-on-readonly="true"/>
+ <menuitem id="options-status-confirmed-menuitem"
+ label="&newevent.status.confirmed.label;"
+ accesskey="&newevent.status.confirmed.accesskey;"
+ type="radio"
+ command="cmd_status_confirmed"
+ disable-on-readonly="true"/>
+ <menuitem id="options-status-canceled-menuitem"
+ label="&newevent.eventStatus.cancelled.label;"
+ accesskey="&newevent.eventStatus.cancelled.accesskey;"
+ type="radio"
+ command="cmd_status_cancelled"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+ <menuseparator id="options-menuseparator2" class="event-only"/>
+ <menu id="options-freebusy-menu"
+ class="event-only"
+ label="&event.menu.options.show.time.label;"
+ accesskey="&event.menu.options.show.time.accesskey;"
+ disable-on-readonly="true">
+ <menupopup id="options-freebusy-menupopup">
+ <menuitem id="options-freebusy-busy-menuitem"
+ label="&event.menu.options.show.time.busy.label;"
+ accesskey="&event.menu.options.show.time.busy.accesskey;"
+ type="radio"
+ command="cmd_showtimeas_busy"
+ disable-on-readonly="true"/>
+ <menuitem id="options-freebusy-free-menuitem"
+ label="&event.menu.options.show.time.free.label;"
+ accesskey="&event.menu.options.show.time.free.accesskey;"
+ type="radio"
+ command="cmd_showtimeas_free"
+ disable-on-readonly="true"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+ </menu>
+ </menubar>
+ </toolbar>
+
+ <toolbarpalette id="event-toolbarpalette">
+#include ../item-editing/calendar-item-toolbar.inc.xhtml
+ </toolbarpalette>
+
+ <!-- toolboxid is set here since we move the toolbar around in tabs -->
+ <toolbar is="customizable-toolbar" id="event-toolbar"
+ toolboxid="event-toolbox"
+ class="chromeclass-toolbar themeable-full"
+ customizable="true"
+ labelalign="end"
+ defaultlabelalign="end"
+ context="event-dialog-toolbar-context-menu"
+ defaultset="button-saveandclose,button-attendees,button-privacy,button-url,button-delete"/>
+ </toolbox>
+
+ <!-- the calendar-item-panel-iframe iframe is inserted here dynamically in the "load" handler function -->
+
+ <hbox id="status-bar" class="statusbar chromeclass-status" role="status">
+ <hbox id="status-privacy"
+ class="statusbarpanel"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.statusbarpanel.privacy.label;"/>
+ <hbox id="status-privacy-public-box" privacy="PUBLIC">
+ <label value="&event.menu.options.privacy.public.label;"/>
+ </hbox>
+ <hbox id="status-privacy-confidential-box" privacy="CONFIDENTIAL">
+ <label value="&event.menu.options.privacy.confidential.label;"/>
+ </hbox>
+ <hbox id="status-privacy-private-box" privacy="PRIVATE">
+ <label value="&event.menu.options.privacy.private.label;"/>
+ </hbox>
+ </hbox>
+ <hbox id="status-priority"
+ class="statusbarpanel"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.priority2.label;"/>
+ <html:img class="cal-statusbar-1" />
+ </hbox>
+ <hbox id="status-status"
+ class="statusbarpanel"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&task.status.label;"/>
+ <label id="status-status-tentative-label"
+ value="&newevent.status.tentative.label;"
+ hidden="true"/>
+ <label id="status-status-confirmed-label"
+ value="&newevent.status.confirmed.label;"
+ hidden="true"/>
+ <label id="status-status-cancelled-label"
+ value="&newevent.eventStatus.cancelled.label;"
+ hidden="true"/>
+ </hbox>
+ <hbox id="status-freebusy"
+ class="statusbarpanel event-only"
+ align="center"
+ flex="1"
+ collapsed="true"
+ pack="start">
+ <label value="&event.statusbarpanel.freebusy.label;"/>
+ <label id="status-freebusy-free-label"
+ value="&event.freebusy.legend.free;"
+ hidden="true"/>
+ <label id="status-freebusy-busy-label"
+ value="&event.freebusy.legend.busy;"
+ hidden="true"/>
+ </hbox>
+ </hbox>
+</dialog>
+</window>
diff --git a/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js
new file mode 100644
index 0000000000..41c85eeea9
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js
@@ -0,0 +1,476 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals addMenuItem, getItemsFromIcsFile, putItemsIntoCal,
+ sortCalendarArray */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const gModel = {
+ /** @type {calICalendar[]} */
+ calendars: [],
+
+ /** @type {Map(number -> calIItemBase)} */
+ itemsToImport: new Map(),
+
+ /** @type {nsIFile | null} */
+ file: null,
+
+ /** @type {Map(number -> CalendarItemSummary)} */
+ itemSummaries: new Map(),
+};
+
+/**
+ * Window load event handler.
+ */
+async function onWindowLoad() {
+ // Workaround to add padding to the dialog buttons area which is in shadow dom.
+ // If the padding value changes here it should also change in the CSS.
+ let dialog = document.getElementsByTagName("dialog")[0];
+ dialog.shadowRoot.querySelector(".dialog-button-box").style = "padding-inline: 10px;";
+
+ gModel.file = window.arguments[0];
+ document.getElementById("calendar-ics-file-dialog-file-path").value = gModel.file.path;
+
+ let calendars = cal.manager.getCalendars();
+ gModel.calendars = getCalendarsThatCanImport(calendars);
+ if (!gModel.calendars.length) {
+ // No calendars to import into. Show error dialog and close the window.
+ cal.showError(await document.l10n.formatValue("calendar-ics-file-dialog-no-calendars"), window);
+ window.close();
+ return;
+ }
+
+ let composite = cal.view.getCompositeCalendar(window);
+ let defaultCalendarId = composite && composite.defaultCalendar?.id;
+ setUpCalendarMenu(gModel.calendars, defaultCalendarId);
+ cal.view.colorTracker.registerWindow(window);
+
+ // Finish laying out and displaying the window, then come back to do the hard work.
+ Services.tm.dispatchToMainThread(async () => {
+ let startTime = Date.now();
+
+ getItemsFromIcsFile(gModel.file).forEach((item, index) => {
+ gModel.itemsToImport.set(index, item);
+ });
+ if (gModel.itemsToImport.size == 0) {
+ // No items to import, close the window. An error dialog has already been
+ // shown by `getItemsFromIcsFile`.
+ window.close();
+ return;
+ }
+
+ // We know that if `getItemsFromIcsFile` took a long time, then `setUpItemSummaries` will also
+ // take a long time. Show a loading message so the user knows something is happening.
+ let loadingMessage = document.getElementById("calendar-ics-file-dialog-items-loading-message");
+ if (Date.now() - startTime > 150) {
+ loadingMessage.removeAttribute("hidden");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ }
+
+ // Not much point filtering or sorting if there's only one event.
+ if (gModel.itemsToImport.size == 1) {
+ document.getElementById("calendar-ics-file-dialog-filters").collapsed = true;
+ }
+
+ await setUpItemSummaries();
+
+ // Remove the loading message from the DOM to avoid it causing problems later.
+ loadingMessage.remove();
+
+ document.addEventListener("dialogaccept", importRemainingItems);
+ });
+}
+window.addEventListener("load", onWindowLoad);
+
+/**
+ * Takes an array of calendars and returns a sorted array of the calendars
+ * that can import items.
+ *
+ * @param {calICalendar[]} calendars - An array of calendars.
+ * @returns {calICalendar[]} Sorted array of calendars that can import items.
+ */
+function getCalendarsThatCanImport(calendars) {
+ let calendarsThatCanImport = calendars.filter(
+ calendar =>
+ !calendar.getProperty("disabled") &&
+ !calendar.readOnly &&
+ cal.acl.userCanAddItemsToCalendar(calendar)
+ );
+ return sortCalendarArray(calendarsThatCanImport);
+}
+
+/**
+ * Add calendars to the calendar drop down menu, and select one.
+ *
+ * @param {calICalendar[]} calendars - An array of calendars.
+ * @param {string | null} defaultCalendarId - ID of the default (currently selected) calendar.
+ */
+function setUpCalendarMenu(calendars, defaultCalendarId) {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ for (let calendar of calendars) {
+ let menuitem = addMenuItem(menulist, calendar.name, calendar.name);
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ menuitem.style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`);
+ menuitem.classList.add("menuitem-iconic");
+ }
+
+ let index = defaultCalendarId
+ ? calendars.findIndex(calendar => calendar.id == defaultCalendarId)
+ : 0;
+
+ menulist.selectedIndex = index == -1 ? 0 : index;
+ updateCalendarMenu();
+}
+
+/**
+ * Update to reflect a change in the selected calendar.
+ */
+function updateCalendarMenu() {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ menulist.style.setProperty(
+ "--item-color",
+ menulist.selectedItem.style.getPropertyValue("--item-color")
+ );
+}
+
+/**
+ * Display summaries of each calendar item from the file being imported.
+ */
+async function setUpItemSummaries() {
+ let items = [...gModel.itemsToImport];
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+
+ // Sort the items, chronologically first, tasks without a date to the end,
+ // then alphabetically.
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ items.sort(([, a], [, b]) => {
+ let aStartDate =
+ a.startDate?.nativeTime ||
+ a.entryDate?.nativeTime ||
+ a.dueDate?.nativeTime ||
+ Number.MAX_SAFE_INTEGER;
+ let bStartDate =
+ b.startDate?.nativeTime ||
+ b.entryDate?.nativeTime ||
+ b.dueDate?.nativeTime ||
+ Number.MAX_SAFE_INTEGER;
+ return aStartDate - bStartDate || collator.compare(a.title, b.title);
+ });
+
+ let [eventButtonText, taskButtonText] = await document.l10n.formatValues([
+ "calendar-ics-file-dialog-import-event-button-label",
+ "calendar-ics-file-dialog-import-task-button-label",
+ ]);
+
+ items.forEach(([index, item]) => {
+ let itemFrame = document.createXULElement("vbox");
+ itemFrame.classList.add("calendar-ics-file-dialog-item-frame");
+
+ let importButton = document.createXULElement("button");
+ importButton.classList.add("calendar-ics-file-dialog-item-import-button");
+ importButton.setAttribute("label", item.isEvent() ? eventButtonText : taskButtonText);
+ importButton.addEventListener("command", importSingleItem.bind(null, item, index));
+
+ let buttonBox = document.createXULElement("hbox");
+ buttonBox.setAttribute("pack", "end");
+ buttonBox.setAttribute("align", "end");
+
+ let summary = document.createXULElement("calendar-item-summary");
+ summary.setAttribute("id", "import-item-summary-" + index);
+
+ itemFrame.appendChild(summary);
+ buttonBox.appendChild(importButton);
+ itemFrame.appendChild(buttonBox);
+
+ itemsContainer.appendChild(itemFrame);
+ summary.item = item;
+
+ summary.updateItemDetails();
+ gModel.itemSummaries.set(index, summary);
+ });
+}
+
+/**
+ * Filter item summaries by search string.
+ *
+ * @param {searchString} [searchString] - Terms to search for.
+ */
+function filterItemSummaries(searchString = "") {
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+
+ searchString = searchString.trim();
+ // Nothing to search for. Display all item summaries.
+ if (!searchString) {
+ gModel.itemSummaries.forEach(s => {
+ s.closest(".calendar-ics-file-dialog-item-frame").hidden = false;
+ });
+
+ itemsContainer.scrollTo(0, 0);
+ return;
+ }
+
+ searchString = searchString.toLowerCase().normalize();
+
+ // Split the search string into tokens. Quoted strings are preserved.
+ let searchTokens = [];
+ let startIndex;
+ while ((startIndex = searchString.indexOf('"')) != -1) {
+ let endIndex = searchString.indexOf('"', startIndex + 1);
+ if (endIndex == -1) {
+ endIndex = searchString.length;
+ }
+
+ searchTokens.push(searchString.substring(startIndex + 1, endIndex));
+ let query = searchString.substring(0, startIndex);
+ if (endIndex < searchString.length) {
+ query += searchString.substr(endIndex + 1);
+ }
+
+ searchString = query.trim();
+ }
+
+ if (searchString.length != 0) {
+ searchTokens = searchTokens.concat(searchString.split(/\s+/));
+ }
+
+ // Check the title and description of each item for matches.
+ gModel.itemSummaries.forEach(s => {
+ let title, description;
+ let matches = searchTokens.every(term => {
+ if (title === undefined) {
+ title = s.item.title.toLowerCase().normalize();
+ }
+ if (title?.includes(term)) {
+ return true;
+ }
+
+ if (description === undefined) {
+ description = s.item.getProperty("description")?.toLowerCase().normalize();
+ }
+ return description?.includes(term);
+ });
+ s.closest(".calendar-ics-file-dialog-item-frame").hidden = !matches;
+ });
+
+ itemsContainer.scrollTo(0, 0);
+}
+
+/**
+ * Sort item summaries.
+ *
+ * @param {Event} event - The oncommand event that triggered this sort.
+ */
+function sortItemSummaries(event) {
+ let [key, direction] = event.target.value.split(" ");
+
+ let comparer;
+ if (key == "title") {
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ if (direction == "ascending") {
+ comparer = (a, b) => collator.compare(a.item.title, b.item.title);
+ } else {
+ comparer = (a, b) => collator.compare(b.item.title, a.item.title);
+ }
+ } else if (key == "start") {
+ if (direction == "ascending") {
+ comparer = (a, b) => a.item.startDate.nativeTime - b.item.startDate.nativeTime;
+ } else {
+ comparer = (a, b) => b.item.startDate.nativeTime - a.item.startDate.nativeTime;
+ }
+ } else {
+ // How did we get here?
+ throw new Error(`Unexpected sort key: ${key}`);
+ }
+
+ let items = [...gModel.itemSummaries.values()].sort(comparer);
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+ for (let item of items) {
+ itemsContainer.appendChild(item.closest(".calendar-ics-file-dialog-item-frame"));
+ }
+ itemsContainer.scrollTo(0, 0);
+
+ for (let menuitem of document.querySelectorAll(
+ "#calendar-ics-file-dialog-sort-popup > menuitem"
+ )) {
+ menuitem.checked = menuitem == event.target;
+ }
+}
+
+/**
+ * Get the currently selected calendar.
+ *
+ * @returns {calICalendar} The currently selected calendar.
+ */
+function getCurrentlySelectedCalendar() {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ let calendar = gModel.calendars[menulist.selectedIndex];
+ return calendar;
+}
+
+/**
+ * Handler for buttons that import a single item. The arguments are bound for
+ * each button instance, except for the event argument.
+ *
+ * @param {calIItemBase} item - Calendar item.
+ * @param {number} itemIndex - Index of the calendar item in the item array.
+ * @param {string} filePath - Path to the file being imported.
+ * @param {Event} event - The button event.
+ */
+async function importSingleItem(item, itemIndex, event) {
+ let dialog = document.getElementsByTagName("dialog")[0];
+ let acceptButton = dialog.getButton("accept");
+ let cancelButton = dialog.getButton("cancel");
+
+ acceptButton.disabled = true;
+ cancelButton.disabled = true;
+
+ let calendar = getCurrentlySelectedCalendar();
+
+ await putItemsIntoCal(calendar, [item], {
+ onDuplicate(item, error) {
+ // TODO: CalCalendarManager already shows a not-very-useful error pop-up.
+ // Once that is fixed, use this callback to display a proper error message.
+ },
+ onError(item, error) {
+ // TODO: CalCalendarManager already shows a not-very-useful error pop-up.
+ // Once that is fixed, use this callback to display a proper error message.
+ },
+ });
+
+ event.target.closest(".calendar-ics-file-dialog-item-frame").remove();
+ gModel.itemsToImport.delete(itemIndex);
+ gModel.itemSummaries.delete(itemIndex);
+
+ acceptButton.disabled = false;
+ if (gModel.itemsToImport.size > 0) {
+ // Change the cancel button label to Close, as we've done some work that
+ // won't be cancelled.
+ cancelButton.label = await document.l10n.formatValue(
+ "calendar-ics-file-cancel-button-close-label"
+ );
+ cancelButton.disabled = false;
+ } else {
+ // No more items to import, remove the "Import All" option.
+ document.removeEventListener("dialogaccept", importRemainingItems);
+
+ cancelButton.hidden = true;
+ acceptButton.label = await document.l10n.formatValue(
+ "calendar-ics-file-accept-button-ok-label"
+ );
+ }
+}
+
+/**
+ * "Import All" button command handler.
+ *
+ * @param {Event} event - Button command event.
+ */
+async function importRemainingItems(event) {
+ event.preventDefault();
+
+ let dialog = document.getElementsByTagName("dialog")[0];
+ let acceptButton = dialog.getButton("accept");
+ let cancelButton = dialog.getButton("cancel");
+
+ acceptButton.disabled = true;
+ cancelButton.disabled = true;
+
+ let calendar = getCurrentlySelectedCalendar();
+ let filteredSummaries = [...gModel.itemSummaries.values()].filter(
+ summary => !summary.closest(".calendar-ics-file-dialog-item-frame").hidden
+ );
+ let remainingItems = filteredSummaries.map(summary => summary.item);
+
+ let progressElement = document.getElementById("calendar-ics-file-dialog-progress");
+ let duplicatesElement = document.getElementById("calendar-ics-file-dialog-duplicates-message");
+ let errorsElement = document.getElementById("calendar-ics-file-dialog-errors-message");
+
+ let optionsPane = document.getElementById("calendar-ics-file-dialog-options-pane");
+ let progressPane = document.getElementById("calendar-ics-file-dialog-progress-pane");
+ let resultPane = document.getElementById("calendar-ics-file-dialog-result-pane");
+
+ let importListener = {
+ count: 0,
+ duplicatesCount: 0,
+ errorsCount: 0,
+ progressInterval: null,
+
+ onStart() {
+ progressElement.max = remainingItems.length;
+ optionsPane.hidden = true;
+ progressPane.hidden = false;
+
+ this.progressInterval = setInterval(() => {
+ progressElement.value = this.count;
+ }, 50);
+ },
+ onDuplicate(item, error) {
+ this.duplicatesCount++;
+ },
+ onError(item, error) {
+ this.errorsCount++;
+ },
+ onProgress(count, total) {
+ this.count = count;
+ },
+ async onEnd() {
+ progressElement.value = this.count;
+ clearInterval(this.progressInterval);
+
+ document.l10n.setAttributes(duplicatesElement, "calendar-ics-file-import-duplicates", {
+ duplicatesCount: this.duplicatesCount,
+ });
+ duplicatesElement.hidden = this.duplicatesCount == 0;
+ document.l10n.setAttributes(errorsElement, "calendar-ics-file-import-errors", {
+ errorsCount: this.errorsCount,
+ });
+ errorsElement.hidden = this.errorsCount == 0;
+
+ let [acceptButtonLabel, cancelButtonLabel] = await document.l10n.formatValues([
+ { id: "calendar-ics-file-accept-button-ok-label" },
+ { id: "calendar-ics-file-cancel-button-close-label" },
+ ]);
+
+ filteredSummaries.forEach(summary => {
+ let itemIndex = parseInt(summary.id.substring("import-item-summary-".length), 10);
+ gModel.itemsToImport.delete(itemIndex);
+ gModel.itemSummaries.delete(itemIndex);
+ summary.closest(".calendar-ics-file-dialog-item-frame").remove();
+ });
+
+ document.getElementById("calendar-ics-file-dialog-search-input").value = "";
+ filterItemSummaries();
+ let itemsRemain = !!document.querySelector(".calendar-ics-file-dialog-item-frame");
+
+ // An artificial delay so the progress pane doesn't appear then immediately disappear.
+ setTimeout(() => {
+ if (itemsRemain) {
+ acceptButton.disabled = false;
+ cancelButton.label = cancelButtonLabel;
+ cancelButton.disabled = false;
+ } else {
+ acceptButton.label = acceptButtonLabel;
+ acceptButton.disabled = false;
+ cancelButton.hidden = true;
+ document.removeEventListener("dialogaccept", importRemainingItems);
+ }
+
+ optionsPane.hidden = !itemsRemain;
+ progressPane.hidden = true;
+ resultPane.hidden = itemsRemain;
+ }, 500);
+ },
+ };
+
+ putItemsIntoCal(calendar, remainingItems, importListener);
+}
+
+/**
+ * These functions are called via `putItemsIntoCal` in import-export.js so
+ * they need to be defined in global scope but they don't need to do anything
+ * in this case.
+ */
+function startBatchTransaction() {}
+function endBatchTransaction() {}
diff --git a/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml
new file mode 100644
index 0000000000..0f28148aca
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.xhtml
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/searchBox.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-item-summary.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-ics-file-dialog.css"?>
+
+<html
+ id="calendar-ics-file-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ style="min-width: 42em; min-height: 42em"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="calendar-ics-file-window-title"></title>
+ <link rel="localization" href="calendar/calendar-editable-item.ftl" />
+ <link rel="localization" href="calendar/calendar-ics-file-dialog.ftl" />
+ <script src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script src="chrome://calendar/content/import-export.js"></script>
+ <script src="chrome://calendar/content/widgets/calendar-item-summary.js"></script>
+ <script src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script src="chrome://calendar/content/calendar-ics-file-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog
+ buttons="accept,cancel"
+ data-l10n-id="calendar-ics-file-dialog-2"
+ data-l10n-attrs="buttonlabelaccept"
+ >
+ <vbox id="calendar-ics-file-dialog-options-pane" flex="1">
+ <vbox id="calendar-ics-file-dialog-header">
+ <description
+ id="calendar-ics-file-dialog-message"
+ data-l10n-id="calendar-ics-file-dialog-message-2"
+ ></description>
+ <description id="calendar-ics-file-dialog-file-path" crop="start"></description>
+
+ <label
+ id="calendar-ics-file-dialog-calendar-menu-label"
+ data-l10n-id="calendar-ics-file-dialog-calendar-menu-label"
+ control="calendar-ics-file-dialog-calendar-menu"
+ ></label>
+
+ <menulist id="calendar-ics-file-dialog-calendar-menu" oncommand="updateCalendarMenu();" />
+
+ <hbox id="calendar-ics-file-dialog-filters">
+ <search-textbox
+ id="calendar-ics-file-dialog-search-input"
+ class="themeableSearchBox"
+ flex="1"
+ data-l10n-id="calendar-ics-file-dialog-search-input"
+ data-l10n-attrs="placeholder"
+ oncommand="filterItemSummaries(this.value);"
+ />
+ <button
+ id="calendar-ics-file-dialog-sort-button"
+ type="menu"
+ oncommand="sortItemSummaries(event);"
+ >
+ <menupopup id="calendar-ics-file-dialog-sort-popup">
+ <menuitem
+ id="calendar-ics-file-dialog-sort-start-ascending"
+ type="radio"
+ checked="true"
+ data-l10n-id="calendar-ics-file-dialog-sort-start-ascending"
+ data-l10n-attrs="label"
+ value="start ascending"
+ />
+ <menuitem
+ id="calendar-ics-file-dialog-sort-start-descending"
+ type="radio"
+ data-l10n-id="calendar-ics-file-dialog-sort-start-descending"
+ data-l10n-attrs="label"
+ value="start descending"
+ />
+ <menuitem
+ id="calendar-ics-file-dialog-sort-title-ascending"
+ type="radio"
+ data-l10n-id="calendar-ics-file-dialog-sort-title-ascending"
+ data-l10n-attrs="label"
+ value="title ascending"
+ />
+ <menuitem
+ id="calendar-ics-file-dialog-sort-title-descending"
+ type="radio"
+ data-l10n-id="calendar-ics-file-dialog-sort-title-descending"
+ data-l10n-attrs="label"
+ value="title descending"
+ />
+ </menupopup>
+ </button>
+ </hbox>
+ </vbox>
+
+ <vbox id="calendar-ics-file-dialog-items-container">
+ <label
+ id="calendar-ics-file-dialog-items-loading-message"
+ hidden="true"
+ data-l10n-id="calendar-ics-file-dialog-items-loading-message"
+ data-l10n-attrs="value"
+ />
+ </vbox>
+ </vbox>
+
+ <vbox id="calendar-ics-file-dialog-progress-pane" hidden="true">
+ <description
+ id="calendar-ics-file-dialog-progress-message"
+ data-l10n-id="calendar-ics-file-dialog-progress-message"
+ ></description>
+ <html:progress id="calendar-ics-file-dialog-progress" value="0" />
+ </vbox>
+
+ <vbox id="calendar-ics-file-dialog-result-pane" hidden="true">
+ <description
+ id="calendar-ics-file-dialog-result-message"
+ data-l10n-id="calendar-ics-file-import-complete"
+ ></description>
+ <description id="calendar-ics-file-dialog-duplicates-message"></description>
+ <description id="calendar-ics-file-dialog-errors-message"></description>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-identity-utils.js b/comm/calendar/base/content/dialogs/calendar-identity-utils.js
new file mode 100644
index 0000000000..abd5bc8eb3
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-identity-utils.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported initMailIdentitiesRow, saveMailIdentitySelection,
+ notifyOnIdentitySelection, initForceEmailScheduling,
+ saveForceEmailScheduling, updateForceEmailSchedulingControl */
+
+/* global MozElements, addMenuItem, gCalendar */
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyGetter(this, "gIdentityNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ document.getElementById("no-identity-notification").append(element);
+ });
+});
+
+/**
+ * Initialize the email identity row. Shared between the calendar creation
+ * dialog and the calendar properties dialog.
+ *
+ * @param {calICalendar} aCalendar - The calendar being created or edited.
+ */
+function initMailIdentitiesRow(aCalendar) {
+ if (!aCalendar) {
+ document.getElementById("calendar-email-identity-row").toggleAttribute("hidden", true);
+ }
+
+ let imipIdentityDisabled = aCalendar.getProperty("imip.identity.disabled");
+ document
+ .getElementById("calendar-email-identity-row")
+ .toggleAttribute("hidden", imipIdentityDisabled);
+
+ if (imipIdentityDisabled) {
+ // If the imip identity is disabled, we don't have to set up the
+ // menulist.
+ return;
+ }
+
+ // If there is no transport but also no organizer id, then the
+ // provider has not statically configured an organizer id. This is
+ // basically what happens when "None" is selected.
+ let menuPopup = document.getElementById("email-identity-menupopup");
+
+ // Remove all children from the email list to avoid duplicates if the list
+ // has already been populated during a previous step in the calendar
+ // creation wizard.
+ while (menuPopup.hasChildNodes()) {
+ menuPopup.lastChild.remove();
+ }
+
+ addMenuItem(menuPopup, cal.l10n.getLtnString("imipNoIdentity"), "none");
+ let identities;
+ if (aCalendar && aCalendar.aclEntry && aCalendar.aclEntry.hasAccessControl) {
+ identities = aCalendar.aclEntry.getOwnerIdentities();
+ } else {
+ identities = MailServices.accounts.allIdentities;
+ }
+ for (let identity of identities) {
+ addMenuItem(menuPopup, identity.identityName, identity.key);
+ }
+ let sel = aCalendar.getProperty("imip.identity");
+ if (sel) {
+ sel = sel.QueryInterface(Ci.nsIMsgIdentity);
+ }
+ document.getElementById("email-identity-menulist").value = sel ? sel.key : "none";
+}
+
+/**
+ * Returns the selected email identity. Shared between the calendar creation
+ * dialog and the calendar properties dialog.
+ *
+ * @param {calICalendar} aCalendar - The calendar for the identity selection.
+ * @returns {string} The key of the selected nsIMsgIdentity or 'none'.
+ */
+function getMailIdentitySelection(aCalendar) {
+ let sel = "none";
+ if (aCalendar) {
+ let imipIdentityDisabled = aCalendar.getProperty("imip.identity.disabled");
+ let selItem = document.getElementById("email-identity-menulist").selectedItem;
+ if (!imipIdentityDisabled && selItem) {
+ sel = selItem.getAttribute("value");
+ }
+ }
+ return sel;
+}
+
+/**
+ * Persists the selected email identity. Shared between the calendar creation
+ * dialog and the calendar properties dialog.
+ *
+ * @param {calICalendar} aCalendar - The calendar for the identity selection.
+ */
+function saveMailIdentitySelection(aCalendar) {
+ if (aCalendar) {
+ let sel = getMailIdentitySelection(aCalendar);
+ // no imip.identity.key will default to the default account/identity, whereas
+ // an empty key indicates no imip; that identity will not be found
+ aCalendar.setProperty("imip.identity.key", sel == "none" ? "" : sel);
+ }
+}
+
+/**
+ * Displays a warning if the user doesn't assign an email identity to a
+ * calendar. Shared between the calendar creation dialog and the calendar
+ * properties dialog.
+ *
+ * @param {calICalendar} aCalendar - The calendar for the identity selection.
+ */
+function notifyOnIdentitySelection(aCalendar) {
+ gIdentityNotification.removeAllNotifications();
+
+ let msg = cal.l10n.getLtnString("noIdentitySelectedNotification");
+ let sel = getMailIdentitySelection(aCalendar);
+
+ if (sel == "none") {
+ gIdentityNotification.appendNotification(
+ "noIdentitySelected",
+ {
+ label: msg,
+ priority: gIdentityNotification.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ } else {
+ gIdentityNotification.removeAllNotifications();
+ }
+}
+
+/**
+ * Initializing calendar creation wizard and properties dialog to display the
+ * option to enforce email scheduling for outgoing scheduling operations.
+ * Used in the calendar properties dialog.
+ */
+function initForceEmailScheduling() {
+ if (gCalendar && gCalendar.type == "caldav") {
+ let checkbox = document.getElementById("force-email-scheduling");
+ let curStatus = checkbox.getAttribute("checked") == "true";
+ let newStatus = gCalendar.getProperty("forceEmailScheduling") || curStatus;
+ if (curStatus != newStatus) {
+ if (newStatus) {
+ checkbox.setAttribute("checked", "true");
+ } else {
+ checkbox.removeAttribute("checked");
+ }
+ }
+ updateForceEmailSchedulingControl();
+ } else {
+ document.getElementById("calendar-force-email-scheduling-row").toggleAttribute("hidden", true);
+ }
+}
+
+/**
+ * Persisting the calendar property to enforce email scheduling. Used in the
+ * calendar properties dialog.
+ */
+function saveForceEmailScheduling() {
+ if (gCalendar && gCalendar.type == "caldav") {
+ let checkbox = document.getElementById("force-email-scheduling");
+ if (checkbox && checkbox.getAttribute("disable-capability") != "true") {
+ let status = checkbox.getAttribute("checked") == "true";
+ gCalendar.setProperty("forceEmailScheduling", status);
+ }
+ }
+}
+
+/**
+ * Updates the forceEmailScheduling control based on the currently assigned
+ * email identity to this calendar. Used in the calendar properties dialog.
+ */
+function updateForceEmailSchedulingControl() {
+ let checkbox = document.getElementById("force-email-scheduling");
+ if (
+ gCalendar &&
+ gCalendar.getProperty("capabilities.autoschedule.supported") &&
+ getMailIdentitySelection(gCalendar) != "none"
+ ) {
+ checkbox.removeAttribute("disable-capability");
+ checkbox.removeAttribute("disabled");
+ } else {
+ checkbox.setAttribute("disable-capability", "true");
+ checkbox.setAttribute("disabled", "true");
+ }
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js
new file mode 100644
index 0000000000..871ffef276
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.js
@@ -0,0 +1,310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals MozXULElement, MozElements */ // From calendar-invitations-dialog.xhtml.
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ class MozCalendarInvitationsRichlistitem extends MozElements.MozRichlistitem {
+ constructor() {
+ super();
+
+ this.mCalendarItem = null;
+ this.mInitialParticipationStatus = null;
+ this.mParticipationStatus = null;
+ this.calInvitationsProps = Services.strings.createBundle(
+ "chrome://calendar/locale/calendar-invitations-dialog.properties"
+ );
+ }
+
+ getString(propName) {
+ return this.calInvitationsProps.GetStringFromName(propName);
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ this.setAttribute("is", "calendar-invitations-richlistitem");
+ this.classList.add("calendar-invitations-richlistitem");
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <hbox align="start" flex="1">
+ <!-- Note: The wrapper div is only here because the XUL box does not
+ - properly crop img elements with CSS object-fit and
+ - object-position. Should be removed when converting the parent
+ - element to HTML. -->
+ <html:div>
+ <html:img class="calendar-invitations-richlistitem-icon"
+ src="chrome://calendar/skin/shared/calendar-invitations-dialog-list-images.png" />
+ </html:div>
+ <vbox flex="1">
+ <label class="calendar-invitations-richlistitem-title" crop="end"/>
+ <label class="calendar-invitations-richlistitem-date" crop="end"/>
+ <label class="calendar-invitations-richlistitem-recurrence" crop="end"/>
+ <label class="calendar-invitations-richlistitem-location" crop="end"/>
+ <label class="calendar-invitations-richlistitem-organizer" crop="end"/>
+ <label class="calendar-invitations-richlistitem-attendee" crop="end"/>
+ <label class="calendar-invitations-richlistitem-spacer" value="" hidden="true"/>
+ </vbox>
+ <vbox>
+ <button group="${this.getAttribute("itemId")}"
+ type="radio"
+ class="calendar-invitations-richlistitem-accept-button
+ calendar-invitations-richlistitem-button"
+ label="&calendar.invitations.list.accept.button.label;"
+ oncommand="accept();"/>
+ <button group="${this.getAttribute("itemId")}"
+ type="radio"
+ class="calendar-invitations-richlistitem-decline-button
+ calendar-invitations-richlistitem-button"
+ label="&calendar.invitations.list.decline.button.label;"
+ oncommand="decline();"/>
+ </vbox>
+ </hbox>
+ `,
+ ["chrome://calendar/locale/calendar-invitations-dialog.dtd"]
+ )
+ );
+ }
+
+ set calendarItem(val) {
+ this.setCalendarItem(val);
+ }
+
+ get calendarItem() {
+ return this.mCalendarItem;
+ }
+
+ set initialParticipationStatus(val) {
+ this.mInitialParticipationStatus = val;
+ }
+
+ get initialParticipationStatus() {
+ return this.mInitialParticipationStatus;
+ }
+
+ set participationStatus(val) {
+ this.mParticipationStatus = val;
+ let icon = this.querySelector(".calendar-invitations-richlistitem-icon");
+ // Status attribute changes the image region in CSS.
+ icon.setAttribute("status", val);
+ document.l10n.setAttributes(
+ icon,
+ `calendar-invitation-current-participation-status-icon-${val.toLowerCase()}`
+ );
+ }
+
+ get participationStatus() {
+ return this.mParticipationStatus;
+ }
+
+ setCalendarItem(item) {
+ this.mCalendarItem = item;
+ this.mInitialParticipationStatus = this.getCalendarItemParticipationStatus(item);
+ this.participationStatus = this.mInitialParticipationStatus;
+
+ let titleLabel = this.querySelector(".calendar-invitations-richlistitem-title");
+ titleLabel.setAttribute("value", item.title);
+
+ let dateLabel = this.querySelector(".calendar-invitations-richlistitem-date");
+ let dateString = cal.dtz.formatter.formatItemInterval(item);
+ if (item.startDate.isDate) {
+ dateString += ", " + this.getString("allday-event");
+ }
+ dateLabel.setAttribute("value", dateString);
+
+ let recurrenceLabel = this.querySelector(".calendar-invitations-richlistitem-recurrence");
+ if (item.recurrenceInfo) {
+ recurrenceLabel.setAttribute("value", this.getString("recurrent-event"));
+ } else {
+ recurrenceLabel.setAttribute("hidden", "true");
+ let spacer = this.querySelector(".calendar-invitations-richlistitem-spacer");
+ spacer.removeAttribute("hidden");
+ }
+
+ let locationLabel = this.querySelector(".calendar-invitations-richlistitem-location");
+ let locationProperty = item.getProperty("LOCATION") || this.getString("none");
+ let locationString = this.calInvitationsProps.formatStringFromName("location", [
+ locationProperty,
+ ]);
+
+ locationLabel.setAttribute("value", locationString);
+
+ let organizerLabel = this.querySelector(".calendar-invitations-richlistitem-organizer");
+ let org = item.organizer;
+ let organizerProperty = "";
+ if (org) {
+ if (org.commonName && org.commonName.length > 0) {
+ organizerProperty = org.commonName;
+ } else if (org.id) {
+ organizerProperty = org.id.replace(/^mailto:/i, "");
+ }
+ }
+ let organizerString = this.calInvitationsProps.formatStringFromName("organizer", [
+ organizerProperty,
+ ]);
+ organizerLabel.setAttribute("value", organizerString);
+
+ let attendeeLabel = this.querySelector(".calendar-invitations-richlistitem-attendee");
+ let att = cal.itip.getInvitedAttendee(item);
+ let attendeeProperty = "";
+ if (att) {
+ if (att.commonName && att.commonName.length > 0) {
+ attendeeProperty = att.commonName;
+ } else if (att.id) {
+ attendeeProperty = att.id.replace(/^mailto:/i, "");
+ }
+ }
+ let attendeeString = this.calInvitationsProps.formatStringFromName("attendee", [
+ attendeeProperty,
+ ]);
+ attendeeLabel.setAttribute("value", attendeeString);
+ Array.from(this.querySelectorAll("button")).map(button =>
+ button.setAttribute("group", item.hashId)
+ );
+ }
+
+ getCalendarItemParticipationStatus(item) {
+ let att = cal.itip.getInvitedAttendee(item);
+ return att ? att.participationStatus : null;
+ }
+
+ setCalendarItemParticipationStatus(item, status) {
+ if (item.calendar?.supportsScheduling) {
+ let att = item.calendar.getSchedulingSupport().getInvitedAttendee(item);
+ if (att) {
+ let att_ = att.clone();
+ att_.participationStatus = status;
+
+ // Update attendee
+ item.removeAttendee(att);
+ item.addAttendee(att_);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ accept() {
+ this.participationStatus = "ACCEPTED";
+ }
+
+ decline() {
+ this.participationStatus = "DECLINED";
+ }
+ }
+ customElements.define("calendar-invitations-richlistitem", MozCalendarInvitationsRichlistitem, {
+ extends: "richlistitem",
+ });
+}
+
+window.addEventListener("DOMContentLoaded", onLoad);
+window.addEventListener("unload", onUnload);
+
+/**
+ * Sets up the invitations dialog from the window arguments, retrieves the
+ * invitations from the invitations manager.
+ */
+async function onLoad() {
+ let title = document.title;
+ let updatingBox = document.getElementById("updating-box");
+ updatingBox.removeAttribute("hidden");
+ opener.setCursor("auto");
+
+ let { invitationsManager } = window.arguments[0];
+ let items = await cal.iterate.mapStream(invitationsManager.getInvitations(), chunk => {
+ document.title = title + " (" + chunk.length + ")";
+ let updatingBox = document.getElementById("updating-box");
+ updatingBox.setAttribute("hidden", "true");
+ let richListBox = document.getElementById("invitations-listbox");
+ for (let item of chunk) {
+ let newNode = document.createXULElement("richlistitem", {
+ is: "calendar-invitations-richlistitem",
+ });
+ richListBox.appendChild(newNode);
+ newNode.calendarItem = item;
+ }
+ });
+
+ invitationsManager.toggleInvitationsPanel(items);
+ updatingBox.setAttribute("hidden", "true");
+
+ let richListBox = document.getElementById("invitations-listbox");
+ if (richListBox.getRowCount() > 0) {
+ richListBox.selectedIndex = 0;
+ } else {
+ let noInvitationsBox = document.getElementById("noinvitations-box");
+ noInvitationsBox.removeAttribute("hidden");
+ }
+}
+
+/**
+ * Cleans up the invitations dialog, cancels pending requests.
+ */
+async function onUnload() {
+ let args = window.arguments[0];
+ return args.invitationsManager.cancelPendingRequests();
+}
+
+/**
+ * Handler function to be called when the accept button is pressed.
+ */
+document.addEventListener("dialogaccept", async () => {
+ let args = window.arguments[0];
+ fillJobQueue(args.queue);
+ await args.invitationsManager.processJobQueue(args.queue);
+ args.finishedCallBack();
+});
+
+/**
+ * Handler function to be called when the cancel button is pressed.
+ */
+document.addEventListener("dialogcancel", () => {
+ let args = window.arguments[0];
+ args.finishedCallBack();
+});
+
+/**
+ * Fills the job queue from the invitations-listbox's items. The job queue
+ * contains objects for all items that have a modified participation status.
+ *
+ * @param queue The queue to fill.
+ */
+function fillJobQueue(queue) {
+ let richListBox = document.getElementById("invitations-listbox");
+ let rowCount = richListBox.getRowCount();
+ for (let i = 0; i < rowCount; i++) {
+ let richListItem = richListBox.getItemAtIndex(i);
+ let newStatus = richListItem.participationStatus;
+ let oldStatus = richListItem.initialParticipationStatus;
+ if (newStatus != oldStatus) {
+ let actionString = "modify";
+ let oldCalendarItem = richListItem.calendarItem;
+ let newCalendarItem = oldCalendarItem.clone();
+
+ // set default alarm on unresponded items that have not been declined:
+ if (
+ !newCalendarItem.getAlarms().length &&
+ oldStatus == "NEEDS-ACTION" &&
+ newStatus != "DECLINED"
+ ) {
+ cal.alarms.setDefaultValues(newCalendarItem);
+ }
+
+ richListItem.setCalendarItemParticipationStatus(newCalendarItem, newStatus);
+ let job = {
+ action: actionString,
+ oldItem: oldCalendarItem,
+ newItem: newCalendarItem,
+ };
+ queue.push(job);
+ }
+ }
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml
new file mode 100644
index 0000000000..c0ed60d95d
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-invitations-dialog.xhtml
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-invitations-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar-invitations-dialog.dtd">
+%dtd1; ]>
+
+<html
+ id="calendar-invitations-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="calendar-general-dialog"
+ lightweightthemes="true"
+ persist="screenX screenY"
+ style="min-width: 600px; min-height: 350px"
+ scrolling="false"
+>
+ <head>
+ <title>&calendar.invitations.dialog.invitations.text;</title>
+ <link rel="localization" href="calendar/calendar-invitations-dialog.ftl" />
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-invitations-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog class="scrollable" buttons="accept,cancel">
+ <div id="invitationContainer">
+ <richlistbox id="invitations-listbox" />
+ <hbox id="updating-box" align="center" pack="center" hidden="true">
+ <label value="&calendar.invitations.dialog.statusmessage.updating.text;" crop="end" />
+ <html:img
+ class="calendar-invitations-updating-icon"
+ src="chrome://global/skin/icons/loading.png"
+ alt=""
+ />
+ </hbox>
+ <hbox id="noinvitations-box" align="center" pack="center" hidden="true">
+ <label
+ value="&calendar.invitations.dialog.statusmessage.noinvitations.text;"
+ crop="end"
+ />
+ </hbox>
+ </div>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js
new file mode 100644
index 0000000000..dbc8532586
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.js
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global addMenuItem */
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+/**
+ * @callback onOkCallback
+ * @param {nsIMsgIdentity} identity - The identity the user selected.
+ */
+
+/**
+ * @typdef {object} CalendarItipIdentityDialogArgs
+ * @property {nsIMsgIdentity[]} identities - List of identities to select from.
+ * @property {number} responseMode - One of the response mode constants
+ * from calIItipItem indicating the
+ * mode the user choose.
+ * @property {Function} onCancel - Called when the user clicks cancel.
+ * @property {onOkCallback} onOk - Called when the user selects an
+ * identity.
+ */
+
+/**
+ * Populates the identity menu list with the available identities.
+ */
+function onLoad() {
+ let label = document.getElementById("identity-menu-label");
+ document.l10n.setAttributes(
+ label,
+ window.arguments[0].responseMode == Ci.calIItipItem.NONE
+ ? "calendar-itip-identity-label-none"
+ : "calendar-itip-identity-label"
+ );
+
+ let identityMenu = document.getElementById("identity-menu");
+ for (let identity of window.arguments[0].identities) {
+ let menuitem = addMenuItem(identityMenu, identity.fullAddress, identity.fullAddress);
+ menuitem.identity = identity;
+ }
+
+ identityMenu.selectedIndex = 0;
+
+ document.addEventListener("dialogaccept", () => {
+ window.arguments[0].onOk(identityMenu.selectedItem.identity);
+ });
+
+ document.addEventListener("dialogcancel", window.arguments[0].onCancel);
+}
+
+window.addEventListener("load", onLoad);
diff --git a/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml
new file mode 100644
index 0000000000..fc3f380024
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-itip-identity-dialog.xhtml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-itip-identity-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html>
+
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ lightweightthemes="true"
+>
+ <head>
+ <title data-l10n-id="calendar-itip-identity-dialog-title"></title>
+
+ <link rel="localization" href="calendar/calendar-itip-identity-dialog.ftl" />
+
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-itip-identity-dialog.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ </head>
+
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog buttons="accept,cancel">
+ <html:div
+ id="calendar-itip-identity-warning"
+ data-l10n-id="calendar-itip-identity-warning"
+ ></html:div>
+
+ <label id="identity-menu-label" control="identity-menu" />
+
+ <menulist id="identity-menu" />
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-migration-dialog.js b/comm/calendar/base/content/dialogs/calendar-migration-dialog.js
new file mode 100644
index 0000000000..bac1cd8fec
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-migration-dialog.js
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+window.addEventListener("DOMContentLoaded", event => {
+ gMigrateWizard.loadMigrators();
+});
+
+var gMigrateWizard = {
+ /**
+ * Called from onload of the migrator window. Takes all of the migrators
+ * that were passed in via window.arguments and adds them to checklist. The
+ * user can then check these off to migrate the data from those sources.
+ */
+ loadMigrators() {
+ let wizardPage2 = document.getElementById("wizardPage2");
+ wizardPage2.addEventListener("pageshow", gMigrateWizard.migrateChecked);
+
+ let listbox = document.getElementById("datasource-list");
+
+ // XXX Once we have branding for lightning, this hack can go away
+ let props = Services.strings.createBundle("chrome://calendar/locale/migration.properties");
+
+ let wizard = document.querySelector("wizard");
+ let desc = document.getElementById("wizard-desc");
+ // Since we don't translate "Lightning"...
+ wizard.title = props.formatStringFromName("migrationTitle", ["Lightning"]);
+ desc.textContent = props.formatStringFromName("migrationDescription", ["Lightning"]);
+
+ console.debug("migrators: " + window.arguments.length);
+ for (let migrator of window.arguments[0]) {
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.setAttribute("checked", true);
+ checkbox.setAttribute("label", migrator.title);
+ checkbox.migrator = migrator;
+ listbox.appendChild(checkbox);
+ }
+ },
+
+ /**
+ * Called from the second page of the wizard. Finds all of the migrators
+ * that were checked and begins migrating their data. Also controls the
+ * progress dialog so the user can see what is happening. (somewhat)
+ */
+ migrateChecked() {
+ let migrators = [];
+
+ // Get all the checked migrators into an array
+ let listbox = document.getElementById("datasource-list");
+ for (let i = listbox.children.length - 1; i >= 0; i--) {
+ if (listbox.children[i].getAttribute("checked")) {
+ migrators.push(listbox.children[i].migrator);
+ }
+ }
+
+ // If no migrators were checked, then we're done
+ if (migrators.length == 0) {
+ window.close();
+ }
+
+ // Don't let the user get away while we're migrating
+ // XXX may want to wire this into the 'cancel' function once that's
+ // written
+ let wizard = document.querySelector("wizard");
+ wizard.canAdvance = false;
+ wizard.canRewind = false;
+
+ // We're going to need this for the progress meter's description
+ let props = Services.strings.createBundle("chrome://calendar/locale/migration.properties");
+ let label = document.getElementById("progress-label");
+ let meter = document.getElementById("migrate-progressmeter");
+
+ let i = 0;
+ // Because some of our migrators involve async code, we need this
+ // call-back function so we know when to start the next migrator.
+ function getNextMigrator() {
+ if (migrators[i]) {
+ let mig = migrators[i];
+
+ // Increment i to point to the next migrator
+ i++;
+ console.debug("starting migrator: " + mig.title);
+ label.value = props.formatStringFromName("migratingApp", [mig.title]);
+ meter.value = ((i - 1) / migrators.length) * 100;
+ mig.args.push(getNextMigrator);
+
+ try {
+ mig.migrate(...mig.args);
+ } catch (e) {
+ console.debug("Failed to migrate: " + mig.title);
+ console.debug(e);
+ getNextMigrator();
+ }
+ } else {
+ console.debug("migration done");
+ wizard.canAdvance = true;
+ label.value = props.GetStringFromName("finished");
+ meter.value = 100;
+ gMigrateWizard.setCanRewindFalse();
+ }
+ }
+
+ // And get the first migrator
+ getNextMigrator();
+ },
+
+ /**
+ * Makes sure the wizard "back" button can not be pressed.
+ */
+ setCanRewindFalse() {
+ document.querySelector("wizard").canRewind = false;
+ },
+};
diff --git a/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml
new file mode 100644
index 0000000000..115f035000
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-migration-dialog.xhtml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- Style sheets -->
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+%brandDTD;
+<!ENTITY % migrationDtd SYSTEM "chrome://calendar/locale/migration.dtd">
+%migrationDtd; ]>
+<html
+ id="migration-wizard"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ branded="true"
+ windowtype="Calendar:MigrationWizard"
+ scrolling="false"
+>
+ <head>
+ <title>&migration.title;</title>
+ <link rel="localization" href="toolkit/global/wizard.ftl" />
+ <script defer="defer" src="chrome://calendar/content/import-export.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-migration-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <wizard style="width: 100vw; height: 100vh">
+ <wizardpage
+ id="wizardPage1"
+ pageid="initialPage"
+ next="progressPage"
+ label="&migration.welcome;"
+ >
+ <label id="wizard-desc" control="datasource-list">&migration.list.description;</label>
+ <vbox id="datasource-list" flex="1" />
+ </wizardpage>
+
+ <wizardpage id="wizardPage2" pageid="progressPage" label="&migration.importing;">
+ <label control="migrate-progressmeter">&migration.progress.description;</label>
+ <vbox flex="1">
+ <html:progress id="migrate-progressmeter" value="0" max="100" />
+ <label value="" flex="1" id="progress-label" />
+ </vbox>
+ </wizardpage>
+ </wizard>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js
new file mode 100644
index 0000000000..cb66e24ff9
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+window.addEventListener("DOMContentLoaded", onLoad);
+
+document.addEventListener("dialogaccept", () => exitOccurrenceDialog(1));
+document.addEventListener("dialogcancel", () => exitOccurrenceDialog(0));
+
+function exitOccurrenceDialog(aReturnValue) {
+ window.arguments[0].value = aReturnValue;
+ window.close();
+}
+
+function getDString(aKey) {
+ return cal.l10n.getString("calendar-occurrence-prompt", aKey);
+}
+
+function onLoad() {
+ let action = window.arguments[0].action || "edit";
+ // the calling code prevents sending no items
+ let multiple = window.arguments[0].items.length == 1 ? "single" : "multiple";
+ let itemType;
+ for (let item of window.arguments[0].items) {
+ let type = item.isEvent() ? "event" : "task";
+ if (itemType != type) {
+ itemType = itemType ? "mixed" : type;
+ }
+ }
+
+ // Set up title and type label
+ document.title = getDString(`windowtitle.${itemType}.${action}`);
+ let title = document.getElementById("title-label");
+ if (multiple == "multiple") {
+ title.value = getDString("windowtitle.multipleitems");
+ document.getElementById("isrepeating-label").value = getDString(
+ `header.containsrepeating.${itemType}.label`
+ );
+ } else {
+ title.value = window.arguments[0].items[0].title;
+ document.getElementById("isrepeating-label").value = getDString(
+ `header.isrepeating.${itemType}.label`
+ );
+ }
+
+ // Set up buttons
+ document.getElementById("accept-buttons-box").setAttribute("action", action);
+ document.getElementById("accept-buttons-box").setAttribute("type", itemType);
+
+ document.getElementById("accept-occurrence-button").label = getDString(
+ `buttons.${multiple}.occurrence.${action}.label`
+ );
+
+ document.getElementById("accept-allfollowing-button").label = getDString(
+ `buttons.${multiple}.allfollowing.${action}.label`
+ );
+ document.getElementById("accept-parent-button").label = getDString(
+ `buttons.${multiple}.parent.${action}.label`
+ );
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml
new file mode 100644
index 0000000000..7a7357681c
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-occurrence-prompt.xhtml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-occurrence-prompt.css" type="text/css"?>
+
+<!DOCTYPE html SYSTEM "chrome://calendar/locale/calendar-occurrence-prompt.dtd">
+<html
+ id="calendar-occurrence-prompt"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title><!-- windowtitle.${itemType}.${action} --></title>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-occurrence-prompt.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog buttons="accept,cancel">
+ <vbox id="occurrence-prompt-header" pack="center">
+ <label id="title-label" crop="end" />
+ <label id="isrepeating-label" />
+ </vbox>
+
+ <vbox id="accept-buttons-box" flex="1" pack="center">
+ <button
+ id="accept-occurrence-button"
+ default="true"
+ dlgtype="accept"
+ class="occurrence-accept-buttons"
+ accesskey="&buttons.occurrence.accesskey;"
+ oncommand="exitOccurrenceDialog(1)"
+ pack="start"
+ />
+ <!-- XXXphilipp Button is hidden until all following is implemented -->
+ <button
+ id="accept-allfollowing-button"
+ class="occurrence-accept-buttons"
+ accesskey="&buttons.allfollowing.accesskey;"
+ oncommand="exitOccurrenceDialog(2)"
+ hidden="true"
+ pack="start"
+ />
+ <button
+ id="accept-parent-button"
+ class="occurrence-accept-buttons"
+ accesskey="&buttons.parent.accesskey;"
+ oncommand="exitOccurrenceDialog(3)"
+ pack="start"
+ />
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-properties-dialog.js b/comm/calendar/base/content/dialogs/calendar-properties-dialog.js
new file mode 100644
index 0000000000..c8533b2d51
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-properties-dialog.js
@@ -0,0 +1,251 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported onLoad */
+
+/* import-globals-from ../../../../mail/base/content/utilityOverlay.js */
+/* import-globals-from ../calendar-ui-utils.js */
+/* import-globals-from calendar-identity-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+
+/**
+ * The calendar to modify, is retrieved from window.arguments[0].calendar
+ */
+var gCalendar;
+
+window.addEventListener("DOMContentLoaded", onLoad);
+
+/**
+ * Called when the calendar properties dialog gets opened. When opening the
+ * window, use an object as argument with a 'calendar' property for the
+ * calendar in question, and a `canDisable` property for whether to offer
+ * disabling/enabling the calendar.
+ */
+function onLoad() {
+ /** @type {{ calendar: calICalendar, canDisable: boolean}} */
+ let args = window.arguments[0];
+
+ gCalendar = args.calendar; // eslint-disable-line no-global-assign
+
+ // Some servers provide colors as an 8-character hex string, which the color
+ // picker can't handle. Strip the alpha component.
+ let calColor = gCalendar.getProperty("color");
+ let alphaHex = calColor?.match(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/);
+ if (alphaHex) {
+ gCalendar.setProperty("color", alphaHex[1]);
+ calColor = alphaHex[1];
+ }
+
+ if (args.canDisable && !gCalendar.getProperty("force-disabled")) {
+ document.documentElement.setAttribute("canDisable", "true");
+ } else {
+ document.getElementById("calendar-enabled-checkbox").hidden = true;
+ }
+
+ document.getElementById("calendar-name").value = gCalendar.name;
+ document.getElementById("calendar-color").value = calColor || "#A8C2E1";
+ if (["memory", "storage"].includes(gCalendar.type)) {
+ document.getElementById("calendar-uri-row").hidden = true;
+ } else {
+ document.getElementById("calendar-uri").value = gCalendar.uri.spec;
+ }
+ document.getElementById("read-only").checked = gCalendar.readOnly;
+
+ if (gCalendar.getProperty("capabilities.username.supported") === true) {
+ document.getElementById("calendar-username").value = gCalendar.getProperty("username");
+ document.getElementById("calendar-username-row").toggleAttribute("hidden", false);
+ } else {
+ document.getElementById("calendar-username-row").toggleAttribute("hidden", true);
+ }
+
+ // Set up refresh interval
+ initRefreshInterval();
+
+ // Set up the cache field
+ let cacheBox = document.getElementById("cache");
+ let canCache = gCalendar.getProperty("cache.supported") !== false;
+ let alwaysCache = gCalendar.getProperty("cache.always");
+ if (!canCache || alwaysCache) {
+ cacheBox.setAttribute("disable-capability", "true");
+ cacheBox.hidden = true;
+ cacheBox.disabled = true;
+ }
+ cacheBox.checked = alwaysCache || (canCache && gCalendar.getProperty("cache.enabled"));
+
+ // Set up the show alarms row and checkbox
+ let suppressAlarmsRow = document.getElementById("calendar-suppressAlarms-row");
+ let suppressAlarms = gCalendar.getProperty("suppressAlarms");
+ document.getElementById("fire-alarms").checked = !suppressAlarms;
+
+ suppressAlarmsRow.toggleAttribute(
+ "hidden",
+ gCalendar.getProperty("capabilities.alarms.popup.supported") === false
+ );
+
+ // Set up the identity and scheduling rows.
+ initMailIdentitiesRow(gCalendar);
+ notifyOnIdentitySelection(gCalendar);
+ initForceEmailScheduling();
+
+ // Set up the disabled checkbox
+ let calendarDisabled = false;
+ if (gCalendar.getProperty("force-disabled")) {
+ document.getElementById("force-disabled-description").removeAttribute("hidden");
+ document.getElementById("calendar-enabled-checkbox").setAttribute("disabled", "true");
+ } else {
+ calendarDisabled = gCalendar.getProperty("disabled");
+ document.getElementById("calendar-enabled-checkbox").checked = !calendarDisabled;
+ document.querySelector("dialog").getButton("extra1").setAttribute("hidden", "true");
+ }
+ setupEnabledCheckbox();
+
+ // start focus on title, unless we are disabled
+ if (!calendarDisabled) {
+ document.getElementById("calendar-name").focus();
+ }
+
+ let notificationsSetting = document.getElementById("calendar-notifications-setting");
+ notificationsSetting.value = gCalendar.getProperty("notifications.times");
+}
+
+/**
+ * Called when the dialog is accepted, to save settings.
+ */
+function onAcceptDialog() {
+ // Save calendar name
+ gCalendar.name = document.getElementById("calendar-name").value;
+
+ // Save calendar color
+ gCalendar.setProperty("color", document.getElementById("calendar-color").value);
+
+ // Save calendar user
+ if (gCalendar.getProperty("capabilities.username.supported") === true) {
+ gCalendar.setProperty("username", document.getElementById("calendar-username").value);
+ }
+
+ // Save readonly state
+ gCalendar.readOnly = document.getElementById("read-only").checked;
+
+ // Save supressAlarms
+ gCalendar.setProperty("suppressAlarms", !document.getElementById("fire-alarms").checked);
+
+ // Save refresh interval
+ if (gCalendar.canRefresh) {
+ let value = document.getElementById("calendar-refreshInterval-menulist").value;
+ gCalendar.setProperty("refreshInterval", value);
+ }
+
+ // Save cache options
+ let alwaysCache = gCalendar.getProperty("cache.always");
+ if (!alwaysCache) {
+ gCalendar.setProperty("cache.enabled", document.getElementById("cache").checked);
+ }
+
+ // Save identity and scheduling options.
+ saveMailIdentitySelection(gCalendar);
+ saveForceEmailScheduling();
+
+ if (!gCalendar.getProperty("force-disabled")) {
+ // Save disabled option (should do this last), remove auto-enabled
+ gCalendar.setProperty(
+ "disabled",
+ !document.getElementById("calendar-enabled-checkbox").checked
+ );
+ gCalendar.deleteProperty("auto-enabled");
+ }
+
+ gCalendar.setProperty(
+ "notifications.times",
+ document.getElementById("calendar-notifications-setting").value
+ );
+}
+// When this event fires, onAcceptDialog might not be the function defined
+// above, so call it indirectly.
+document.addEventListener("dialogaccept", () => onAcceptDialog());
+
+/**
+ * Called when an identity is selected.
+ */
+function onChangeIdentity(aEvent) {
+ notifyOnIdentitySelection(gCalendar);
+ updateForceEmailSchedulingControl();
+}
+
+/**
+ * When the calendar is disabled, we need to disable a number of other elements
+ */
+function setupEnabledCheckbox() {
+ let isEnabled = document.getElementById("calendar-enabled-checkbox").checked;
+ let els = document.getElementsByAttribute("disable-with-calendar", "true");
+ for (let i = 0; i < els.length; i++) {
+ els[i].disabled = !isEnabled || els[i].getAttribute("disable-capability") == "true";
+ }
+}
+
+/**
+ * Called to unsubscribe from a calendar. The button for this function is not
+ * shown unless the provider for the calendar is missing (i.e force-disabled)
+ */
+document.addEventListener("dialogextra1", () => {
+ cal.manager.unregisterCalendar(gCalendar);
+ window.close();
+});
+
+function initRefreshInterval() {
+ function createMenuItem(minutes) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("value", minutes);
+
+ let everyMinuteString = cal.l10n.getCalString("calendarPropertiesEveryMinute");
+ let label = PluralForm.get(minutes, everyMinuteString).replace("#1", minutes);
+ menuitem.setAttribute("label", label);
+
+ return menuitem;
+ }
+
+ document
+ .getElementById("calendar-refreshInterval-row")
+ .toggleAttribute("hidden", !gCalendar.canRefresh);
+
+ if (gCalendar.canRefresh) {
+ let refreshInterval = gCalendar.getProperty("refreshInterval");
+ if (refreshInterval === null) {
+ refreshInterval = 30;
+ }
+
+ let foundValue = false;
+ let separator = document.getElementById("calendar-refreshInterval-manual-separator");
+ let menulist = document.getElementById("calendar-refreshInterval-menulist");
+ for (let min of [1, 5, 15, 30, 60]) {
+ let menuitem = createMenuItem(min);
+
+ separator.parentNode.insertBefore(menuitem, separator);
+ if (refreshInterval == min) {
+ menulist.selectedItem = menuitem;
+ foundValue = true;
+ }
+ }
+
+ if (refreshInterval == 0) {
+ menulist.selectedItem = document.getElementById("calendar-refreshInterval-manual");
+ foundValue = true;
+ }
+
+ if (!foundValue) {
+ // Special menuitem in case the user changed the value in the config editor.
+ let menuitem = createMenuItem(refreshInterval);
+ separator.parentNode.insertBefore(menuitem, separator.nextElementSibling);
+ menulist.selectedItem = menuitem;
+ }
+ }
+}
+
+/**
+ * Open the Preferences tab with global notifications setting.
+ */
+function showGlobalNotificationsPref() {
+ openPreferencesTab("paneCalendar", "calendarNotificationCategory");
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml
new file mode 100644
index 0000000000..cc1186eb3d
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-properties-dialog.xhtml
@@ -0,0 +1,257 @@
+<?xml version="1.0" encoding="UTf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-properties-dialog.css" type="text/css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" >
+%dtd2;
+<!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendarCreation.dtd" >
+%dtd3;
+<!ENTITY % dtd4 SYSTEM "chrome://lightning/locale/lightning.dtd" >
+%dtd4; ]>
+<html
+ id="calendar-properties-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="calendar-general-dialog"
+ windowtype="Calendar:PropertiesDialog"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ width="600"
+ height="630"
+>
+ <head>
+ <title>&calendar.server.dialog.title.edit;</title>
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-identity-utils.js"></script>
+ <script
+ defer="defer"
+ src="chrome://calendar/content/widgets/calendar-notifications-setting.js"
+ ></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-properties-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <!-- A streamlined version of this dialog is used in the accountSetup.xhtml file
+ as a native HTML dialog. Keep these dialogs in sync if a property changes. -->
+ <dialog
+ buttons="accept,cancel,extra1"
+ buttonlabelextra1="&calendarproperties.unsubscribe.label;"
+ buttonaccesskeyextra1="&calendarproperties.unsubscribe.accesskey;"
+ >
+ <description id="force-disabled-description" hidden="true"
+ >&calendarproperties.forceDisabled.label;</description
+ >
+
+ <vbox id="no-identity-notification" class="notification-inline">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+ <checkbox
+ id="calendar-enabled-checkbox"
+ label="&calendarproperties.enabled2.label;"
+ oncommand="setupEnabledCheckbox()"
+ />
+ <html:table id="calendar-properties-table">
+ <html:tr id="calendar-name-row">
+ <html:th>
+ <label
+ id="calendar-name-label"
+ value="&calendar.server.dialog.name.label;"
+ disable-with-calendar="true"
+ control="calendar-name"
+ />
+ </html:th>
+ <html:td>
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="calendar-name"
+ type="text"
+ class="input-inline"
+ disable-with-calendar="true"
+ aria-labelledby="calendar-name-label"
+ />
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-color-row">
+ <html:th>
+ <label
+ id="calendar-color-label"
+ value="&calendarproperties.color.label;"
+ disable-with-calendar="true"
+ control="calendar-color"
+ />
+ </html:th>
+ <html:td>
+ <html:input
+ id="calendar-color"
+ type="color"
+ class="input-inline-color"
+ disable-with-calendar="true"
+ aria-labelledby="calendar-color-label"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-username-row">
+ <html:th>
+ <label
+ id="calendar-username-label"
+ value="&locationpage.username.label;"
+ disable-with-calendar="true"
+ control="calendar-username"
+ />
+ </html:th>
+ <html:td>
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="calendar-username"
+ type="text"
+ class="input-inline"
+ disable-with-calendar="true"
+ aria-labelledby="calendar-username-label"
+ />
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-uri-row">
+ <html:th>
+ <label
+ id="calendar-uri-label"
+ value="&calendarproperties.location.label;"
+ disable-with-calendar="true"
+ control="calendar-uri"
+ />
+ </html:th>
+ <html:td>
+ <hbox flex="1" class="input-container">
+ <html:input
+ id="calendar-uri"
+ type="url"
+ class="input-inline"
+ readonly="readonly"
+ disable-with-calendar="true"
+ aria-labelledby="calendar-uri-label"
+ />
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-refreshInterval-row">
+ <html:th>
+ <label
+ value="&calendarproperties.refreshInterval.label;"
+ disable-with-calendar="true"
+ control="calendar-refreshInterval-textbox"
+ />
+ </html:th>
+ <html:td>
+ <menulist
+ id="calendar-refreshInterval-menulist"
+ disable-with-calendar="true"
+ label="&calendarproperties.refreshInterval.label;"
+ >
+ <menupopup id="calendar-refreshInterval-menupopup">
+ <!-- This will be filled programmatically to reduce the number of needed strings -->
+ <menuseparator id="calendar-refreshInterval-manual-separator" />
+ <menuitem
+ id="calendar-refreshInterval-manual"
+ value="0"
+ label="&calendarproperties.refreshInterval.manual.label;"
+ />
+ </menupopup>
+ </menulist>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-readOnly-row">
+ <html:th></html:th>
+ <html:td>
+ <checkbox
+ id="read-only"
+ label="&calendarproperties.readonly.label;"
+ disable-with-calendar="true"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-suppressAlarms-row">
+ <html:th></html:th>
+ <html:td>
+ <checkbox
+ id="fire-alarms"
+ label="&calendarproperties.firealarms.label;"
+ disable-with-calendar="true"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-cache-row">
+ <html:th></html:th>
+ <html:td>
+ <checkbox
+ id="cache"
+ label="&calendarproperties.cache3.label;"
+ disable-with-calendar="true"
+ />
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-email-identity-row">
+ <html:th>
+ <label
+ value="&lightning.calendarproperties.email.label;"
+ control="email-identity-menulist"
+ disable-with-calendar="true"
+ />
+ </html:th>
+ <html:td>
+ <menulist
+ id="email-identity-menulist"
+ disable-with-calendar="true"
+ oncommand="onChangeIdentity(event)"
+ >
+ <menupopup id="email-identity-menupopup" />
+ </menulist>
+ </html:td>
+ </html:tr>
+ <html:tr id="calendar-force-email-scheduling-row">
+ <html:th></html:th>
+ <html:td>
+ <checkbox
+ id="force-email-scheduling"
+ label="&lightning.calendarproperties.forceEmailScheduling.label;"
+ disable-with-calendar="true"
+ tooltiptext="&lightning.calendarproperties.forceEmailScheduling.tooltiptext2;"
+ />
+ </html:td>
+ </html:tr>
+ </html:table>
+
+ <separator />
+ <vbox id="calendar-notifications">
+ <label
+ id="calendar-notifications-title"
+ value="&lightning.calendarproperties.notifications.label;"
+ disable-with-calendar="true"
+ />
+ <calendar-notifications-setting
+ id="calendar-notifications-setting"
+ disable-with-calendar="true"
+ />
+ <hbox id="global-notifications-row">
+ <button
+ label="&lightning.calendarproperties.globalNotifications.label;"
+ oncommand="showGlobalNotificationsPref();"
+ ></button>
+ </hbox>
+ </vbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js
new file mode 100644
index 0000000000..0f39cecbf1
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+window.addEventListener("DOMContentLoaded", onLoad);
+function onLoad() {
+ let extension = window.arguments[0].extension;
+ document.getElementById("provider-name-label").value = extension.name;
+
+ let calendarList = document.getElementById("calendar-list");
+
+ for (let calendar of cal.manager.getCalendars()) {
+ if (calendar.providerID != extension.id) {
+ continue;
+ }
+
+ let item = document.createXULElement("richlistitem");
+ item.setAttribute("calendar-id", calendar.id);
+
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.classList.add("calendar-selected");
+ item.appendChild(checkbox);
+
+ let colorMarker = document.createElement("div");
+ colorMarker.classList.add("calendar-color");
+ item.appendChild(colorMarker);
+ colorMarker.style.backgroundColor = calendar.getProperty("color");
+
+ let label = document.createXULElement("label");
+ label.classList.add("calendar-name");
+ label.value = calendar.name;
+ item.appendChild(label);
+
+ calendarList.appendChild(item);
+ }
+}
+
+document.addEventListener("dialogaccept", () => {
+ // Tell our caller that the extension should be uninstalled.
+ let args = window.arguments[0];
+ args.shouldUninstall = true;
+
+ let calendarList = document.getElementById("calendar-list");
+
+ // Unsubscribe from all selected calendars
+ for (let item of calendarList.children) {
+ if (item.querySelector(".calendar-selected").checked) {
+ cal.manager.unregisterCalendar(cal.manager.getCalendarById(item.getAttribute("calendar-id")));
+ }
+ }
+});
+
+document.addEventListener("dialogcancel", () => {
+ let args = window.arguments[0];
+ args.shouldUninstall = false;
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml
new file mode 100644
index 0000000000..3d254aafcc
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xhtml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-providerUninstall-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/calendar-widgets.css"?>
+
+<!DOCTYPE html SYSTEM "chrome://calendar/locale/provider-uninstall.dtd">
+<html
+ id="calendar-provider-uninstall-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:ProviderUninstall"
+ width="480"
+ height="320"
+ style="min-width: 480px"
+ scrolling="false"
+>
+ <head>
+ <title>&providerUninstall.title;</title>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script
+ defer="defer"
+ src="chrome://calendar/content/calendar-providerUninstall-dialog.js"
+ ></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog
+ buttonlabelaccept="&providerUninstall.accept.label;"
+ buttonaccesskeyaccept="&providerUninstall.accept.accesskey;"
+ >
+ <description id="pre-name-description">&providerUninstall.preName.label;</description>
+ <label id="provider-name-label" />
+ <description id="post-name-description">&providerUninstall.postName.label;</description>
+ <description id="reinstall-note-description"
+ >&providerUninstall.reinstallNote.label;</description
+ >
+
+ <richlistbox id="calendar-list" flex="1" />
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-summary-dialog.js b/comm/calendar/base/content/dialogs/calendar-summary-dialog.js
new file mode 100644
index 0000000000..0f70375814
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-summary-dialog.js
@@ -0,0 +1,381 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported reply */
+
+/* global MozElements */
+
+/* import-globals-from calendar-dialog-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+ChromeUtils.defineESModuleGetters(this, {
+ SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "gStatusNotification", () => {
+ return new MozElements.NotificationBox(async element => {
+ let box = document.getElementById("status-notifications");
+ // Fix window size after the notification animation is done.
+ box.addEventListener(
+ "transitionend",
+ () => {
+ window.sizeToContent();
+ },
+ { once: true }
+ );
+ box.append(element);
+ });
+});
+
+window.addEventListener("load", onLoad);
+window.addEventListener("unload", onUnload);
+
+/**
+ * Sets up the summary dialog, setting all needed fields on the dialog from the
+ * item received in the window arguments.
+ */
+async function onLoad() {
+ let args = window.arguments[0];
+ let item = args.calendarEvent;
+ item = item.clone(); // use an own copy of the passed item
+ window.calendarItem = item;
+ window.isInvitation = args.isInvitation;
+ let dialog = document.querySelector("dialog");
+
+ document.title = item.title;
+
+ // set the dialog-id to enable the right CSS to be used.
+ if (item.isEvent()) {
+ setDialogId(dialog, "calendar-event-summary-dialog");
+ } else if (item.isTodo()) {
+ setDialogId(dialog, "calendar-task-summary-dialog");
+ }
+
+ // Start setting up the item summary custom element.
+ let itemSummary = document.getElementById("calendar-item-summary");
+ itemSummary.item = item;
+
+ window.readOnly = itemSummary.readOnly;
+ let calendar = itemSummary.calendar;
+
+ if (!window.readOnly) {
+ let attendee = cal.itip.getInvitedAttendee(item, calendar);
+ if (attendee) {
+ // if this is an unresponded invitation, preset our default alarm values:
+ if (!item.getAlarms().length && attendee.participationStatus == "NEEDS-ACTION") {
+ cal.alarms.setDefaultValues(item);
+ }
+
+ window.attendee = attendee.clone();
+ // Since we don't have API to update an attendee in place, remove
+ // and add again. Also, this is needed if the attendee doesn't exist
+ // (i.e REPLY on a mailing list)
+ item.removeAttendee(attendee);
+ item.addAttendee(window.attendee);
+
+ window.responseMode = "USER";
+ }
+ }
+
+ // Finish setting up the item summary custom element.
+ itemSummary.updateItemDetails();
+
+ updateToolbar();
+ updateDialogButtons(item);
+
+ if (typeof window.ToolbarIconColor !== "undefined") {
+ window.ToolbarIconColor.init();
+ }
+
+ await document.l10n.translateRoots();
+ window.sizeToContent();
+ window.focus();
+ opener.setCursor("auto");
+}
+
+function onUnload() {
+ if (typeof window.ToolbarIconColor !== "undefined") {
+ window.ToolbarIconColor.uninit();
+ }
+}
+
+/**
+ * Updates the user's participation status (PARTSTAT from see RFC5545), and
+ * send a notification if requested. Then close the dialog.
+ *
+ * @param {string} aResponseMode - a literal of one of the response modes defined
+ * in calIItipItem (like 'NONE')
+ * @param {string} aPartStat - participation status; a PARTSTAT value
+ */
+function reply(aResponseMode, aPartStat) {
+ // Set participation status.
+ if (window.attendee) {
+ let aclEntry = window.calendarItem.calendar.aclEntry;
+ if (aclEntry) {
+ let userAddresses = aclEntry.getUserAddresses();
+ if (
+ userAddresses.length > 0 &&
+ !cal.email.attendeeMatchesAddresses(window.attendee, userAddresses)
+ ) {
+ window.attendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]);
+ }
+ }
+ window.attendee.participationStatus = aPartStat;
+ updateToolbar();
+ }
+
+ // Send notification and close window.
+ saveAndClose(aResponseMode);
+}
+
+/**
+ * Stores the event in the calendar, sends a notification if requested and
+ * closes the dialog.
+ *
+ * @param {string} aResponseMode - a literal of one of the response modes defined
+ * in calIItipItem (like 'NONE')
+ */
+function saveAndClose(aResponseMode) {
+ window.responseMode = aResponseMode;
+ document.querySelector("dialog").acceptDialog();
+}
+
+function updateToolbar() {
+ if (window.readOnly || window.isInvitation !== true) {
+ document.getElementById("summary-toolbox").hidden = true;
+ return;
+ }
+
+ let replyButtons = document.getElementsByAttribute("type", "menu-button");
+ for (let element of replyButtons) {
+ element.removeAttribute("hidden");
+ if (window.attendee) {
+ // we disable the control which represents the current partstat
+ let status = window.attendee.participationStatus || "NEEDS-ACTION";
+ if (element.getAttribute("value") == status) {
+ element.setAttribute("disabled", "true");
+ } else {
+ element.removeAttribute("disabled");
+ }
+ }
+ }
+
+ if (window.attendee) {
+ // we display a notification about the users partstat
+ let partStat = window.attendee.participationStatus || "NEEDS-ACTION";
+ let type = window.calendarItem.isEvent() ? "event" : "task";
+
+ let msgStr = {
+ ACCEPTED: type + "Accepted",
+ COMPLETED: "taskCompleted",
+ DECLINED: type + "Declined",
+ DELEGATED: type + "Delegated",
+ TENTATIVE: type + "Tentative",
+ };
+ // this needs to be noted differently to get accepted the '-' in the key
+ msgStr["NEEDS-ACTION"] = type + "NeedsAction";
+ msgStr["IN-PROGRESS"] = "taskInProgress";
+
+ let msg = cal.l10n.getString("calendar-event-dialog", msgStr[partStat]);
+
+ gStatusNotification.appendNotification(
+ "statusNotification",
+ {
+ label: msg,
+ priority: gStatusNotification.PRIORITY_INFO_MEDIUM,
+ },
+ null
+ );
+ } else {
+ gStatusNotification.removeAllNotifications();
+ }
+}
+
+/**
+ * Copy the text content of the given link node to the clipboard.
+ *
+ * @param {string} labelNode - The label node inside an html:a element.
+ */
+function locationCopyLink(labelNode) {
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
+ clipboard.copyString(labelNode.parentNode.getAttribute("href"));
+}
+
+/**
+ * This configures the dialog buttons depending on the writable status
+ * of the item and whether it recurs or not:
+ * 1) The calendar is read-only - The buttons stay hidden.
+ * 2) The item is an invitation - The buttons stay hidden.
+ * 3) The item is recurring - Show an edit menu with occurrence options.
+ * 4) Otherwise - Show the single edit button.
+ *
+ * @param {calIItemBase} item
+ */
+function updateDialogButtons(item) {
+ let editButton = document.getElementById("calendar-summary-dialog-edit-button");
+ let isRecurring = item.parentItem !== item;
+ if (window.readOnly === true) {
+ // This enables pressing the "enter" key to close the dialog.
+ editButton.focus();
+ } else if (window.isInvitation === true) {
+ document.addEventListener("dialogaccept", onInvitationDialogAccept);
+ } else if (isRecurring) {
+ // Show the edit button menu for repeating events.
+ let menuButton = document.getElementById("calendar-summary-dialog-edit-menu-button");
+ menuButton.hidden = false;
+
+ // Pressing the "enter" key will display the occurrence menu.
+ document.getElementById("calendar-summary-dialog-edit-menu-button").focus();
+ document.addEventListener("dialogaccept", evt => {
+ evt.preventDefault();
+ });
+ } else {
+ // Show the single edit button for non-repeating events.
+ document.addEventListener("dialogaccept", () => {
+ useEditDialog(item);
+ });
+ editButton.hidden = false;
+ }
+ // Show the custom dialog footer when the event is editable.
+ if (window.readOnly !== true && window.isInvitation !== true) {
+ let footer = document.getElementById("calendar-summary-dialog-custom-button-footer");
+ footer.hidden = false;
+ }
+}
+
+/**
+ * Saves any changed information to the item.
+ */
+function onInvitationDialogAccept() {
+ // let's make sure we have a response mode defined
+ let resp = window.responseMode || "USER";
+ let respMode = { responseMode: Ci.calIItipItem[resp] };
+
+ let args = window.arguments[0];
+ let oldItem = args.calendarEvent;
+ let newItem = window.calendarItem;
+ let calendar = newItem.calendar;
+ saveReminder(newItem, calendar, document.querySelector(".item-alarm"));
+ adaptScheduleAgent(newItem);
+ args.onOk(newItem, calendar, oldItem, null, respMode);
+ window.calendarItem = newItem;
+}
+
+/**
+ * Invokes the editing dialog for the current item occurrence.
+ */
+function onEditThisOccurrence() {
+ useEditDialog(window.calendarItem);
+}
+
+/**
+ * Invokes the editing dialog for all occurrences of the current item.
+ */
+function onEditAllOccurrences() {
+ useEditDialog(window.calendarItem.parentItem);
+}
+
+/**
+ * Switch to the "modify" mode dialog so the user can make changes to the event.
+ *
+ * @param {calIItemBase} item
+ */
+function useEditDialog(item) {
+ window.addEventListener("unload", () => {
+ window.opener.modifyEventWithDialog(item, false);
+ });
+ window.close();
+}
+
+/**
+ * Initializes the context menu used for the attendees area.
+ *
+ * @param {Event} event
+ */
+function onAttendeeContextMenu(event) {
+ let copyMenu = document.getElementById("attendee-popup-copy-menu");
+ let item = window.arguments[0].calendarEvent;
+
+ let attId =
+ event.target.getAttribute("attendeeid") || event.target.parentNode.getAttribute("attendeeid");
+ let attendee = item.getAttendees().find(att => att.id == attId);
+
+ if (!attendee) {
+ copyMenu.hidden = true;
+ return;
+ }
+
+ let id = attendee.toString();
+ let idMenuItem = document.getElementById("attendee-popup-copy-menu-id");
+ idMenuItem.setAttribute("label", id);
+ idMenuItem.hidden = false;
+
+ let name = attendee.commonName;
+ let nameMenuItem = document.getElementById("attendee-popup-copy-menu-common-name");
+ if (name && name != id) {
+ nameMenuItem.setAttribute("label", name);
+ nameMenuItem.hidden = false;
+ } else {
+ nameMenuItem.hidden = true;
+ }
+
+ copyMenu.hidden = false;
+}
+
+/**
+ * Initializes the context menu used for the event description area in the
+ * event summary.
+ *
+ * @param {Event} event
+ */
+function openDescriptionContextMenu(event) {
+ const popup = document.getElementById("description-popup");
+ const link = event.target.closest("a") ? event.target.closest("a").getAttribute("href") : null;
+ const linkText = event.target.closest("a") ? event.target.closest("a").text : null;
+ const copyLinkTextMenuItem = document.getElementById("description-context-menu-copy-link-text");
+ const copyLinkLocationMenuItem = document.getElementById(
+ "description-context-menu-copy-link-location"
+ );
+ const selectionCollapsed = SelectionUtils.getSelectionDetails(window).docSelectionIsCollapsed;
+
+ // Hide copy command if there is no text selected.
+ popup.querySelector('[command="cmd_copy"]').hidden = selectionCollapsed;
+
+ copyLinkLocationMenuItem.hidden = !link;
+ copyLinkTextMenuItem.hidden = !link;
+ popup.querySelector("#calendar-summary-description-context-menuseparator").hidden =
+ selectionCollapsed && !link;
+ copyLinkTextMenuItem.setAttribute("text", linkText);
+
+ popup.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ event.preventDefault();
+}
+
+/**
+ * Copies the link text in a calender event description
+ * @param {Event} event
+ */
+async function copyLinkTextToClipboard(event) {
+ return navigator.clipboard.writeText(event.target.getAttribute("text"));
+}
+
+/**
+ * Copies the label value of a menuitem to the clipboard.
+ */
+async function copyLabelToClipboard(event) {
+ return navigator.clipboard.writeText(event.target.getAttribute("label"));
+}
+
+/**
+ * Brings up the compose window to send an e-mail to all attendees.
+ */
+function sendMailToAttendees() {
+ let item = window.arguments[0].calendarEvent;
+ let toList = cal.email.createRecipientList(item.getAttendees());
+ let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]);
+ let identity = item.calendar.getProperty("imip.identity");
+ cal.email.sendTo(toList, emailSubject, null, identity);
+}
diff --git a/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml
new file mode 100644
index 0000000000..d3bfe394fc
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-summary-dialog.xhtml
@@ -0,0 +1,232 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/input-fields.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-item-summary.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-summary-dialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" >
+<!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" >
+<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" >
+%globalDTD; %calendarDTD; %dialogDTD; %brandDTD; ]>
+<html
+ id="calendar-summary-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ icon="calendar-general-dialog"
+ windowtype="Calendar:EventSummaryDialog"
+ lightweightthemes="true"
+ persist="screenX screenY"
+ scrolling="false"
+>
+ <head>
+ <title><!-- item title --></title>
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="calendar/calendar-summary-dialog.ftl" />
+ <link rel="localization" href="calendar/calendar-editable-item.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-item-summary.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-summary-dialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog buttons=",">
+ <toolbox
+ id="summary-toolbox"
+ class="mail-toolbox"
+ mode="full"
+ defaultmode="full"
+ iconsize="small"
+ defaulticonsize="small"
+ labelalign="end"
+ defaultlabelalign="end"
+ >
+ <toolbar
+ id="summary-toolbar"
+ toolboxid="summary-toolbox"
+ class="chromeclass-toolbar themeable-full"
+ customizable="false"
+ labelalign="end"
+ defaultlabelalign="end"
+ >
+ <toolbarbutton
+ id="saveandcloseButton"
+ tooltiptext="&summary.dialog.saveclose.tooltiptext;"
+ label="&summary.dialog.saveclose.label;"
+ oncommand="saveAndClose('NONE');"
+ class="cal-event-toolbarbutton toolbarbutton-1 saveandcloseButton"
+ />
+ <toolbarbutton
+ is="toolbarbutton-menu-button"
+ id="acceptButton"
+ type="menu"
+ tooltiptext="&summary.dialog.accept.tooltiptext;"
+ label="&summary.dialog.accept.label;"
+ oncommand="reply('AUTO', 'ACCEPTED');"
+ class="cal-event-toolbarbutton toolbarbutton-1 replyButton"
+ >
+ <menupopup id="acceptDropdown">
+ <menuitem
+ id="acceptButton_Send"
+ tooltiptext="&summary.dialog.send.tooltiptext;"
+ label="&summary.dialog.send.label;"
+ oncommand="reply('AUTO', 'ACCEPTED'); event.stopPropagation();"
+ />
+ <menuitem
+ id="acceptButton_DontSend"
+ tooltiptext="&summary.dialog.dontsend.tooltiptext;"
+ label="&summary.dialog.dontsend.label;"
+ oncommand="reply('NONE', 'ACCEPTED'); event.stopPropagation();"
+ />
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton
+ is="toolbarbutton-menu-button"
+ id="tentativeButton"
+ type="menu"
+ tooltiptext="&summary.dialog.tentative.tooltiptext;"
+ label="&summary.dialog.tentative.label;"
+ oncommand="reply('AUTO', 'TENTATIVE');"
+ class="cal-event-toolbarbutton toolbarbutton-1 replyButton"
+ >
+ <menupopup id="tentativeDropdown">
+ <menuitem
+ id="tenatativeButton_Send"
+ tooltiptext="&summary.dialog.send.tooltiptext;"
+ label="&summary.dialog.send.label;"
+ oncommand="reply('AUTO', 'TENTATIVE'); event.stopPropagation();"
+ />
+ <menuitem
+ id="tenativeButton_DontSend"
+ tooltiptext="&summary.dialog.dontsend.tooltiptext;"
+ label="&summary.dialog.dontsend.label;"
+ oncommand="reply('NONE', 'TENTATIVE'); event.stopPropagation();"
+ />
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton
+ is="toolbarbutton-menu-button"
+ id="declineButton"
+ type="menu"
+ tooltiptext="&summary.dialog.decline.tooltiptext;"
+ label="&summary.dialog.decline.label;"
+ oncommand="reply('AUTO', 'DECLINED');"
+ class="cal-event-toolbarbutton toolbarbutton-1 replyButton"
+ >
+ <menupopup id="declineDropdown">
+ <menuitem
+ id="declineButton_Send"
+ tooltiptext="&summary.dialog.send.tooltiptext;"
+ label="&summary.dialog.send.label;"
+ oncommand="reply('AUTO', 'DECLINED'); event.stopPropagation();"
+ />
+ <menuitem
+ id="declineButton_DontSend"
+ tooltiptext="&summary.dialog.dontsend.tooltiptext;"
+ label="&summary.dialog.dontsend.label;"
+ oncommand="reply('NONE', 'DECLINED'); event.stopPropagation();"
+ />
+ </menupopup>
+ </toolbarbutton>
+ </toolbar>
+ </toolbox>
+
+ <vbox id="status-notifications">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+ <calendar-item-summary id="calendar-item-summary" flex="1" />
+
+ <!-- LOCATION LINK CONTEXT MENU -->
+ <menupopup id="location-link-context-menu">
+ <menuitem
+ id="location-link-context-menu-copy"
+ label="&calendar.copylink.label;"
+ accesskey="&calendar.copylink.accesskey;"
+ oncommand="locationCopyLink(this.parentNode.triggerNode)"
+ />
+ </menupopup>
+ <!-- ATTENDEES CONTEXT MENU -->
+ <menupopup id="attendee-popup">
+ <menu id="attendee-popup-copy-menu" data-l10n-id="text-action-copy">
+ <menupopup>
+ <menuitem
+ id="attendee-popup-copy-menu-common-name"
+ oncommand="copyLabelToClipboard(event)"
+ />
+ <menuitem id="attendee-popup-copy-menu-id" oncommand="copyLabelToClipboard(event)" />
+ </menupopup>
+ </menu>
+ <menuitem
+ id="attendee-popup-sendemail"
+ label="&event.email.attendees.label;"
+ accesskey="&event.email.attendees.accesskey;"
+ oncommand="sendMailToAttendees()"
+ />
+ </menupopup>
+ <menupopup id="description-popup" onpopupshowing="goUpdateGlobalEditMenuItems(true);">
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy" />
+ <menuitem
+ id="description-context-menu-copy-link-location"
+ label="&calendar.copylink.label;"
+ accesskey="&calendar.copylink.accesskey;"
+ oncommand="goDoCommand('cmd_copyLink')"
+ />
+ <menuitem
+ id="description-context-menu-copy-link-text"
+ data-l10n-id="description-context-menu-copy-link-text"
+ oncommand="copyLinkTextToClipboard(event)"
+ />
+ <menuseparator id="calendar-summary-description-context-menuseparator" />
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll" />
+ </menupopup>
+ <hbox id="calendar-summary-dialog-custom-button-footer" hidden="true">
+ <spacer class="button-spacer" flex="1" />
+
+ <button
+ id="calendar-summary-dialog-edit-button"
+ default="true"
+ dlgtype="accept"
+ hidden="true"
+ data-l10n-id="calendar-summary-dialog-edit-button"
+ />
+
+ <button
+ id="calendar-summary-dialog-edit-menu-button"
+ type="menu"
+ hidden="true"
+ data-l10n-id="calendar-summary-dialog-edit-menu-button"
+ >
+ <menupopup id="edit-button-context-menu">
+ <menuitem
+ id="edit-button-context-menu-this-occurrence"
+ data-l10n-id="edit-button-context-menu-this-occurrence"
+ oncommand="onEditThisOccurrence()"
+ />
+ <menuitem
+ id="edit-button-context-menu-all-occurrences"
+ data-l10n-id="edit-button-context-menu-all-occurrences"
+ oncommand="onEditAllOccurrences()"
+ />
+ </menupopup>
+ </button>
+ </hbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js
new file mode 100644
index 0000000000..f226951fd1
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+window.addEventListener("DOMContentLoaded", onLoad, { once: true });
+function onLoad() {
+ let { calendarName, originalURI, targetURI } = window.arguments[0];
+
+ document.l10n.setAttributes(
+ document.getElementById("calendar-uri-redirect-description"),
+ "calendar-uri-redirect-description",
+ { calendarName }
+ );
+
+ document.getElementById("originalURI").textContent = originalURI;
+ document.getElementById("targetURI").textContent = targetURI;
+ window.sizeToContent();
+}
+
+document.addEventListener("dialogaccept", () => {
+ window.arguments[0].returnValue = true;
+});
+
+document.addEventListener("dialogcancel", () => {
+ window.arguments[0].returnValue = false;
+});
diff --git a/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml
new file mode 100644
index 0000000000..5ee74ae88e
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-uri-redirect-dialog.xhtml
@@ -0,0 +1,46 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?>
+<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?>
+
+<html
+ id="calendar-uri-redirect-dialog"
+ xmlns="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ width="600"
+ scrolling="false"
+>
+ <head>
+ <title data-l10n-id="calendar-uri-redirect-window-title"></title>
+ <link rel="localization" href="calendar/calendar-uri-redirect-dialog.ftl" />
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-uri-redirect-dialog.js"></script>
+ </head>
+ <body>
+ <xul:dialog buttons="accept,cancel">
+ <p
+ id="calendar-uri-redirect-description"
+ data-l10n-id="calendar-uri-redirect-description"
+ data-l10n-args='{"calendarName": ""}'
+ ></p>
+
+ <p>
+ <span data-l10n-id="calendar-uri-redirect-original-uri-label"></span>
+ <br />
+ <span id="originalURI"></span>
+ </p>
+
+ <p>
+ <span data-l10n-id="calendar-uri-redirect-target-uri-label"></span>
+ <br />
+ <span id="targetURI"></span>
+ </p>
+ </xul:dialog>
+ </body>
+</html>
diff --git a/comm/calendar/base/content/dialogs/chooseCalendarDialog.js b/comm/calendar/base/content/dialogs/chooseCalendarDialog.js
new file mode 100644
index 0000000000..47532df8ea
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/chooseCalendarDialog.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported loadCalendars */
+
+/* import-globals-from ../calendar-ui-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function loadCalendars() {
+ const calendarManager = Cc["@mozilla.org/calendar/manager;1"].getService(Ci.calICalendarManager);
+ let listbox = document.getElementById("calendar-list");
+ let composite = cal.view.getCompositeCalendar(window.opener);
+ let selectedIndex = 0;
+ let calendars;
+
+ if (window.arguments[0].calendars) {
+ calendars = window.arguments[0].calendars;
+ } else {
+ calendars = calendarManager.getCalendars();
+ }
+ calendars = sortCalendarArray(calendars);
+
+ for (let i = 0; i < calendars.length; i++) {
+ let calendar = calendars[i];
+ let listItem = document.createXULElement("richlistitem");
+
+ let colorCell = document.createXULElement("box");
+ try {
+ colorCell.style.backgroundColor = calendar.getProperty("color") || "#a8c2e1";
+ } catch (e) {}
+ listItem.appendChild(colorCell);
+
+ let nameCell = document.createXULElement("label");
+ nameCell.setAttribute("value", calendar.name);
+ nameCell.setAttribute("flex", "1");
+ listItem.appendChild(nameCell);
+
+ listItem.calendar = calendar;
+ listbox.appendChild(listItem);
+
+ // Select the default calendar of the opening calendar window.
+ if (calendar.id == composite.defaultCalendar.id) {
+ selectedIndex = i;
+ }
+ }
+ document.getElementById("prompt").textContent = window.arguments[0].promptText;
+ if (window.arguments[0].promptNotify) {
+ document.getElementById("promptNotify").textContent = window.arguments[0].promptNotify;
+ }
+
+ // this button is the default action
+ let dialog = document.querySelector("dialog");
+ let accept = dialog.getButton("accept");
+ if (window.arguments[0].labelOk) {
+ accept.setAttribute("label", window.arguments[0].labelOk);
+ accept.removeAttribute("hidden");
+ }
+
+ let extra1 = dialog.getButton("extra1");
+ if (window.arguments[0].labelExtra1) {
+ extra1.setAttribute("label", window.arguments[0].labelExtra1);
+ extra1.removeAttribute("hidden");
+ } else {
+ extra1.setAttribute("hidden", "true");
+ }
+
+ if (calendars.length) {
+ listbox.ensureIndexIsVisible(selectedIndex);
+ listbox.timedSelect(listbox.getItemAtIndex(selectedIndex), 0);
+ } else {
+ // If there are no calendars, then disable the accept button
+ accept.setAttribute("disabled", "true");
+ }
+
+ window.sizeToContent();
+}
+
+document.addEventListener("dialogaccept", () => {
+ let listbox = document.getElementById("calendar-list");
+ window.arguments[0].onOk(listbox.selectedItem.calendar);
+});
+
+document.addEventListener("dialogextra1", () => {
+ let listbox = document.getElementById("calendar-list");
+ window.arguments[0].onExtra1(listbox.selectedItem.calendar);
+ window.close();
+});
diff --git a/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml b/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml
new file mode 100644
index 0000000000..6f6932d966
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/chooseCalendarDialog.xhtml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/chooseCalendarDialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!-- DTD File with all strings specific to the file -->
+<!DOCTYPE window SYSTEM "chrome://calendar/locale/calendar.dtd">
+
+<window
+ id="chooseCalendar"
+ title="&calendar.select.dialog.title;"
+ windowtype="Calendar:CalendarPicker"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="setTimeout(loadCalendars, 0);"
+ lightweightthemes="true"
+ persist="screenX screenY height width"
+>
+ <dialog buttons="accept,cancel">
+ <script src="chrome://calendar/content/calendar-ui-utils.js" />
+ <script src="chrome://calendar/content/chooseCalendarDialog.js" />
+ <script src="chrome://messenger/content/dialogShadowDom.js" />
+
+ <vbox id="dialog-box" flex="1">
+ <label id="prompt" control="calendar-list" />
+ <richlistbox id="calendar-list" flex="1" seltype="single" />
+ <description id="promptNotify" />
+ </vbox>
+ </dialog>
+</window>
diff --git a/comm/calendar/base/content/dialogs/publishDialog.js b/comm/calendar/base/content/dialogs/publishDialog.js
new file mode 100644
index 0000000000..3488220301
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/publishDialog.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gOnOkFunction; // function to be called when user clicks OK
+var gPublishObject;
+
+window.addEventListener("DOMContentLoaded", loadCalendarPublishDialog);
+
+/**
+ * Called when the dialog is loaded.
+ */
+function loadCalendarPublishDialog() {
+ let args = window.arguments[0];
+
+ gOnOkFunction = args.onOk;
+
+ if (args.publishObject) {
+ gPublishObject = args.publishObject;
+ if (
+ args.publishObject.remotePath &&
+ /^(https?|webcals?):\/\//.test(args.publishObject.remotePath)
+ ) {
+ document.getElementById("publish-remotePath-textbox").value = args.publishObject.remotePath;
+ }
+ } else {
+ gPublishObject = {};
+ }
+
+ checkURLField();
+
+ let firstFocus = document.getElementById("publish-remotePath-textbox");
+ firstFocus.focus();
+}
+
+/**
+ * Called when the OK button is clicked.
+ */
+function onOKCommand(event) {
+ gPublishObject.remotePath = document
+ .getElementById("publish-remotePath-textbox")
+ .value.replace(/^webcal/, "http");
+
+ // call caller's on OK function
+ gOnOkFunction(gPublishObject, progressDialog);
+ let dialog = document.querySelector("dialog");
+ dialog.getButton("accept").setAttribute("label", dialog.getAttribute("buttonlabelaccept2"));
+ event.preventDefault();
+}
+document.addEventListener("dialogaccept", onOKCommand, { once: true });
+
+function checkURLField() {
+ document.querySelector("dialog").getButton("accept").disabled = !document.getElementById(
+ "publish-remotePath-textbox"
+ ).validity.valid;
+}
+
+var progressDialog = {
+ onStartUpload() {
+ document.getElementById("publish-progressmeter").setAttribute("value", "0");
+ document.querySelector("dialog").getButton("cancel").hidden = true;
+ },
+
+ onStopUpload(percentage) {
+ document.getElementById("publish-progressmeter").setAttribute("value", percentage);
+ },
+};
+progressDialog.wrappedJSObject = progressDialog;
diff --git a/comm/calendar/base/content/dialogs/publishDialog.xhtml b/comm/calendar/base/content/dialogs/publishDialog.xhtml
new file mode 100644
index 0000000000..ec6ceef7d4
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/publishDialog.xhtml
@@ -0,0 +1,51 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/colors.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/publishDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd"> %dtd1;
+<!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd">
+%dtd2; ]>
+<html
+ id="calendar-publishwindow"
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ windowtype="Calendar:PublishDialog"
+ persist="screenX screenY"
+ lightweightthemes="true"
+ scrolling="false"
+>
+ <head>
+ <title>&calendar.publish.dialog.title;</title>
+ <link rel="localization" href="branding/brand.ftl" />
+ <script defer="defer" src="chrome://messenger/content/dialogShadowDom.js"></script>
+ <script defer="defer" src="chrome://calendar/content/publishDialog.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog
+ buttons="accept,cancel"
+ buttonlabelaccept="&calendar.publish.publish.button;"
+ buttonlabelaccept2="&calendar.publish.close.button;"
+ >
+ <html:div>
+ <html:label for="publish-remotePath-textbox">&calendar.publish.url.label;</html:label>
+ <html:input
+ id="publish-remotePath-textbox"
+ type="url"
+ pattern="(https?|webcals?)://.*"
+ size="64"
+ required="required"
+ placeholder="https://www.example.com/webdav/calendar.ics"
+ oninput="checkURLField()"
+ />
+ </html:div>
+ <html:progress id="publish-progressmeter" value="0" max="100"></html:progress>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/imip-bar-overlay.inc.xhtml b/comm/calendar/base/content/imip-bar-overlay.inc.xhtml
new file mode 100644
index 0000000000..06ac71a183
--- /dev/null
+++ b/comm/calendar/base/content/imip-bar-overlay.inc.xhtml
@@ -0,0 +1,296 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file requires the following localization files:
+# chrome://lightning/locale/lightning.dtd
+
+ <hbox id="imip-bar"
+ class="calendar-notification-bar"
+ collapsed="true"
+ label="&lightning.imipbar.description;"
+ align="center">
+ <html:img src="chrome://messenger/skin/icons/new/normal/calendar-invite.svg"
+ alt="" />
+ <description class="msgNotificationBarText"
+ flex="1">
+ &lightning.imipbar.description;
+ </description>
+
+ <!-- Some Toolbox implementation notes:
+ -
+ - css style:
+ - classes within toolbox are making use of existing TB css definitions - as used in
+ - /comm-central/source/mail/base/content/msgHdrView.inc, only icon defining
+ - classes like imipAcceptButton are noted separately and OS specific within
+ - skin/calendar.css (resp. the OS-specific theme folders)
+ -
+ - The toolbarbuttons will be adjusted dynamically in imip-bar.js based on their
+ - content of menuitems. To avoid breaking this, the following should be considered
+ - if adding/changing toolbarbutton definitions.
+ - general:
+ - * the toolbarbuttons will appear in order of definition
+ - within the toolbar if visible
+ - * must be hidden by default
+ - * menuitem inside must not be hidden by default
+ - simple button:
+ - * must not have a type attribute
+ - * may have menupopup/menuitem within (not displayed though)
+ - dropdown only:
+ - * must have type=menu
+ - * should have a menupopup with at least one menuitem
+ - smart-dropdown (toolbarbutton-menu-button)
+ - * must have type=menu
+ - * should have a menupopup with at least one menuitem
+ //-->
+ <vbox id="imip-view-toolbox" class="inline-toolbox">
+ <hbox id="imip-view-toolbar" class="themeable-brighttext">
+
+ <!-- show event/invitation details -->
+ <toolbarbutton id="imipDetailsButton"
+ label="&lightning.imipbar.btnDetails.label;"
+ tooltiptext="&lightning.imipbar.btnDetails.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipDetailsButton"
+ oncommand="calImipBar.executeAction('X-SHOWDETAILS')"
+ hidden="true"/>
+
+ <!-- decline counter -->
+ <toolbarbutton id="imipDeclineCounterButton"
+ label="&lightning.imipbar.btnDeclineCounter.label;"
+ tooltiptext="&lightning.imipbar.btnDeclineCounter.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipDeclineCounterButton"
+ oncommand="calImipBar.executeAction('X-DECLINECOUNTER')"
+ hidden="true"/>
+
+ <!-- reschedule -->
+ <toolbarbutton id="imipRescheduleButton"
+ label="&lightning.imipbar.btnReschedule.label;"
+ tooltiptext="&lightning.imipbar.btnReschedule.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipRescheduleButton"
+ oncommand="calImipBar.executeAction('X-RESCHEDULE')"
+ hidden="true"/>
+
+ <!-- add published events -->
+ <toolbarbutton id="imipAddButton"
+ label="&lightning.imipbar.btnAdd.label;"
+ tooltiptext="&lightning.imipbar.btnAdd.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipAddButton"
+ oncommand="calImipBar.executeAction()"
+ hidden="true"/>
+
+ <!-- update published events and invitations -->
+ <toolbarbutton id="imipUpdateButton"
+ label="&lightning.imipbar.btnUpdate.label;"
+ tooltiptext="&lightning.imipbar.btnUpdate.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipUpdateButton"
+ oncommand="calImipBar.executeAction()"
+ hidden="true"/>
+
+ <!-- delete cancelled events from calendar -->
+ <toolbarbutton id="imipDeleteButton"
+ label="&lightning.imipbar.btnDelete.label;"
+ tooltiptext="&lightning.imipbar.btnDelete.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipDeleteButton"
+ oncommand="calImipBar.executeAction()"
+ hidden="true"/>
+
+ <!-- re-confirm partstat -->
+ <toolbarbutton id="imipReconfirmButton"
+ label="&lightning.imipbar.btnReconfirm2.label;"
+ tooltiptext="&lightning.imipbar.btnReconfirm.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipReconfirmButton"
+ oncommand="calImipBar.executeAction()"
+ hidden="true"/>
+
+ <!-- go to calendar tab -->
+ <toolbarbutton id="imipGoToCalendarButton"
+ label="&lightning.imipbar.btnGoToCalendar.label;"
+ tooltiptext="&lightning.imipbar.btnGoToCalendar.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipGoToCalendarButton"
+ oncommand="cal.window.goToCalendar();"
+ hidden="true"/>
+
+ <!-- accept -->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipAcceptButton"
+ tooltiptext="&lightning.imipbar.btnAccept2.tooltiptext;"
+ label="&lightning.imipbar.btnAccept.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO');"
+ type="menu"
+ class="imip-button toolbarbutton-1 message-header-view-button imipAcceptButton"
+ hidden="true">
+ <menupopup id="imipAcceptDropdown">
+ <label id="imipAcceptButton_AcceptLabel"
+ class="imipAcceptLabel"
+ tooltiptext="&lightning.imipbar.btnAccept2.tooltiptext;"
+ value="&lightning.imipbar.btnAccept.label;"/>
+ <menuitem id="imipAcceptButton_Accept"
+ tooltiptext="&lightning.imipbar.btnSend.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipAcceptButton_AcceptDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'NONE'); event.stopPropagation();"/>
+ <separatpor flex="1" class="groove"/>
+ <label id="imipAcceptButton_TentativeLabel"
+ class="imipAcceptLabel"
+ tooltiptext="&lightning.imipbar.btnTentative2.tooltiptext;"
+ value="&lightning.imipbar.btnTentative.label;"/>
+ <menuitem id="imipAcceptButton_Tentative"
+ tooltiptext="&lightning.imipbar.btnSend.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipAcceptButton_TentativeDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- accept recurrences -->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipAcceptRecurrencesButton"
+ tooltiptext="&lightning.imipbar.btnAcceptRecurrences2.tooltiptext;"
+ label="&lightning.imipbar.btnAcceptRecurrences.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO');"
+ type="menu"
+ class="imip-button toolbarbutton-1 message-header-view-button imipAcceptRecurrencesButton"
+ hidden="true">
+ <menupopup id="imipAcceptRecurrencesDropdown">
+ <label id="imipAcceptRecurrencesButton_AcceptLabel"
+ class="imipAcceptLabel"
+ tooltiptext="&lightning.imipbar.btnAcceptRecurrences2.tooltiptext;"
+ value="&lightning.imipbar.btnAcceptRecurrences.label;"/>
+ <menuitem id="imipAcceptRecurrencesButton_Accept"
+ tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipAcceptRecurrencesButton_AcceptDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('ACCEPTED', 'NONE'); event.stopPropagation();"/>
+ <separatpor flex="1" class="groove"/>
+ <label id="imipAcceptRecurrencesButton_TentativeLabel"
+ class="imipAcceptLabel"
+ tooltiptext="&lightning.imipbar.btnTentativeRecurrences2.tooltiptext;"
+ value="&lightning.imipbar.btnTentativeRecurrences.label;"/>
+ <menuitem id="imipAcceptRecurrencesButton_Tentative"
+ tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipAcceptRecurrencesButton_TentativeDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- tentative; should only be used, if no imipMoreButton is used and
+ - imipDeclineButton/imipAcceptButton have no visible menuitems //-->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipTentativeButton"
+ label="&lightning.imipbar.btnTentative.label;"
+ tooltiptext="&lightning.imipbar.btnTentative2.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipTentativeButton"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO');"
+ type="menu"
+ hidden="true">
+ <menupopup id="imipTentativeDropdown">
+ <menuitem id="imipTentativeButton_Tentative"
+ tooltiptext="&lightning.imipbar.btnSend.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipTentativeButton_TentativeDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- tentative recurrences; should only be used, if no imipMoreButton is used and
+ - imipDeclineRecurrencesButton/imipAcceptRecurrencesButton have no visible menuitems //-->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipTentativeRecurrencesButton"
+ label="&lightning.imipbar.btnTentativeRecurrences.label;"
+ tooltiptext="&lightning.imipbar.btnTentativeRecurrences2.tooltiptext;"
+ class="toolbarbutton-1 message-header-view-button imipTentativeRecurrencesButton"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO');"
+ type="menu"
+ hidden="true">
+ <menupopup id="imipTentativeRecurrencesDropdown">
+ <menuitem id="imipTentativeRecurrencesButton_Tentative"
+ tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipTentativeRecurrencesButton_TentativeDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('TENTATIVE', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- decline -->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipDeclineButton"
+ tooltiptext="&lightning.imipbar.btnDecline2.tooltiptext;"
+ label="&lightning.imipbar.btnDecline.label;"
+ oncommand="calImipBar.executeAction('DECLINED', 'AUTO');"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button imipDeclineButton"
+ hidden="true">
+ <menupopup id="imipDeclineDropdown">
+ <menuitem id="imipDeclineButton_Decline"
+ tooltiptext="&lightning.imipbar.btnSend.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('DECLINED', 'AUTO'); event.stopPropagation();"/>
+ <menuitem id="imipDeclineButton_DeclineDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSend.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('DECLINED', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- decline recurrences -->
+ <toolbarbutton is="toolbarbutton-menu-button" id="imipDeclineRecurrencesButton"
+ tooltiptext="&lightning.imipbar.btnDeclineRecurrences2.tooltiptext;"
+ label="&lightning.imipbar.btnDeclineRecurrences.label;"
+ oncommand="calImipBar.executeAction('DECLINED', 'AUTO');"
+ type="menu"
+ class="toolbarbutton-1 message-header-view-button imipDeclineRecurrencesButton"
+ hidden="true">
+ <menupopup id="imipDeclineRecurrencesDropdown">
+ <menuitem id="imipDeclineRecurrencesButton_DeclineAll"
+ tooltiptext="&lightning.imipbar.btnSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnSend.label;"
+ oncommand="calImipBar.executeAction('DECLINED'); event.stopPropagation();"/>
+ <menuitem id="imipDeclineRecurrencesButton_DeclineDontSend"
+ tooltiptext="&lightning.imipbar.btnDontSendSeries.tooltiptext;"
+ label="&lightning.imipbar.btnDontSend.label;"
+ oncommand="calImipBar.executeAction('DECLINED', 'NONE'); event.stopPropagation();"/>
+ <!-- add here more menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+
+ <!-- more options -->
+ <toolbarbutton id="imipMoreButton"
+ type="menu"
+ wantdropmarker="true"
+ tooltiptext="&lightning.imipbar.btnMore.tooltiptext;"
+ label="&lightning.imipbar.btnMore.label;"
+ class="toolbarbutton-1 message-header-view-button imipMoreButton"
+ hidden="true">
+ <menupopup id="imipMoreDropdown">
+ <menuitem id="imipMoreButton_SaveCopy"
+ tooltiptext="&lightning.imipbar.btnSaveCopy.tooltiptext;"
+ label="&lightning.imipbar.btnSaveCopy.label;"
+ oncommand="calImipBar.executeAction('X-SAVECOPY'); event.stopPropagation();"/>
+ <menuitem id="imipMoreButton_DoNotShowImipBar"
+ label="&lightning.imipbar.btnDoNotShowImipBar.label;"
+ oncommand="calImipBar.doNotShowImipBar();"/>
+ <!-- add here a menuitem as needed -->
+ </menupopup>
+ </toolbarbutton>
+ </hbox>
+ </vbox>
+ </hbox>
diff --git a/comm/calendar/base/content/imip-bar.js b/comm/calendar/base/content/imip-bar.js
new file mode 100644
index 0000000000..f40e4cce22
--- /dev/null
+++ b/comm/calendar/base/content/imip-bar.js
@@ -0,0 +1,429 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../../mail/base/content/msgHdrView.js */
+/* import-globals-from item-editing/calendar-item-editing.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Provides shortcuts to set label and collapsed attribute of imip-bar node.
+ */
+const imipBar = {
+ get bar() {
+ return document.querySelector(".calendar-notification-bar");
+ },
+ get label() {
+ return this.bar.querySelector(".msgNotificationBarText").textContent;
+ },
+ set label(val) {
+ this.bar.querySelector(".msgNotificationBarText").textContent = val;
+ },
+ get collapsed() {
+ return this.bar.collapsed;
+ },
+ set collapsed(val) {
+ this.bar.collapsed = val;
+ },
+};
+
+/**
+ * This bar lives inside the message window.
+ * Its lifetime is the lifetime of the main thunderbird message window.
+ */
+var calImipBar = {
+ actionFunc: null,
+ itipItem: null,
+ foundItems: null,
+ loadingItipItem: null,
+
+ /**
+ * Thunderbird Message listener interface, hide the bar before we begin
+ */
+ onStartHeaders() {
+ calImipBar.resetBar();
+ },
+
+ /**
+ * Thunderbird Message listener interface
+ */
+ onEndHeaders() {},
+
+ /**
+ * Load Handler called to initialize the imip bar
+ * NOTE: This function is called without a valid this-context!
+ */
+ load() {
+ // Add a listener to gMessageListeners defined in msgHdrView.js
+ gMessageListeners.push(calImipBar);
+
+ // Hook into this event to hide the message header pane otherwise, the imip
+ // bar will still be shown when changing folders.
+ document.getElementById("msgHeaderView").addEventListener("message-header-pane-hidden", () => {
+ calImipBar.resetBar();
+ });
+
+ // Set up our observers
+ Services.obs.addObserver(calImipBar, "onItipItemCreation");
+ },
+
+ /**
+ * Unload handler to clean up after the imip bar
+ * NOTE: This function is called without a valid this-context!
+ */
+ unload() {
+ removeEventListener("messagepane-loaded", calImipBar.load, true);
+ removeEventListener("messagepane-unloaded", calImipBar.unload, true);
+
+ calImipBar.resetBar();
+ Services.obs.removeObserver(calImipBar, "onItipItemCreation");
+ },
+
+ showImipBar(itipItem, imipMethod) {
+ if (!Services.prefs.getBoolPref("calendar.itip.showImipBar", true)) {
+ // Do not show the imip bar if the user has opted out of seeing it.
+ return;
+ }
+
+ // How we get here:
+ //
+ // 1. `mime_find_class` finds the `CalMimeConverter` class matches the
+ // content-type of an attachment.
+ // 2. `mime_find_class` extracts the method from the attachments headers
+ // and sets `imipMethod` on the message's mail channel.
+ // 3. `CalMimeConverter` is called to generate the HTML in the message.
+ // It initialises `itipItem` and sets it on the channel.
+ // 4. msgHdrView.js gathers `itipItem` and `imipMethod` from the channel.
+
+ cal.itip.initItemFromMsgData(itipItem, imipMethod, gMessage);
+
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ window.dispatchEvent(new CustomEvent("onItipItemCreation", { detail: itipItem }));
+ }
+
+ imipBar.collapsed = false;
+ imipBar.label = cal.itip.getMethodText(itipItem.receivedMethod);
+
+ // This is triggered by CalMimeConverter.convertToHTML, so we know that
+ // the message is not yet loaded with the invite. Keep track of this for
+ // displayModifications.
+ calImipBar.overlayLoaded = false;
+
+ if (!Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ calImipBar.overlayLoaded = true;
+
+ let doc = document.getElementById("messagepane").contentDocument;
+ let details = doc.getElementById("imipHTMLDetails");
+ let msgbody = doc.querySelector("div.moz-text-html");
+ if (!msgbody) {
+ details.setAttribute("open", "open");
+ } else {
+ // The HTML representation can contain important notes.
+
+ // For consistent appearance, move the generated meeting details first.
+ msgbody.prepend(details);
+
+ if (Services.prefs.getBoolPref("calendar.itip.imipDetailsOpen", true)) {
+ // Expand the iMIP details if pref says so.
+ details.setAttribute("open", "open");
+ }
+ }
+ }
+ // NOTE: processItipItem may call setupOptions asynchronously because the
+ // getItem method it triggers is async for *some* calendars. In theory,
+ // this could complete after a different item has been loaded, so we
+ // record the loading item now, and early exit setupOptions if the loading
+ // item has since changed.
+ // NOTE: loadingItipItem is reset on changing messages in resetBar.
+ calImipBar.loadingItipItem = itipItem;
+ cal.itip.processItipItem(itipItem, calImipBar.setupOptions);
+
+ // NOTE: At this point we essentially have two parallel async operations:
+ // 1. Load the CalMimeConverter.convertToHTML into the #messagepane and
+ // then set overlayLoaded to true.
+ // 2. Find a corresponding event through processItipItem and then call
+ // setupOptions. Note that processItipItem may be instantaneous for
+ // some calendars.
+ //
+ // In the mean time, if we switch messages, then loadingItipItem will be
+ // set to some other value: either another item, or null by resetBar.
+ //
+ // Once setupOptions is called, if the message has since changed we do
+ // nothing and exit. Otherwise, if we found a corresponding item in the
+ // calendar, we proceed to displayModifications. If overlayLoaded is true
+ // we update the #messagepane immediately, otherwise we update it on
+ // DOMContentLoaded, which has not yet happened.
+ },
+
+ /**
+ * Hide the imip bar and reset the itip item.
+ */
+ resetBar() {
+ imipBar.collapsed = true;
+ calImipBar.resetButtons();
+
+ // Clear our iMIP/iTIP stuff so it doesn't contain stale information.
+ cal.itip.cleanupItipItem(calImipBar.itipItem);
+ calImipBar.itipItem = null;
+ calImipBar.loadingItipItem = null;
+ },
+
+ /**
+ * Resets all buttons and its menuitems, all buttons are hidden thereafter
+ */
+ resetButtons() {
+ let buttons = calImipBar.getButtons();
+ for (let button of buttons) {
+ button.setAttribute("hidden", "true");
+ for (let item of calImipBar.getMenuItems(button)) {
+ item.removeAttribute("hidden");
+ }
+ }
+ },
+
+ /**
+ * Provides a list of all available buttons
+ */
+ getButtons() {
+ let toolbarbuttons = document
+ .getElementById("imip-view-toolbar")
+ .getElementsByTagName("toolbarbutton");
+ return Array.from(toolbarbuttons);
+ },
+
+ /**
+ * Provides a list of available menuitems of a button
+ *
+ * @param aButton button node
+ */
+ getMenuItems(aButton) {
+ let items = [];
+ let mitems = aButton.getElementsByTagName("menuitem");
+ if (mitems != null && mitems.length > 0) {
+ for (let mitem of mitems) {
+ items.push(mitem);
+ }
+ }
+ return items;
+ },
+
+ /**
+ * Checks and converts button types based on available menuitems of the buttons
+ * to avoid dropdowns which are empty or only replicating the default button action
+ * Should be called once the buttons are set up
+ */
+ conformButtonType() {
+ // check only needed on visible and not simple buttons
+ let buttons = calImipBar
+ .getButtons()
+ .filter(aElement => aElement.hasAttribute("type") && !aElement.hidden);
+ // change button if appropriate
+ for (let button of buttons) {
+ let items = calImipBar.getMenuItems(button).filter(aItem => !aItem.hidden);
+ if (button.type == "menu" && items.length == 0) {
+ // hide non functional buttons
+ button.hidden = true;
+ } else if (button.type == "menu") {
+ if (
+ items.length == 0 ||
+ (items.length == 1 &&
+ button.hasAttribute("oncommand") &&
+ items[0].hasAttribute("oncommand") &&
+ button.getAttribute("oncommand").endsWith(items[0].getAttribute("oncommand")))
+ ) {
+ // convert to simple button
+ button.removeAttribute("type");
+ }
+ }
+ }
+ },
+
+ /**
+ * This is our callback function that is called each time the itip bar UI needs updating.
+ * NOTE: This function is called without a valid this-context!
+ *
+ * @param itipItem The iTIP item to set up for
+ * @param rc The status code from processing
+ * @param actionFunc The action function called for execution
+ * @param foundItems An array of items found while searching for the item
+ * in subscribed calendars
+ */
+ setupOptions(itipItem, rc, actionFunc, foundItems) {
+ if (itipItem !== calImipBar.loadingItipItem) {
+ // The given itipItem refers to an earlier displayed message.
+ return;
+ }
+
+ let data = cal.itip.getOptionsText(itipItem, rc, actionFunc, foundItems);
+
+ if (Components.isSuccessCode(rc)) {
+ calImipBar.itipItem = itipItem;
+ calImipBar.actionFunc = actionFunc;
+ calImipBar.foundItems = foundItems;
+ }
+
+ // We need this to determine whether this is an outgoing or incoming message because
+ // Thunderbird doesn't provide a distinct flag on message level to do so. Relying on
+ // folder flags only may lead to false positives.
+ let isOutgoing = function (aMsgHdr) {
+ if (!aMsgHdr) {
+ return false;
+ }
+ let author = aMsgHdr.mime2DecodedAuthor;
+ let isSentFolder = aMsgHdr.folder && aMsgHdr.folder.flags & Ci.nsMsgFolderFlags.SentMail;
+ if (author && isSentFolder) {
+ for (let identity of MailServices.accounts.allIdentities) {
+ if (author.includes(identity.email) && !identity.fccReplyFollowsParent) {
+ return true;
+ }
+ }
+ }
+ return false;
+ };
+
+ // We override the bar label for sent out invitations and in case the event does not exist
+ // anymore, we also clear the buttons if any to avoid e.g. accept/decline buttons
+ if (isOutgoing(gMessage)) {
+ if (calImipBar.foundItems && calImipBar.foundItems[0]) {
+ data.label = cal.l10n.getLtnString("imipBarSentText");
+ } else {
+ data = {
+ label: cal.l10n.getLtnString("imipBarSentButRemovedText"),
+ buttons: [],
+ hideMenuItems: [],
+ hideItems: [],
+ showItems: [],
+ };
+ }
+ }
+
+ imipBar.label = data.label;
+ // let's reset all buttons first
+ calImipBar.resetButtons();
+ // now we update the visible items - buttons are hidden by default
+ // apart from that, we need this to adapt the accept button depending on
+ // whether three or four button style is present
+ for (let item of data.hideItems) {
+ document.getElementById(item).setAttribute("hidden", "true");
+ }
+ for (let item of data.showItems) {
+ document.getElementById(item).removeAttribute("hidden");
+ }
+ // adjust button style if necessary
+ calImipBar.conformButtonType();
+
+ calImipBar.displayModifications();
+ },
+
+ /**
+ * Displays changes in case of invitation updates in invitation overlay.
+ *
+ * NOTE: This should only be called if the invitation is already loaded in the
+ * #messagepane, in which case calImipBar.overlayLoaded should be set to true,
+ * or is guaranteed to be loaded next in #messagepane.
+ */
+ displayModifications() {
+ if (
+ !calImipBar.foundItems ||
+ !calImipBar.foundItems[0] ||
+ !calImipBar.itipItem ||
+ !Services.prefs.getBoolPref("calendar.itip.displayInvitationChanges", false)
+ ) {
+ return;
+ }
+
+ let itipItem = calImipBar.itipItem;
+ let foundEvent = calImipBar.foundItems[0];
+ let currentEvent = itipItem.getItemList()[0];
+ let diff = cal.itip.compare(currentEvent, foundEvent);
+ if (diff != 0) {
+ let newEvent;
+ let oldEvent;
+
+ if (diff == 1) {
+ // This is an update to previously accepted invitation.
+ oldEvent = foundEvent;
+ newEvent = currentEvent;
+ } else {
+ // This is a copy of a previously sent out invitation or a previous
+ // revision of a meanwhile accepted invitation, so we flip the order.
+ oldEvent = currentEvent;
+ newEvent = foundEvent;
+ }
+
+ let browser = document.getElementById("messagepane");
+ let doUpdate = () => {
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ return;
+ }
+ cal.invitation.updateInvitationOverlay(
+ browser.contentDocument,
+ newEvent,
+ itipItem,
+ oldEvent
+ );
+ };
+ if (calImipBar.overlayLoaded) {
+ // Document is already loaded.
+ doUpdate();
+ } else {
+ // The event is not yet shown. This can happen if setupOptions is called
+ // before CalMimeConverter.convertToHTML has finished, or the
+ // corresponding HTML string has not yet been loaded.
+ // Wait until the event is shown, then immediately update it.
+ browser.addEventListener("DOMContentLoaded", doUpdate, { once: true });
+ }
+ }
+ },
+
+ /**
+ * Executes an action triggered by an imip bar button
+ *
+ * @param {string} aParticipantStatus A partstat string as per RfC 5545
+ * @param {string} aResponse Either 'AUTO', 'NONE' or 'USER',
+ * see calItipItem interface
+ * @returns {boolean} true, if the action succeeded
+ */
+ executeAction(aParticipantStatus, aResponse) {
+ return cal.itip.executeAction(
+ window,
+ aParticipantStatus,
+ aResponse,
+ calImipBar.actionFunc,
+ calImipBar.itipItem,
+ calImipBar.foundItems,
+ ({ resetButtons, label }) => {
+ if (label != undefined) {
+ calImipBar.label = label;
+ }
+ if (resetButtons) {
+ calImipBar.resetButtons();
+ }
+ }
+ );
+ },
+
+ /**
+ * Hide the imip bar in all windows and set a pref to prevent it from being
+ * shown again. Called when clicking the imip bar's "do not show..." menu item.
+ */
+ doNotShowImipBar() {
+ Services.prefs.setBoolPref("calendar.itip.showImipBar", false);
+ for (let window of Services.ww.getWindowEnumerator()) {
+ if (window.calImipBar) {
+ window.calImipBar.resetBar();
+ }
+ }
+ },
+};
+
+{
+ let msgHeaderView = document.getElementById("msgHeaderView");
+ if (msgHeaderView && msgHeaderView.loaded) {
+ calImipBar.load();
+ } else {
+ addEventListener("messagepane-loaded", calImipBar.load, true);
+ }
+}
+addEventListener("messagepane-unloaded", calImipBar.unload, true);
diff --git a/comm/calendar/base/content/import-export.js b/comm/calendar/base/content/import-export.js
new file mode 100644
index 0000000000..1ee8f59ae7
--- /dev/null
+++ b/comm/calendar/base/content/import-export.js
@@ -0,0 +1,330 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from item-editing/calendar-item-editing.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/* exported loadEventsFromFile, exportEntireCalendar */
+
+// File constants copied from file-utils.js
+var MODE_RDONLY = 0x01;
+var MODE_WRONLY = 0x02;
+var MODE_CREATE = 0x08;
+var MODE_TRUNCATE = 0x20;
+
+/**
+ * Loads events from a file into a calendar. If called without a file argument,
+ * the user is asked to pick a file.
+ *
+ * @param {nsIFile} [fileArg] - Optional, a file to load events from.
+ * @returns {Promise<boolean>} True if the import dialog was opened, false if
+ * not (e.g. on cancel of file picker dialog).
+ */
+async function loadEventsFromFile(fileArg) {
+ let file = fileArg;
+ if (!file) {
+ file = await pickFileToImport();
+ if (!file) {
+ // Probably the user clicked "cancel" (no file and the promise was not
+ // rejected in pickFileToImport).
+ return false;
+ }
+ }
+
+ Services.ww.openWindow(
+ null,
+ "chrome://calendar/content/calendar-ics-file-dialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,centerscreen",
+ file
+ );
+ return true;
+}
+
+/**
+ * Show a file picker dialog and return the file.
+ *
+ * @returns {Promise<nsIFile | undefined>} The picked file or undefined if the
+ * user cancels the dialog.
+ */
+function pickFileToImport() {
+ return new Promise(resolve => {
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ picker.init(window, cal.l10n.getCalString("filepickerTitleImport"), Ci.nsIFilePicker.modeOpen);
+ picker.defaultExtension = "ics";
+
+ let currentListLength = 0;
+ for (let { data } of Services.catMan.enumerateCategory("cal-importers")) {
+ let contractId = Services.catMan.getCategoryEntry("cal-importers", data);
+ let importer;
+ try {
+ importer = Cc[contractId].getService(Ci.calIImporter);
+ } catch (e) {
+ cal.WARN("Could not initialize importer: " + contractId + "\nError: " + e);
+ continue;
+ }
+ let types = importer.getFileTypes();
+ for (let type of types) {
+ picker.appendFilter(type.description, type.extensionFilter);
+ if (type.extensionFilter == "*." + picker.defaultExtension) {
+ picker.filterIndex = currentListLength;
+ }
+ currentListLength++;
+ }
+ }
+
+ picker.appendFilters(Ci.nsIFilePicker.filterAll);
+ picker.open(returnValue => {
+ if (returnValue == Ci.nsIFilePicker.returnCancel) {
+ resolve();
+ return;
+ }
+ resolve(picker.file);
+ });
+ });
+}
+
+/**
+ * Given an ICS file, return an array of calendar items parsed from it.
+ *
+ * @param {nsIFile} file - File to get items from.
+ * @returns {calIItemBase[]} Array of calendar items.
+ */
+function getItemsFromIcsFile(file) {
+ let importer = Cc["@mozilla.org/calendar/import;1?type=ics"].getService(Ci.calIImporter);
+
+ let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let items = [];
+ let exception;
+
+ try {
+ inputStream.init(file, MODE_RDONLY, 0o444, {});
+ items = importer.importFromStream(inputStream);
+ } catch (ex) {
+ exception = ex;
+ switch (ex.result) {
+ case Ci.calIErrors.INVALID_TIMEZONE:
+ cal.showError(cal.l10n.getCalString("timezoneError", [file.path]), window);
+ break;
+ default:
+ cal.showError(cal.l10n.getCalString("unableToRead") + file.path + "\n" + ex, window);
+ }
+ } finally {
+ inputStream.close();
+ }
+
+ if (!items.length && !exception) {
+ // The ics did not contain any events, so we should
+ // notify the user about it, if we haven't before.
+ cal.showError(cal.l10n.getCalString("noItemsInCalendarFile2", [file.path]), window);
+ }
+
+ return items;
+}
+
+/**
+ * @callback onProgress
+ * @param {number} count
+ * @param {number} total
+ */
+
+/**
+ * @callback onError
+ * @param {calIItemBase} item - The item which failed to import.
+ * @param {number | nsIException} error The error number from Components.results, or
+ * the exception which contains the error number.
+ */
+
+/**
+ * Listener for the stages of putItemsIntoCal().
+ *
+ * @typedef PutItemsIntoCalListener
+ * @property {Function} onStart
+ * @property {onError} onDuplicate
+ * @property {onError} onError
+ * @property {onProgress} onProgress
+ * @property {Function} onEnd
+ */
+
+/**
+ * Put items into a certain calendar, catching errors and showing them to the
+ * user.
+ *
+ * @param {calICalendar} destCal - The destination calendar.
+ * @param {calIItemBase[]} aItems - An array of items to put into the calendar.
+ * @param {string} aFilePath - The original file path, for error messages.
+ * @param {PutItemsIntoCalListener} [aListener] - Optional listener.
+ */
+async function putItemsIntoCal(destCal, aItems, aListener) {
+ async function callListener(method, ...args) {
+ if (aListener && typeof aListener[method] == "function") {
+ await aListener[method](...args);
+ }
+ }
+
+ await callListener("onStart");
+
+ // Set batch for the undo/redo transaction manager
+ startBatchTransaction();
+
+ let count = 0;
+ let total = aItems.length;
+
+ for (let item of aItems) {
+ try {
+ await destCal.addItem(item);
+ } catch (e) {
+ if (e == Ci.calIErrors.DUPLICATE_ID) {
+ await callListener("onDuplicate", item, e);
+ } else {
+ console.error(e);
+ await callListener("onError", item, e);
+ }
+ }
+
+ count++;
+ await callListener("onProgress", count, total);
+ }
+
+ // End transmgr batch
+ endBatchTransaction();
+
+ await callListener("onEnd");
+}
+
+/**
+ * Save data to a file. Create the file or overwrite an existing file.
+ *
+ * @param {calIEvent[]} calendarEventArray - Array of calendar events that should be saved to file.
+ * @param {string} [aDefaultFileName] - Initial filename shown in SaveAs dialog.
+ */
+function saveEventsToFile(calendarEventArray, aDefaultFileName) {
+ if (!calendarEventArray || !calendarEventArray.length) {
+ return;
+ }
+
+ // Show the 'Save As' dialog and ask for a filename to save to
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ picker.init(window, cal.l10n.getCalString("filepickerTitleExport"), Ci.nsIFilePicker.modeSave);
+
+ let filename;
+ if (aDefaultFileName && aDefaultFileName.length && aDefaultFileName.length > 0) {
+ filename = aDefaultFileName;
+ } else if (calendarEventArray.length == 1 && calendarEventArray[0].title) {
+ filename = calendarEventArray[0].title;
+ } else {
+ filename = cal.l10n.getCalString("defaultFileName");
+ }
+ // Remove characters usually illegal on the file system.
+ picker.defaultString = filename.replace(/[/\\?%*:|"<>]/g, "-");
+
+ picker.defaultExtension = "ics";
+
+ // Get a list of exporters
+ let contractids = [];
+ let currentListLength = 0;
+ let defaultCIDIndex = 0;
+ for (let { data } of Services.catMan.enumerateCategory("cal-exporters")) {
+ let contractid = Services.catMan.getCategoryEntry("cal-exporters", data);
+ let exporter;
+ try {
+ exporter = Cc[contractid].getService(Ci.calIExporter);
+ } catch (e) {
+ cal.WARN("Could not initialize exporter: " + contractid + "\nError: " + e);
+ continue;
+ }
+ let types = exporter.getFileTypes();
+ for (let type of types) {
+ picker.appendFilter(type.description, type.extensionFilter);
+ if (type.extensionFilter == "*." + picker.defaultExtension) {
+ picker.filterIndex = currentListLength;
+ defaultCIDIndex = currentListLength;
+ }
+ contractids.push(contractid);
+ currentListLength++;
+ }
+ }
+
+ // Now find out as what to save, convert the events and save to file.
+ picker.open(rv => {
+ if (rv == Ci.nsIFilePicker.returnCancel || !picker.file || !picker.file.path) {
+ return;
+ }
+
+ let filterIndex = picker.filterIndex;
+ if (picker.filterIndex < 0 || picker.filterIndex > contractids.length) {
+ // For some reason the wrong filter was selected, assume default extension
+ filterIndex = defaultCIDIndex;
+ }
+
+ let exporter = Cc[contractids[filterIndex]].getService(Ci.calIExporter);
+
+ let filePath = picker.file.path;
+ if (!filePath.includes(".")) {
+ filePath += "." + exporter.getFileTypes()[0].defaultExtension;
+ }
+
+ let outputStream;
+ let localFileInstance = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ localFileInstance.initWithPath(filePath);
+
+ outputStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ try {
+ outputStream.init(
+ localFileInstance,
+ MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE,
+ parseInt("0664", 8),
+ 0
+ );
+
+ // XXX Do the right thing with unicode and stuff. Or, again, should the
+ // exporter handle that?
+ exporter.exportToStream(outputStream, calendarEventArray, null);
+ outputStream.close();
+ } catch (ex) {
+ cal.showError(cal.l10n.getCalString("unableToWrite") + filePath, window);
+ }
+ });
+}
+
+/**
+ * Exports all the events and tasks in a calendar. If aCalendar is not specified,
+ * the user will be prompted with a list of calendars to choose which one to export.
+ *
+ * @param aCalendar (optional) A specific calendar to export
+ */
+function exportEntireCalendar(aCalendar) {
+ let getItemsFromCal = async function (aCal) {
+ let items = await aCal.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null);
+ saveEventsToFile(items, aCal.name);
+ };
+
+ if (aCalendar) {
+ getItemsFromCal(aCalendar);
+ } else {
+ let calendars = cal.manager.getCalendars();
+
+ if (calendars.length == 1) {
+ // There's only one calendar, so it's silly to ask what calendar
+ // the user wants to import into.
+ getItemsFromCal(calendars[0]);
+ } else {
+ // Ask what calendar to import into
+ let args = {};
+ args.onOk = getItemsFromCal;
+ args.promptText = cal.l10n.getCalString("exportPrompt");
+ openDialog(
+ "chrome://calendar/content/chooseCalendarDialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ }
+ }
+}
diff --git a/comm/calendar/base/content/item-editing/calendar-item-editing.js b/comm/calendar/base/content/item-editing/calendar-item-editing.js
new file mode 100644
index 0000000000..a280e62f48
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-editing.js
@@ -0,0 +1,849 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../calendar-management.js */
+/* import-globals-from ../calendar-views-utils.js */
+
+/* globals goUpdateCommand */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { CalTransactionManager } = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAddTransaction: "resource:///modules/CalTransactionManager.jsm",
+ CalDeleteTransaction: "resource:///modules/CalTransactionManager.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalModifyTransaction: "resource:///modules/CalTransactionManager.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+/* exported modifyEventWithDialog, undo, redo, setContextPartstat */
+
+/**
+ * The global calendar transaction manager.
+ *
+ * @type {CalTransactionManager}
+ */
+var gCalTransactionMgr = CalTransactionManager.getInstance();
+
+/**
+ * If a batch transaction is active, it is stored here.
+ *
+ * @type {CalBatchTransaction?}
+ */
+var gCalBatchTransaction = null;
+
+/**
+ * Sets the default values for new items, taking values from either the passed
+ * parameters or the preferences.
+ *
+ * @param {calIItemBase} aItem - The item to set up.
+ * @param {?calICalendar} aCalendar - The calendar to apply.
+ * @param {?calIDateTime} aStartDate - The start date to set.
+ * @param {?calIDateTime} aEndDate - The end date/due date to set.
+ * @param {?calIDateTime} aInitialDate - The reference date for the date pickers.
+ * @param {boolean} [aForceAllday=false] - Force the event/task to be an all-day item.
+ * @param {calIAttendee[]} aAttendees - Attendees to add, if `aItem` is an event.
+ */
+function setDefaultItemValues(
+ aItem,
+ aCalendar = null,
+ aStartDate = null,
+ aEndDate = null,
+ aInitialDate = null,
+ aForceAllday = false,
+ aAttendees = []
+) {
+ function endOfDay(aDate) {
+ let eod = aDate ? aDate.clone() : cal.dtz.now();
+ eod.hour = Services.prefs.getIntPref("calendar.view.dayendhour", 19);
+ eod.minute = 0;
+ eod.second = 0;
+ return eod;
+ }
+ function startOfDay(aDate) {
+ let sod = aDate ? aDate.clone() : cal.dtz.now();
+ sod.hour = Services.prefs.getIntPref("calendar.view.daystarthour", 8);
+ sod.minute = 0;
+ sod.second = 0;
+ return sod;
+ }
+
+ let initialDate = aInitialDate ? aInitialDate.clone() : cal.dtz.now();
+ initialDate.isDate = true;
+
+ if (aItem.isEvent()) {
+ if (aStartDate) {
+ aItem.startDate = aStartDate.clone();
+ if (aStartDate.isDate && !aForceAllday) {
+ // This is a special case where the date is specified, but the
+ // time is not. To take care, we setup up the time to our
+ // default event start time.
+ aItem.startDate = cal.dtz.getDefaultStartDate(aItem.startDate);
+ } else if (aForceAllday) {
+ // If the event should be forced to be allday, then don't set up
+ // any default hours and directly make it allday.
+ aItem.startDate.isDate = true;
+ aItem.startDate.timezone = cal.dtz.floating;
+ }
+ } else {
+ // If no start date was passed, then default to the next full hour
+ // of today, but with the date of the selected day
+ aItem.startDate = cal.dtz.getDefaultStartDate(initialDate);
+ }
+
+ if (aEndDate) {
+ aItem.endDate = aEndDate.clone();
+ if (aForceAllday) {
+ // XXX it is currently not specified, how callers that force all
+ // day should pass the end date. Right now, they should make
+ // sure that the end date is 00:00:00 of the day after.
+ aItem.endDate.isDate = true;
+ aItem.endDate.timezone = cal.dtz.floating;
+ }
+ } else {
+ aItem.endDate = aItem.startDate.clone();
+ if (aForceAllday) {
+ // All day events need to go to the beginning of the next day.
+ aItem.endDate.day++;
+ } else {
+ // If the event is not all day, then add the default event
+ // length.
+ aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ }
+ }
+
+ // Free/busy status is only valid for events, must not be set for tasks.
+ aItem.setProperty("TRANSP", cal.item.getEventDefaultTransparency(aForceAllday));
+
+ for (let attendee of aAttendees) {
+ aItem.addAttendee(attendee);
+ }
+ } else if (aItem.isTodo()) {
+ let now = cal.dtz.now();
+ let initDate = initialDate ? initialDate.clone() : now;
+ initDate.isDate = false;
+ initDate.hour = now.hour;
+ initDate.minute = now.minute;
+ initDate.second = now.second;
+
+ if (aStartDate) {
+ aItem.entryDate = aStartDate.clone();
+ } else {
+ let defaultStart = Services.prefs.getStringPref("calendar.task.defaultstart", "none");
+ if (
+ Services.prefs.getIntPref("calendar.alarms.onfortodos", 0) == 1 &&
+ defaultStart == "none"
+ ) {
+ // start date is required if we want to set an alarm
+ defaultStart = "offsetcurrent";
+ }
+
+ let units = Services.prefs.getStringPref("calendar.task.defaultstartoffsetunits", "minutes");
+ if (!["days", "hours", "minutes"].includes(units)) {
+ units = "minutes";
+ }
+ let startOffset = cal.createDuration();
+ startOffset[units] = Services.prefs.getIntPref("calendar.task.defaultstartoffset", 0);
+ let start;
+
+ switch (defaultStart) {
+ case "none":
+ break;
+ case "startofday":
+ start = startOfDay(initDate);
+ break;
+ case "tomorrow":
+ start = startOfDay(initDate);
+ start.day++;
+ break;
+ case "nextweek":
+ start = startOfDay(initDate);
+ start.day += 7;
+ break;
+ case "offsetcurrent":
+ start = initDate.clone();
+ start.addDuration(startOffset);
+ break;
+ case "offsetnexthour":
+ start = initDate.clone();
+ start.second = 0;
+ start.minute = 0;
+ start.hour++;
+ start.addDuration(startOffset);
+ break;
+ }
+
+ if (start) {
+ aItem.entryDate = start;
+ }
+ }
+
+ if (aEndDate) {
+ aItem.dueDate = aEndDate.clone();
+ } else {
+ let defaultDue = Services.prefs.getStringPref("calendar.task.defaultdue", "none");
+
+ let units = Services.prefs.getStringPref("calendar.task.defaultdueoffsetunits", "minutes");
+ if (!["days", "hours", "minutes"].includes(units)) {
+ units = "minutes";
+ }
+ let dueOffset = cal.createDuration();
+ dueOffset[units] = Services.prefs.getIntPref("calendar.task.defaultdueoffset", 0);
+
+ let start = aItem.entryDate ? aItem.entryDate.clone() : initDate.clone();
+ let due;
+
+ switch (defaultDue) {
+ case "none":
+ break;
+ case "endofday":
+ due = endOfDay(start);
+ // go to tomorrow if we're past the end of today
+ if (start.compare(due) > 0) {
+ due.day++;
+ }
+ break;
+ case "tomorrow":
+ due = endOfDay(start);
+ due.day++;
+ break;
+ case "nextweek":
+ due = endOfDay(start);
+ due.day += 7;
+ break;
+ case "offsetcurrent":
+ due = start.clone();
+ due.addDuration(dueOffset);
+ break;
+ case "offsetnexthour":
+ due = start.clone();
+ due.second = 0;
+ due.minute = 0;
+ due.hour++;
+ due.addDuration(dueOffset);
+ break;
+ }
+
+ if (aItem.entryDate && due && aItem.entryDate.compare(due) > 0) {
+ // due can't be earlier than start date.
+ due = aItem.entryDate;
+ }
+
+ if (due) {
+ aItem.dueDate = due;
+ }
+ }
+ }
+
+ // Calendar
+ aItem.calendar = aCalendar || getSelectedCalendar();
+
+ // Alarms
+ cal.alarms.setDefaultValues(aItem);
+}
+
+/**
+ * Creates an event with the calendar event dialog.
+ *
+ * @param {?calICalendar} calendar - The calendar to create the event in
+ * @param {?calIDateTime} startDate - The event's start date.
+ * @param {?calIDateTime} endDate - The event's end date.
+ * @param {?string} summary - The event's title.
+ * @param {?calIEvent} event - A template event to show in the dialog
+ * @param {?boolean} forceAllDay - Make sure the event shown in the dialog is an all-day event.
+ * @param {?calIAttendee} attendees - Attendees to add to the event.
+ */
+function createEventWithDialog(
+ calendar,
+ startDate,
+ endDate,
+ summary,
+ event,
+ forceAllDay,
+ attendees
+) {
+ let onNewEvent = function (item, opcalendar, originalItem, listener, extresponse = null) {
+ if (item.id) {
+ // If the item already has an id, then this is the result of
+ // saving the item without closing, and then saving again.
+ doTransaction("modify", item, opcalendar, originalItem, listener, extresponse);
+ } else {
+ // Otherwise, this is an addition
+ doTransaction("add", item, opcalendar, null, listener, extresponse);
+ }
+ };
+
+ if (event) {
+ if (!event.isMutable) {
+ event = event.clone();
+ }
+ // If the event should be created from a template, then make sure to
+ // remove the id so that the item obtains a new id when doing the
+ // transaction
+ event.id = null;
+
+ if (forceAllDay) {
+ event.startDate.isDate = true;
+ event.endDate.isDate = true;
+ if (event.startDate.compare(event.endDate) == 0) {
+ // For a one day all day event, the end date must be 00:00:00 of
+ // the next day.
+ event.endDate.day++;
+ }
+ }
+
+ if (!event.calendar) {
+ event.calendar = calendar || getSelectedCalendar();
+ }
+ } else {
+ event = new CalEvent();
+
+ let refDate = currentView().selectedDay?.clone();
+ setDefaultItemValues(event, calendar, startDate, endDate, refDate, forceAllDay, attendees);
+ if (summary) {
+ event.title = summary;
+ }
+ }
+ openEventDialog(event, event.calendar, "new", onNewEvent);
+}
+
+/**
+ * Creates a task with the calendar event dialog.
+ *
+ * @param calendar (optional) The calendar to create the task in
+ * @param dueDate (optional) The task's due date.
+ * @param summary (optional) The task's title.
+ * @param todo (optional) A template task to show in the dialog.
+ * @param initialDate (optional) The initial date for new task datepickers
+ */
+function createTodoWithDialog(calendar, dueDate, summary, todo, initialDate) {
+ let onNewItem = function (item, opcalendar, originalItem, listener, extresponse = null) {
+ if (item.id) {
+ // If the item already has an id, then this is the result of
+ // saving the item without closing, and then saving again.
+ doTransaction("modify", item, opcalendar, originalItem, listener, extresponse);
+ } else {
+ // Otherwise, this is an addition
+ doTransaction("add", item, opcalendar, null, listener, extresponse);
+ }
+ };
+
+ if (todo) {
+ // If the todo should be created from a template, then make sure to
+ // remove the id so that the item obtains a new id when doing the
+ // transaction
+ if (todo.id) {
+ todo = todo.clone();
+ todo.id = null;
+ }
+
+ if (!todo.calendar) {
+ todo.calendar = calendar || getSelectedCalendar();
+ }
+ } else {
+ todo = new CalTodo();
+ setDefaultItemValues(todo, calendar, null, dueDate, initialDate);
+
+ if (summary) {
+ todo.title = summary;
+ }
+ }
+
+ openEventDialog(todo, calendar, "new", onNewItem, initialDate);
+}
+
+/**
+ * Opens the passed event item for viewing. This enables the modify callback in
+ * openEventDialog so invitation responses can be edited.
+ *
+ * @param {calIItemBase} item - The calendar item to view.
+ */
+function openEventDialogForViewing(item) {
+ function onDialogComplete(newItem, calendar, originalItem, listener, extresponse) {
+ doTransaction("modify", newItem, calendar, originalItem, listener, extresponse);
+ }
+ openEventDialog(item, item.calendar, "view", onDialogComplete);
+}
+
+/**
+ * Modifies the passed event in the event dialog.
+ *
+ * @param aItem The item to modify.
+ * @param aPromptOccurrence If the user should be prompted to select if the
+ * parent item or occurrence should be modified.
+ * @param initialDate (optional) The initial date for new task datepickers
+ * @param aCounterProposal (optional) An object representing the counterproposal
+ * {
+ * {JsObject} result: {
+ * type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF"
+ * descr: {String} a technical description of the problem if type is ERROR or NODIFF,
+ * otherwise an empty string
+ * },
+ * (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{
+ * property: {String} a property that is subject to the proposal
+ * proposed: {String} the proposed value
+ * original: {String} the original value
+ * }]
+ * }
+ */
+function modifyEventWithDialog(aItem, aPromptOccurrence, initialDate = null, aCounterProposal) {
+ let dlg = cal.item.findWindow(aItem);
+ if (dlg) {
+ dlg.focus();
+ return;
+ }
+
+ let onModifyItem = function (item, calendar, originalItem, listener, extresponse = null) {
+ doTransaction("modify", item, calendar, originalItem, listener, extresponse);
+ };
+
+ let item = aItem;
+ let response;
+ if (aPromptOccurrence !== false) {
+ [item, , response] = promptOccurrenceModification(aItem, true, "edit");
+ }
+
+ if (item && (response || response === undefined)) {
+ openEventDialog(item, item.calendar, "modify", onModifyItem, initialDate, aCounterProposal);
+ }
+}
+
+/**
+ * @callback onDialogComplete
+ *
+ * @param {calIItemBase} newItem
+ * @param {calICalendar} calendar
+ * @param {calIItemBase} originalItem
+ * @param {?calIOperationListener} listener
+ * @param {?object} extresponse
+ */
+
+/**
+ * Opens the event dialog with the given item (task OR event).
+ *
+ * @param {calIItemBase} calendarItem - The item to open the dialog with.
+ * @param {calICalendar} calendar - The calendar to open the dialog with.
+ * @param {string} mode - The operation the dialog should do
+ * ("new", "view", "modify").
+ * @param {onDialogComplete} callback - The callback to call when the dialog
+ * has completed.
+ * @param {?calIDateTime} initialDate - The initial date for new task
+ * datepickers.
+ * @param {?object} counterProposal - An object representing the
+ * counterproposal - see description
+ * for modifyEventWithDialog().
+ */
+function openEventDialog(
+ calendarItem,
+ calendar,
+ mode,
+ callback,
+ initialDate = null,
+ counterProposal
+) {
+ let dlg = cal.item.findWindow(calendarItem);
+ if (dlg) {
+ dlg.focus();
+ return;
+ }
+
+ // Set up some defaults
+ mode = mode || "new";
+ calendar = calendar || getSelectedCalendar();
+ let calendars = cal.manager.getCalendars();
+ calendars = calendars.filter(cal.acl.isCalendarWritable);
+
+ let isItemSupported;
+ if (calendarItem.isTodo()) {
+ isItemSupported = function (aCalendar) {
+ return aCalendar.getProperty("capabilities.tasks.supported") !== false;
+ };
+ } else if (calendarItem.isEvent()) {
+ isItemSupported = function (aCalendar) {
+ return aCalendar.getProperty("capabilities.events.supported") !== false;
+ };
+ }
+
+ // Filter out calendars that don't support the given calendar item
+ calendars = calendars.filter(isItemSupported);
+
+ // Filter out calendar/items that we cannot write to/modify
+ if (mode == "new") {
+ calendars = calendars.filter(cal.acl.userCanAddItemsToCalendar);
+ } else if (mode == "modify") {
+ calendars = calendars.filter(aCalendar => {
+ /* If the calendar is the item calendar, we check that the item
+ * can be modified. If the calendar is NOT the item calendar, we
+ * check that the user can remove items from that calendar and
+ * add items to the current one.
+ */
+ let isSameCalendar = calendarItem.calendar == aCalendar;
+ let canModify = cal.acl.userCanModifyItem(calendarItem);
+ let canMoveItems =
+ cal.acl.userCanDeleteItemsFromCalendar(calendarItem.calendar) &&
+ cal.acl.userCanAddItemsToCalendar(aCalendar);
+
+ return isSameCalendar ? canModify : canMoveItems;
+ });
+ }
+
+ if (
+ mode == "new" &&
+ (!cal.acl.isCalendarWritable(calendar) ||
+ !cal.acl.userCanAddItemsToCalendar(calendar) ||
+ !isItemSupported(calendar))
+ ) {
+ if (calendars.length < 1) {
+ // There are no writable calendars or no calendar supports the given
+ // item. Don't show the dialog.
+ return;
+ }
+ // Pick the first calendar that supports the item and is writable
+ calendar = calendars[0];
+ if (calendarItem) {
+ // XXX The dialog currently uses the items calendar as a first
+ // choice. Since we are shortly before a release to keep
+ // regression risk low, explicitly set the item's calendar here.
+ calendarItem.calendar = calendars[0];
+ }
+ }
+
+ // Setup the window arguments
+ let args = {};
+ args.calendarEvent = calendarItem;
+ args.calendar = calendar;
+ args.mode = mode;
+ args.onOk = callback;
+ args.initialStartDateValue = initialDate || cal.dtz.getDefaultStartDate();
+ args.counterProposal = counterProposal;
+ args.inTab = Services.prefs.getBoolPref("calendar.item.editInTab", false);
+ // this will be called if file->new has been selected from within the dialog
+ args.onNewEvent = function (opcalendar) {
+ createEventWithDialog(opcalendar, null, null);
+ };
+ args.onNewTodo = function (opcalendar) {
+ createTodoWithDialog(opcalendar);
+ };
+
+ // the dialog will reset this to auto when it is done loading.
+ window.setCursor("wait");
+
+ // Ask the provider if this item is an invitation. If this is the case,
+ // we'll open the summary dialog since the user is not allowed to change
+ // the details of the item.
+ let isInvitation =
+ calendar.supportsScheduling && calendar.getSchedulingSupport().isInvitation(calendarItem);
+
+ // open the dialog modeless
+ let url;
+ let isEditable = mode == "modify" && !isInvitation && cal.acl.userCanModifyItem(calendarItem);
+
+ if (cal.acl.isCalendarWritable(calendar) && (mode == "new" || isEditable)) {
+ // Currently the read-only summary dialog is never opened in a tab.
+ if (args.inTab) {
+ url = "chrome://calendar/content/calendar-item-iframe.xhtml";
+ } else {
+ url = "chrome://calendar/content/calendar-event-dialog.xhtml";
+ }
+ } else {
+ url = "chrome://calendar/content/calendar-summary-dialog.xhtml";
+ args.inTab = false;
+ args.isInvitation = isInvitation;
+ }
+
+ if (args.inTab) {
+ args.url = url;
+ let tabmail = document.getElementById("tabmail");
+ let tabtype = args.calendarEvent.isEvent() ? "calendarEvent" : "calendarTask";
+ tabmail.openTab(tabtype, args);
+ } else {
+ // open in a window
+ openDialog(url, "_blank", "chrome,titlebar,toolbar,resizable", args);
+ }
+}
+
+/**
+ * Prompts the user how the passed item should be modified. If the item is an
+ * exception or already a parent item, the item is returned without prompting.
+ * If "all occurrences" is specified, the parent item is returned. If "this
+ * occurrence only" is specified, then aItem is returned. If "this and following
+ * occurrences" is selected, aItem's parentItem is modified so that the
+ * recurrence rules end (UNTIL) just before the given occurrence. If
+ * aNeedsFuture is specified, a new item is made from the part that was stripped
+ * off the passed item.
+ *
+ * EXDATEs and RDATEs that do not fit into the items recurrence are removed. If
+ * the modified item or the future item only consist of a single occurrence,
+ * they are changed to be single items.
+ *
+ * @param aItem The item or array of items to check.
+ * @param aNeedsFuture If true, the future item is parsed.
+ * This parameter can for example be
+ * false if a deletion is being made.
+ * @param aAction Either "edit" or "delete". Sets up
+ * the labels in the occurrence prompt
+ * @returns [modifiedItem, futureItem, promptResponse]
+ * modifiedItem is a single item or array
+ * of items depending on the past aItem
+ *
+ * If "this and all following" was chosen,
+ * an array containing the item *until*
+ * the given occurrence (modifiedItem),
+ * and the item *after* the given
+ * occurrence (futureItem).
+ *
+ * If any other option was chosen,
+ * futureItem is null and the
+ * modifiedItem is either the parent item
+ * or the passed occurrence, or null if
+ * the dialog was canceled.
+ *
+ * The promptResponse parameter gives the
+ * response of the dialog as a constant.
+ */
+function promptOccurrenceModification(aItem, aNeedsFuture, aAction) {
+ const CANCEL = 0;
+ const MODIFY_OCCURRENCE = 1;
+ const MODIFY_FOLLOWING = 2;
+ const MODIFY_PARENT = 3;
+
+ let futureItems = false;
+ let pastItems = [];
+ let returnItem = null;
+ let type = CANCEL;
+ let items = Array.isArray(aItem) ? aItem : [aItem];
+
+ // Check if this actually is an instance of a recurring event
+ if (items.every(item => item == item.parentItem)) {
+ type = MODIFY_PARENT;
+ } else if (aItem && items.length) {
+ // Prompt the user. Setting modal blocks the dialog until it is closed. We
+ // use rv to pass our return value.
+ let rv = { value: CANCEL, items, action: aAction };
+ window.openDialog(
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ "PromptOccurrenceModification",
+ "centerscreen,chrome,modal,titlebar",
+ rv
+ );
+ type = rv.value;
+ }
+
+ switch (type) {
+ case MODIFY_PARENT:
+ pastItems = items.map(item => item.parentItem);
+ break;
+ case MODIFY_FOLLOWING:
+ // TODO tbd in a different bug
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ case MODIFY_OCCURRENCE:
+ pastItems = items;
+ break;
+ case CANCEL:
+ // Since we have not set past or futureItem, the return below will
+ // take care.
+ break;
+ }
+ if (aItem) {
+ returnItem = Array.isArray(aItem) ? pastItems : pastItems[0];
+ }
+ return [returnItem, futureItems, type];
+}
+
+// Undo/Redo code
+
+/**
+ * Create and commit a transaction with the given arguments to the transaction
+ * manager. Also updates the undo/redo menu.
+ *
+ * @param action The action to do.
+ * @param item The new item to add/modify/delete
+ * @param calendar The calendar to do the transaction on
+ * @param oldItem (optional) some actions require an old item
+ * @param observer (optional) the observer to call when complete.
+ * @param extResponse (optional) JS object with additional parameters for sending itip messages
+ * (see also description of checkAndSend in calItipUtils.jsm)
+ */
+async function doTransaction(action, item, calendar, oldItem, observer, extResponse = null) {
+ // This is usually a user-initiated transaction, so make sure the calendar
+ // this transaction is happening on is visible.
+ top.ensureCalendarVisible(calendar);
+
+ let manager = gCalBatchTransaction || gCalTransactionMgr;
+ let trn;
+ switch (action) {
+ case "add":
+ trn = new CalAddTransaction(item, calendar, oldItem, extResponse);
+ break;
+ case "modify":
+ trn = new CalModifyTransaction(item, calendar, oldItem, extResponse);
+ break;
+ case "delete":
+ trn = new CalDeleteTransaction(item, calendar, oldItem, extResponse);
+ break;
+ default:
+ throw new Components.Exception(
+ `Invalid action specified "${action}"`,
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ await manager.commit(trn);
+
+ // If a batch transaction is active, do not update the menu as
+ // endBatchTransaction() will take care of that.
+ if (gCalBatchTransaction) {
+ return;
+ }
+
+ observer?.onTransactionComplete(trn.item, trn.oldItem);
+ updateUndoRedoMenu();
+}
+
+/**
+ * Undo the last operation done through the transaction manager.
+ */
+function undo() {
+ if (canUndo()) {
+ gCalTransactionMgr.undo();
+ updateUndoRedoMenu();
+ }
+}
+
+/**
+ * Redo the last undone operation in the transaction manager.
+ */
+function redo() {
+ if (canRedo()) {
+ gCalTransactionMgr.redo();
+ updateUndoRedoMenu();
+ }
+}
+
+/**
+ * Start a batch transaction on the transaction manager.
+ */
+function startBatchTransaction() {
+ gCalBatchTransaction = gCalTransactionMgr.beginBatch();
+}
+
+/**
+ * End a previously started batch transaction. NOTE: be sure to call this in a
+ * try-catch-finally-block in case you have code that could fail between
+ * startBatchTransaction and this call.
+ */
+function endBatchTransaction() {
+ gCalBatchTransaction = null;
+ updateUndoRedoMenu();
+}
+
+/**
+ * Checks if the last operation can be undone (or if there is a last operation
+ * at all).
+ */
+function canUndo() {
+ return gCalTransactionMgr.canUndo();
+}
+
+/**
+ * Checks if the last undone operation can be redone.
+ */
+function canRedo() {
+ return gCalTransactionMgr.canRedo();
+}
+
+/**
+ * Update the undo and redo commands.
+ */
+function updateUndoRedoMenu() {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+}
+
+/**
+ * Updates the partstat of the calendar owner for specified items triggered by a
+ * context menu operation
+ *
+ * For a documentation of the expected bahaviours for different use cases of
+ * dealing with context menu partstat actions, see also setupAttendanceMenu(...)
+ * in calendar-ui-utils.js
+ *
+ * @param {EventTarget} aTarget the target of the triggering event
+ * @param {Array} aItems an array of calEvent or calIToDo items
+ */
+function setContextPartstat(aTarget, aItems) {
+ /**
+ * Provides the participation representing the user for a provided item
+ *
+ * @param {calEvent|calTodo} aItem The calendar item to inspect
+ * @returns {?calIAttendee} An calIAttendee object or null if no
+ * participant was detected
+ */
+ function getParticipant(aItem) {
+ let party = null;
+ if (cal.itip.isInvitation(aItem)) {
+ party = cal.itip.getInvitedAttendee(aItem);
+ } else if (aItem.organizer && aItem.getAttendees().length) {
+ let calOrgId = aItem.calendar.getProperty("organizerId");
+ if (calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) {
+ party = aItem.organizer;
+ }
+ }
+ return party;
+ }
+
+ startBatchTransaction();
+ try {
+ // TODO: make sure we overwrite the partstat of all occurrences in
+ // the selection, if the partstat of the respective master item is
+ // changed - see matrix in the doc block of setupAttendanceMenu(...)
+ // in calendar-ui-utils.js
+
+ for (let oldItem of aItems) {
+ // Skip this item if its calendar is read only.
+ if (oldItem.calendar.readOnly) {
+ continue;
+ }
+ if (aTarget.getAttribute("scope") == "all-occurrences") {
+ oldItem = oldItem.parentItem;
+ }
+ let attendee = getParticipant(oldItem);
+ if (attendee) {
+ // skip this item if the partstat for the participant hasn't
+ // changed. otherwise we would always perform update operations
+ // for recurring events on both, the master and the occurrence
+ // item
+ let partStat = aTarget.getAttribute("respvalue");
+ if (attendee.participationStatus == partStat) {
+ continue;
+ }
+
+ let newItem = oldItem.clone();
+ let newAttendee = attendee.clone();
+ newAttendee.participationStatus = partStat;
+ if (newAttendee.isOrganizer) {
+ newItem.organizer = newAttendee;
+ } else {
+ newItem.removeAttendee(attendee);
+ newItem.addAttendee(newAttendee);
+ }
+
+ let extResponse = null;
+ if (aTarget.hasAttribute("respmode")) {
+ let mode = aTarget.getAttribute("respmode");
+ let itipMode = Ci.calIItipItem[mode];
+ extResponse = { responseMode: itipMode };
+ }
+
+ doTransaction("modify", newItem, newItem.calendar, oldItem, null, extResponse);
+ }
+ }
+ } catch (e) {
+ cal.ERROR("Error setting partstat: " + e + "\r\n");
+ } finally {
+ endBatchTransaction();
+ }
+}
diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.js b/comm/calendar/base/content/item-editing/calendar-item-iframe.js
new file mode 100644
index 0000000000..bdabd21356
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.js
@@ -0,0 +1,4302 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported onEventDialogUnload, changeUndiscloseCheckboxStatus,
+ * categoryPopupHiding, categoryTextboxKeypress,
+ * toggleKeepDuration, dateTimeControls2State, onUpdateAllDay,
+ * openNewEvent, openNewTask, openNewMessage,
+ * deleteAllAttachments, copyAttachment, attachmentLinkKeyPress,
+ * attachmentDblClick, attachmentClick, notifyUser,
+ * removeNotification, chooseRecentTimezone, showTimezonePopup,
+ * attendeeDblClick, setAttendeeContext, removeAttendee,
+ * removeAllAttendees, sendMailToUndecidedAttendees, checkUntilDate,
+ * applyValues
+ */
+
+/* global MozElements */
+
+/* import-globals-from ../../../../mail/components/compose/content/editor.js */
+/* import-globals-from ../../../../mail/components/compose/content/editorUtilities.js */
+/* import-globals-from ../calendar-ui-utils.js */
+/* import-globals-from ../dialogs/calendar-dialog-utils.js */
+/* globals gTimezonesEnabled */ // Set by calendar-item-panel.js.
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var {
+ recurrenceRule2String,
+ splitRecurrenceRules,
+ checkRecurrenceRule,
+ countOccurrences,
+ hasUnsupported,
+} = ChromeUtils.import("resource:///modules/calendar/calRecurrenceUtils.jsm");
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+window.addEventListener("load", onLoad);
+window.addEventListener("unload", onEventDialogUnload);
+
+var cloudFileAccounts;
+try {
+ ({ cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm"));
+} catch (e) {
+ // This will fail on Seamonkey, but that's ok since the pref for cloudfiles
+ // is false, which means the UI will not be shown
+}
+
+// the following variables are constructed if the jsContext this file
+// belongs to gets constructed. all those variables are meant to be accessed
+// from within this file only.
+var gStartTime = null;
+var gEndTime = null;
+var gItemDuration = null;
+var gStartTimezone = null;
+var gEndTimezone = null;
+var gUntilDate = null;
+var gIsReadOnly = false;
+var gAttachMap = {};
+var gConfirmCancel = true;
+var gLastRepeatSelection = 0;
+var gIgnoreUpdate = false;
+var gWarning = false;
+var gPreviousCalendarId = null;
+var gTabInfoObject;
+var gLastAlarmSelection = 0;
+var gConfig = {
+ priority: 0,
+ privacy: null,
+ status: "NONE",
+ showTimeAs: null,
+ percentComplete: 0,
+};
+// The following variables are set by the load handler function of the
+// parent context, so that they are already set before iframe content load:
+// - gTimezoneEnabled
+
+XPCOMUtils.defineLazyGetter(this, "gEventNotification", () => {
+ return new MozElements.NotificationBox(element => {
+ document.getElementById("event-dialog-notifications").append(element);
+ });
+});
+
+var eventDialogRequestObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic == "http-on-modify-request" &&
+ aSubject instanceof Ci.nsIChannel &&
+ aSubject.loadInfo &&
+ aSubject.loadInfo.loadingDocument &&
+ aSubject.loadInfo.loadingDocument ==
+ document.getElementById("item-description").contentDocument
+ ) {
+ aSubject.cancel(Cr.NS_ERROR_ABORT);
+ }
+ },
+};
+
+var eventDialogQuitObserver = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(aSubject, aTopic, aData) {
+ // Check whether or not we want to veto the quit request (unless another
+ // observer already did.
+ if (
+ aTopic == "quit-application-requested" &&
+ aSubject instanceof Ci.nsISupportsPRBool &&
+ !aSubject.data
+ ) {
+ aSubject.data = !onCancel();
+ }
+ },
+};
+
+var eventDialogCalendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ target: null,
+ isObserving: false,
+
+ onModifyItem(aNewItem, aOldItem) {
+ if (
+ this.isObserving &&
+ "calendarItem" in window &&
+ window.calendarItem &&
+ window.calendarItem.id == aOldItem.id
+ ) {
+ let doUpdate = true;
+
+ // The item has been modified outside the dialog. We only need to
+ // prompt if there have been local changes also.
+ if (isItemChanged()) {
+ let promptTitle = cal.l10n.getCalString("modifyConflictPromptTitle");
+ let promptMessage = cal.l10n.getCalString("modifyConflictPromptMessage");
+ let promptButton1 = cal.l10n.getCalString("modifyConflictPromptButton1");
+ let promptButton2 = cal.l10n.getCalString("modifyConflictPromptButton2");
+ let flags =
+ Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_0 +
+ Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_1;
+
+ let choice = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMessage,
+ flags,
+ promptButton1,
+ promptButton2,
+ null,
+ null,
+ {}
+ );
+ if (!choice) {
+ doUpdate = false;
+ }
+ }
+
+ let item = aNewItem;
+ if (window.calendarItem.recurrenceId && aNewItem.recurrenceInfo) {
+ item = aNewItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId) || item;
+ }
+ window.calendarItem = item;
+
+ if (doUpdate) {
+ loadDialog(window.calendarItem);
+ }
+ }
+ },
+
+ onDeleteItem(aDeletedItem) {
+ if (
+ this.isObserving &&
+ "calendarItem" in window &&
+ window.calendarItem &&
+ window.calendarItem.id == aDeletedItem.id
+ ) {
+ cancelItem();
+ }
+ },
+
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem() {},
+ onError() {},
+ onPropertyChanged() {},
+ onPropertyDeleting() {},
+
+ observe(aCalendar) {
+ // use the new calendar if one was passed, otherwise use the last one
+ this.target = aCalendar || this.target;
+ if (this.target) {
+ this.cancel();
+ this.target.addObserver(this);
+ this.isObserving = true;
+ }
+ },
+
+ cancel() {
+ if (this.isObserving && this.target) {
+ this.target.removeObserver(this);
+ this.isObserving = false;
+ }
+ },
+};
+
+/**
+ * Checks if the given calendar supports notifying attendees. The item is needed
+ * since calendars may support notifications for only some types of items.
+ *
+ * @param {calICalendar} aCalendar - The calendar to check
+ * @param {calIItemBase} aItem - The item to check support for
+ */
+function canNotifyAttendees(aCalendar, aItem) {
+ try {
+ let calendar = aCalendar.QueryInterface(Ci.calISchedulingSupport);
+ return calendar.canNotify("REQUEST", aItem) && calendar.canNotify("CANCEL", aItem);
+ } catch (exc) {
+ return false;
+ }
+}
+
+/**
+ * Sends an asynchronous message to the parent context that contains the
+ * iframe. Additional properties of aMessage are generally arguments
+ * that will be passed to the function named in aMessage.command.
+ *
+ * @param {object} aMessage - The message to pass to the parent context
+ * @param {string} aMessage.command - The name of a function to call
+ */
+function sendMessage(aMessage) {
+ parent.postMessage(aMessage, "*");
+}
+
+/**
+ * Receives asynchronous messages from the parent context that contains the iframe.
+ *
+ * @param {MessageEvent} aEvent - Contains the message being received
+ */
+function receiveMessage(aEvent) {
+ let validOrigin = gTabmail ? "chrome://messenger" : "chrome://calendar";
+ if (aEvent.origin !== validOrigin) {
+ return;
+ }
+ switch (aEvent.data.command) {
+ case "editAttendees":
+ editAttendees();
+ break;
+ case "attachURL":
+ attachURL();
+ break;
+ case "onCommandDeleteItem":
+ onCommandDeleteItem();
+ break;
+ case "onCommandSave":
+ onCommandSave(aEvent.data.isClosing);
+ break;
+ case "onAccept":
+ onAccept();
+ break;
+ case "onCancel":
+ onCancel(aEvent.data.iframeId);
+ break;
+ case "openNewEvent":
+ openNewEvent();
+ break;
+ case "openNewTask":
+ openNewTask();
+ break;
+ case "editConfigState": {
+ Object.assign(gConfig, aEvent.data.argument);
+ updateConfigState(aEvent.data.argument);
+ break;
+ }
+ case "editToDoStatus": {
+ let textbox = document.getElementById("percent-complete-textbox");
+ textbox.value = aEvent.data.value;
+ updateToDoStatus("percent-changed");
+ break;
+ }
+ case "postponeTask":
+ postponeTask(aEvent.data.value);
+ break;
+ case "toggleTimezoneLinks":
+ gTimezonesEnabled = aEvent.data.checked; // eslint-disable-line
+ updateDateTime();
+ break;
+ case "closingWindowWithTabs": {
+ let response = onCancel(aEvent.data.id, true);
+ sendMessage({
+ command: "replyToClosingWindowWithTabs",
+ response,
+ });
+ break;
+ }
+ case "attachFileByAccountKey":
+ attachFileByAccountKey(aEvent.data.accountKey);
+ break;
+ case "triggerUpdateSaveControls":
+ updateParentSaveControls();
+ break;
+ }
+}
+
+/**
+ * Sets up the event dialog from the window arguments, also setting up all
+ * dialog controls from the window's item.
+ */
+function onLoad() {
+ window.addEventListener("message", receiveMessage);
+
+ // first of all retrieve the array of
+ // arguments this window has been called with.
+ let args = window.arguments[0];
+
+ intializeTabOrWindowVariables();
+
+ // Needed so we can call switchToTab for the prompt about saving
+ // unsaved changes, to show the tab that the prompt is for.
+ if (gInTab) {
+ gTabInfoObject = gTabmail.currentTabInfo;
+ }
+
+ // the most important attribute we expect from the
+ // arguments is the item we'll edit in the dialog.
+ let item = args.calendarEvent;
+
+ // set the iframe's top level id for event vs task
+ if (item.isTodo()) {
+ setDialogId(document.documentElement, "calendar-task-dialog-inner");
+ }
+
+ document.getElementById("item-title").placeholder = cal.l10n.getString(
+ "calendar-event-dialog",
+ item.isEvent() ? "newEvent" : "newTask"
+ );
+
+ window.onAcceptCallback = args.onOk;
+ window.mode = args.mode;
+
+ // we store the item in the window to be able
+ // to access this from any location. please note
+ // that the item is either an occurrence [proxy]
+ // or the stand-alone item [single occurrence item].
+ window.calendarItem = item;
+ // store the initial date value for datepickers in New Task dialog
+ window.initialStartDateValue = args.initialStartDateValue;
+
+ window.attendeeTabLabel = document.getElementById("event-grid-tab-attendees").label;
+ window.attachmentTabLabel = document.getElementById("event-grid-tab-attachments").label;
+
+ // Store the array of attendees on the window for later retrieval. Clone each
+ // existing attendee to prevent modifying objects referenced elsewhere.
+ const attendees = item.getAttendees() ?? [];
+ window.attendees = attendees.map(attendee => attendee.clone());
+
+ window.organizer = null;
+ if (item.organizer) {
+ window.organizer = item.organizer.clone();
+ } else if (attendees.length > 0) {
+ // Previous versions of calendar may not have set the organizer correctly.
+ let organizerId = item.calendar.getProperty("organizerId");
+ if (organizerId) {
+ let organizer = new CalAttendee();
+ organizer.id = cal.email.removeMailTo(organizerId);
+ organizer.commonName = item.calendar.getProperty("organizerCN");
+ organizer.isOrganizer = true;
+ window.organizer = organizer;
+ }
+ }
+
+ // we store the recurrence info in the window so it
+ // can be accessed from any location. since the recurrence
+ // info is a property of the parent item we need to check
+ // whether or not this item is a proxy or a parent.
+ let parentItem = item;
+ if (parentItem.parentItem != parentItem) {
+ parentItem = parentItem.parentItem;
+ }
+
+ window.recurrenceInfo = null;
+ if (parentItem.recurrenceInfo) {
+ window.recurrenceInfo = parentItem.recurrenceInfo.clone();
+ }
+
+ // Set initial values for datepickers in New Tasks dialog
+ if (item.isTodo()) {
+ let initialDatesValue = cal.dtz.dateTimeToJsDate(args.initialStartDateValue);
+ document.getElementById("completed-date-picker").value = initialDatesValue;
+ document.getElementById("todo-entrydate").value = initialDatesValue;
+ document.getElementById("todo-duedate").value = initialDatesValue;
+ }
+ loadDialog(window.calendarItem);
+
+ if (args.counterProposal) {
+ window.counterProposal = args.counterProposal;
+ displayCounterProposal();
+ }
+
+ gMainWindow.setCursor("auto");
+
+ document.getElementById("item-title").select();
+
+ // This causes the app to ask if the window should be closed when the
+ // application is closed.
+ Services.obs.addObserver(eventDialogQuitObserver, "quit-application-requested");
+
+ // This stops the editor from loading remote HTTP(S) content.
+ Services.obs.addObserver(eventDialogRequestObserver, "http-on-modify-request");
+
+ // Normally, Enter closes a <dialog>. We want this to rather on Ctrl+Enter.
+ // Stopping event propagation doesn't seem to work, so just overwrite the
+ // function that does this.
+ if (!gInTab) {
+ document.documentElement._hitEnter = function () {};
+ }
+
+ // set up our calendar event observer
+ eventDialogCalendarObserver.observe(item.calendar);
+
+ // Disable save and save close buttons and menuitems if the item
+ // title is empty.
+ updateTitle();
+
+ cal.view.colorTracker.registerWindow(window);
+
+ top.document.commandDispatcher.addCommandUpdater(
+ document.getElementById("styleMenuItems"),
+ "style",
+ "*"
+ );
+ EditorSharedStartup();
+
+ // We want to keep HTML output as simple as possible, so don't try to use divs
+ // as separators. As a bonus, this avoids a bug in the editor which sometimes
+ // causes the user to have to hit enter twice for it to take effect.
+ const editor = GetCurrentEditor();
+ editor.document.execCommand("defaultparagraphseparator", false, "br");
+
+ onLoad.hasLoaded = true;
+}
+// Set a variable to allow or prevent actions before the dialog is done loading.
+onLoad.hasLoaded = false;
+
+function onEventDialogUnload() {
+ Services.obs.removeObserver(eventDialogRequestObserver, "http-on-modify-request");
+ Services.obs.removeObserver(eventDialogQuitObserver, "quit-application-requested");
+ eventDialogCalendarObserver.cancel();
+}
+
+/**
+ * Handler function to be called when the accept button is pressed.
+ *
+ * @returns Returns true if the window should be closed
+ */
+function onAccept() {
+ dispose();
+ onCommandSave(true);
+ if (!gWarning) {
+ sendMessage({ command: "closeWindowOrTab" });
+ }
+ return !gWarning;
+}
+
+/**
+ * Asks the user if the item should be saved and does so if requested. If the
+ * user cancels, the window should stay open.
+ *
+ * XXX Could possibly be consolidated into onCancel()
+ *
+ * @returns Returns true if the window should be closed.
+ */
+function onCommandCancel() {
+ // Allow closing if the item has not changed and no warning dialog has to be showed.
+ if (!isItemChanged() && !gWarning) {
+ return true;
+ }
+
+ if (gInTab && gTabInfoObject) {
+ // Switch to the tab that the prompt refers to.
+ gTabmail.switchToTab(gTabInfoObject);
+ }
+
+ let promptTitle = cal.l10n.getCalString(
+ window.calendarItem.isEvent() ? "askSaveTitleEvent" : "askSaveTitleTask"
+ );
+ let promptMessage = cal.l10n.getCalString(
+ window.calendarItem.isEvent() ? "askSaveMessageEvent" : "askSaveMessageTask"
+ );
+
+ let flags =
+ Ci.nsIPromptService.BUTTON_TITLE_SAVE * Ci.nsIPromptService.BUTTON_POS_0 +
+ Ci.nsIPromptService.BUTTON_TITLE_CANCEL * Ci.nsIPromptService.BUTTON_POS_1 +
+ Ci.nsIPromptService.BUTTON_TITLE_DONT_SAVE * Ci.nsIPromptService.BUTTON_POS_2;
+
+ let choice = Services.prompt.confirmEx(
+ null,
+ promptTitle,
+ promptMessage,
+ flags,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ switch (choice) {
+ case 0: // Save
+ let itemTitle = document.getElementById("item-title");
+ if (!itemTitle.value) {
+ itemTitle.value = cal.l10n.getCalString("eventUntitled");
+ }
+ onCommandSave(true);
+ return true;
+ case 2: // Don't save
+ // Don't show any warning dialog when closing without saving.
+ gWarning = false;
+ return true;
+ default:
+ // Cancel
+ return false;
+ }
+}
+
+/**
+ * Handler function to be called when the cancel button is pressed.
+ * aPreventClose is true when closing the main window but leaving the tab open.
+ *
+ * @param {string} aIframeId (optional) iframe id of the tab to be closed
+ * @param {boolean} aPreventClose (optional) True means don't close, just ask about saving
+ * @returns {boolean} True if the tab or window should be closed
+ */
+function onCancel(aIframeId, aPreventClose) {
+ // The datepickers need to remove the focus in order to trigger the
+ // validation of the values just edited, with the keyboard, but not yet
+ // confirmed (i.e. not followed by a click, a tab or enter keys pressure).
+ document.documentElement.focus();
+
+ if (!gConfirmCancel || (gConfirmCancel && onCommandCancel())) {
+ dispose();
+ // Don't allow closing the dialog when the user inputs a wrong
+ // date then closes the dialog and answers with "Save" in
+ // the "Save Event" dialog. Don't allow closing the dialog if
+ // the main window is being closed but the tabs in it are not.
+
+ if (!gWarning && !aPreventClose) {
+ sendMessage({ command: "closeWindowOrTab", iframeId: aIframeId });
+ }
+ return !gWarning;
+ }
+ return false;
+}
+
+/**
+ * Cancels (closes) either the window or the tab, for example when the
+ * item is being deleted.
+ */
+function cancelItem() {
+ gConfirmCancel = false;
+ if (gInTab) {
+ onCancel();
+ } else {
+ sendMessage({ command: "cancelDialog" });
+ }
+}
+
+/**
+ * Get the currently selected calendar from the menulist of calendars.
+ *
+ * @returns The currently selected calendar.
+ */
+function getCurrentCalendar() {
+ return document.getElementById("item-calendar").selectedItem.calendar;
+}
+
+/**
+ * Sets up all dialog controls from the information of the passed item.
+ *
+ * @param aItem The item to parse information out of.
+ */
+function loadDialog(aItem) {
+ loadDateTime(aItem);
+
+ document.getElementById("item-title").value = aItem.title;
+ document.getElementById("item-location").value = aItem.getProperty("LOCATION");
+
+ // add calendars to the calendar menulist
+ let calendarList = document.getElementById("item-calendar");
+ let indexToSelect = appendCalendarItems(
+ aItem,
+ calendarList,
+ aItem.calendar || window.arguments[0].calendar
+ );
+ if (indexToSelect > -1) {
+ calendarList.selectedIndex = indexToSelect;
+ }
+
+ // Categories
+ loadCategories(aItem);
+
+ // Attachment
+ loadCloudProviders();
+
+ let hasAttachments = capSupported("attachments");
+ let attachments = aItem.getAttachments();
+ if (hasAttachments && attachments && attachments.length > 0) {
+ for (let attachment of attachments) {
+ addAttachment(attachment);
+ }
+ } else {
+ updateAttachment();
+ }
+
+ // URL link
+ let itemUrl = window.calendarItem.getProperty("URL")?.trim() || "";
+ let showLink = showOrHideItemURL(itemUrl);
+ updateItemURL(showLink, itemUrl);
+
+ // Description
+ let editorElement = document.getElementById("item-description");
+ let editor = editorElement.getHTMLEditor(editorElement.contentWindow);
+
+ let link = editorElement.contentDocument.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://messenger/skin/shared/editorContent.css";
+ editorElement.contentDocument.head.appendChild(link);
+
+ try {
+ let checker = editor.getInlineSpellChecker(true);
+ checker.enableRealTimeSpell = Services.prefs.getBoolPref("mail.spellcheck.inline", true);
+ } catch (ex) {
+ // No dictionaries.
+ }
+
+ if (aItem.descriptionText) {
+ let docFragment = cal.view.textToHtmlDocumentFragment(
+ aItem.descriptionText,
+ editorElement.contentDocument,
+ aItem.descriptionHTML
+ );
+ editor.flags =
+ editor.eEditorMailMask | editor.eEditorNoCSSMask | editor.eEditorAllowInteraction;
+ editor.enableUndo(false);
+ editor.forceCompositionEnd();
+ editor.rootElement.replaceChildren(docFragment);
+ // This reinitialises the editor after we replaced its contents.
+ editor.insertText("");
+ editor.enableUndo(true);
+ }
+
+ editor.resetModificationCount();
+
+ if (aItem.isTodo()) {
+ // Task completed date
+ if (aItem.completedDate) {
+ updateToDoStatus(aItem.status, cal.dtz.dateTimeToJsDate(aItem.completedDate));
+ } else {
+ updateToDoStatus(aItem.status);
+ }
+
+ // Task percent complete
+ let percentCompleteInteger = 0;
+ let percentCompleteProperty = aItem.getProperty("PERCENT-COMPLETE");
+ if (percentCompleteProperty != null) {
+ percentCompleteInteger = parseInt(percentCompleteProperty, 10);
+ }
+ if (percentCompleteInteger < 0) {
+ percentCompleteInteger = 0;
+ } else if (percentCompleteInteger > 100) {
+ percentCompleteInteger = 100;
+ }
+ gConfig.percentComplete = percentCompleteInteger;
+ document.getElementById("percent-complete-textbox").value = percentCompleteInteger;
+ }
+
+ // When in a window, set Item-Menu label to Event or Task
+ if (!gInTab) {
+ let isEvent = aItem.isEvent();
+
+ let labelString = isEvent ? "itemMenuLabelEvent" : "itemMenuLabelTask";
+ let label = cal.l10n.getString("calendar-event-dialog", labelString);
+
+ let accessKeyString = isEvent ? "itemMenuAccesskeyEvent2" : "itemMenuAccesskeyTask2";
+ let accessKey = cal.l10n.getString("calendar-event-dialog", accessKeyString);
+ sendMessage({
+ command: "initializeItemMenu",
+ label,
+ accessKey,
+ });
+ }
+
+ // Repeat details
+ let [repeatType, untilDate] = getRepeatTypeAndUntilDate(aItem);
+ loadRepeat(repeatType, untilDate, aItem);
+
+ // load reminders details
+ let alarmsMenu = document.querySelector(".item-alarm");
+ window.gLastAlarmSelection = loadReminders(aItem.getAlarms(), alarmsMenu, getCurrentCalendar());
+
+ // Synchronize link-top-image with keep-duration-button status
+ let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true";
+ document.getElementById("link-image-top").setAttribute("keep", keepAttribute);
+
+ updateDateTime();
+
+ updateCalendar();
+
+ let notifyCheckbox = document.getElementById("notify-attendees-checkbox");
+ let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
+ let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
+ if (canNotifyAttendees(aItem.calendar, aItem)) {
+ // visualize that the server will send out mail:
+ notifyCheckbox.checked = true;
+ // hide these controls as this a client only feature
+ undiscloseCheckbox.disabled = true;
+ } else {
+ let itemProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS");
+ notifyCheckbox.checked =
+ aItem.calendar.getProperty("imip.identity") &&
+ (itemProp === null
+ ? Services.prefs.getBoolPref("calendar.itip.notify", true)
+ : itemProp == "TRUE");
+ let undiscloseProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
+ undiscloseCheckbox.checked =
+ undiscloseProp === null
+ ? Services.prefs.getBoolPref("calendar.itip.separateInvitationPerAttendee")
+ : undiscloseProp == "TRUE";
+ // disable checkbox, if notifyCheckbox is not checked
+ undiscloseCheckbox.disabled = !notifyCheckbox.checked;
+ }
+ // this may also be a server exposed calendar property from exchange servers - if so, this
+ // probably should overrule the client-side config option
+ let disallowCounterProp = aItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ disallowcounterCheckbox.checked = disallowCounterProp == "TRUE";
+ // if we're in reschedule mode, it's pointless to enable the control
+ disallowcounterCheckbox.disabled = !!window.counterProposal;
+
+ updateAttendeeInterface();
+ updateRepeat(true);
+ updateReminder(true);
+
+ // Status
+ if (aItem.isEvent()) {
+ gConfig.status = aItem.hasProperty("STATUS") ? aItem.getProperty("STATUS") : "NONE";
+ if (gConfig.status == "NONE") {
+ sendMessage({ command: "showCmdStatusNone" });
+ }
+ updateConfigState({ status: gConfig.status });
+ } else {
+ let itemStatus = aItem.getProperty("STATUS");
+ let todoStatus = document.getElementById("todo-status");
+ todoStatus.value = itemStatus;
+ if (!todoStatus.selectedItem) {
+ // No selected item means there was no <menuitem> that matches the
+ // value given. Select the "NONE" item by default.
+ todoStatus.value = "NONE";
+ }
+ }
+
+ // Priority, Privacy, Transparency
+ gConfig.priority = parseInt(aItem.priority, 10);
+ gConfig.privacy = aItem.privacy;
+ gConfig.showTimeAs = aItem.getProperty("TRANSP");
+
+ // update in outer parent context
+ updateConfigState(gConfig);
+
+ if (aItem.getAttendees().length && !aItem.descriptionText) {
+ let tabs = document.getElementById("event-grid-tabs");
+ let attendeeTab = document.getElementById("event-grid-tab-attendees");
+ tabs.selectedItem = attendeeTab;
+ }
+}
+
+/**
+ * Enables/disables undiscloseCheckbox on (un)checking notifyCheckbox
+ */
+function changeUndiscloseCheckboxStatus() {
+ let notifyCheckbox = document.getElementById("notify-attendees-checkbox");
+ let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
+ undiscloseCheckbox.disabled = !notifyCheckbox.checked;
+ updateParentSaveControls();
+}
+
+/**
+ * Loads the item's categories into the category panel
+ *
+ * @param aItem The item to load into the category panel
+ */
+function loadCategories(aItem) {
+ let itemCategories = aItem.getCategories();
+ let categoryList = cal.category.fromPrefs();
+ for (let cat of itemCategories) {
+ if (!categoryList.includes(cat)) {
+ categoryList.push(cat);
+ }
+ }
+ cal.l10n.sortArrayByLocaleCollator(categoryList);
+
+ // Make sure the maximum number of categories is applied to the listbox
+ let calendar = getCurrentCalendar();
+ let maxCount = calendar.getProperty("capabilities.categories.maxCount");
+
+ let categoryPopup = document.getElementById("item-categories-popup");
+ if (maxCount == 1) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("class", "menuitem-iconic");
+ item.setAttribute("label", cal.l10n.getCalString("None"));
+ item.setAttribute("type", "radio");
+ if (itemCategories.length === 0) {
+ item.setAttribute("checked", "true");
+ }
+ categoryPopup.appendChild(item);
+ }
+ for (let cat of categoryList) {
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("class", "menuitem-iconic calendar-category");
+ item.setAttribute("label", cat);
+ item.setAttribute("value", cat);
+ item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio");
+ if (itemCategories.includes(cat)) {
+ item.setAttribute("checked", "true");
+ }
+ let cssSafeId = cal.view.formatStringForCSSRule(cat);
+ item.style.setProperty("--item-color", `var(--category-${cssSafeId}-color)`);
+ categoryPopup.appendChild(item);
+ }
+
+ updateCategoryMenulist();
+}
+
+/**
+ * Updates the category menulist to show the correct label, depending on the
+ * selected categories in the category panel
+ */
+function updateCategoryMenulist() {
+ let categoryMenulist = document.getElementById("item-categories");
+ let categoryPopup = document.getElementById("item-categories-popup");
+
+ // Make sure the maximum number of categories is applied to the listbox
+ let calendar = getCurrentCalendar();
+ let maxCount = calendar.getProperty("capabilities.categories.maxCount");
+
+ // Hide the categories listbox and label in case categories are not
+ // supported
+ document.getElementById("event-grid-category-row").toggleAttribute("hidden", maxCount === 0);
+
+ let label;
+ let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category[checked]");
+ if (categoryList.length > 1) {
+ label = cal.l10n.getCalString("multipleCategories");
+ } else if (categoryList.length == 1) {
+ label = categoryList[0].getAttribute("label");
+ } else {
+ label = cal.l10n.getCalString("None");
+ }
+ categoryMenulist.setAttribute("label", label);
+
+ let labelBox = categoryMenulist.shadowRoot.querySelector("#label-box");
+ let labelLabel = labelBox.querySelector("#label");
+ for (let box of labelBox.querySelectorAll("box")) {
+ box.remove();
+ }
+ for (let i = 0; i < categoryList.length; i++) {
+ let box = labelBox.insertBefore(document.createXULElement("box"), labelLabel);
+ // Normal CSS selectors like :first-child don't work on shadow DOM items,
+ // so we have to set up something they do work on.
+ let parts = ["color"];
+ if (i == 0) {
+ parts.push("first");
+ }
+ if (i == categoryList.length - 1) {
+ parts.push("last");
+ }
+ box.setAttribute("part", parts.join(" "));
+ box.style.setProperty("--item-color", categoryList[i].style.getPropertyValue("--item-color"));
+ }
+}
+
+/**
+ * Updates the categories menulist label and decides if the popup should close
+ *
+ * @param aItem The popuphiding event
+ * @returns Whether the popup should close
+ */
+function categoryPopupHiding(event) {
+ updateCategoryMenulist();
+ let calendar = getCurrentCalendar();
+ let maxCount = calendar.getProperty("capabilities.categories.maxCount");
+ if (maxCount === null || maxCount > 1) {
+ return event.target.localName != "menuitem";
+ }
+ return true;
+}
+
+/**
+ * Prompts for a new category name, then adds it to the list
+ */
+function categoryTextboxKeypress(event) {
+ let category = event.target.value;
+ let categoryPopup = document.getElementById("item-categories-popup");
+ switch (event.key) {
+ case "Tab":
+ case "ArrowDown":
+ case "ArrowUp": {
+ event.target.blur();
+ event.preventDefault();
+
+ let keyCode = event.key == "ArrowUp" ? KeyboardEvent.DOM_VK_UP : KeyboardEvent.DOM_VK_DOWN;
+ categoryPopup.dispatchEvent(new KeyboardEvent("keydown", { keyCode }));
+ categoryPopup.dispatchEvent(new KeyboardEvent("keyup", { keyCode }));
+ return;
+ }
+ case "Escape":
+ if (category) {
+ event.target.value = "";
+ } else {
+ categoryPopup.hidePopup();
+ }
+ event.preventDefault();
+ return;
+ case "Enter":
+ category = category.trim();
+ if (category != "") {
+ break;
+ }
+ return;
+ default:
+ return;
+ }
+ event.preventDefault();
+
+ let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category");
+ let categories = Array.from(categoryList, cat => cat.getAttribute("value"));
+
+ let newIndex = categories.indexOf(category);
+ if (newIndex > -1) {
+ categoryList[newIndex].setAttribute("checked", true);
+ } else {
+ const localeCollator = new Intl.Collator();
+ let compare = localeCollator.compare;
+ newIndex = cal.data.binaryInsert(categories, category, compare, true);
+
+ let calendar = getCurrentCalendar();
+ let maxCount = calendar.getProperty("capabilities.categories.maxCount");
+
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("class", "menuitem-iconic calendar-category");
+ item.setAttribute("label", category);
+ item.setAttribute("value", category);
+ item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio");
+ item.setAttribute("checked", true);
+ categoryPopup.insertBefore(item, categoryList[newIndex]);
+ }
+
+ event.target.value = "";
+ // By pushing this to the end of the event loop, the other checked items in the list
+ // are cleared, where only one category is allowed.
+ setTimeout(updateCategoryMenulist, 0);
+}
+
+/**
+ * Saves the selected categories into the passed item
+ *
+ * @param aItem The item to set the categories on
+ */
+function saveCategories(aItem) {
+ let categoryPopup = document.getElementById("item-categories-popup");
+ let categoryList = Array.from(
+ categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"),
+ cat => cat.getAttribute("label")
+ );
+ aItem.setCategories(categoryList);
+}
+
+/**
+ * Sets up all date related controls from the passed item
+ *
+ * @param item The item to parse information out of.
+ */
+function loadDateTime(item) {
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ if (item.isEvent()) {
+ let startTime = item.startDate;
+ let endTime = item.endDate;
+ let duration = endTime.subtractDate(startTime);
+
+ // Check if an all-day event has been passed in (to adapt endDate).
+ if (startTime.isDate) {
+ startTime = startTime.clone();
+ endTime = endTime.clone();
+
+ endTime.day--;
+ duration.days--;
+ }
+
+ // store the start/end-times as calIDateTime-objects
+ // converted to the default timezone. store the timezones
+ // separately.
+ gStartTimezone = startTime.timezone;
+ gEndTimezone = endTime.timezone;
+ gStartTime = startTime.getInTimezone(kDefaultTimezone);
+ gEndTime = endTime.getInTimezone(kDefaultTimezone);
+ gItemDuration = duration;
+ }
+
+ if (item.isTodo()) {
+ let startTime = null;
+ let endTime = null;
+ let duration = null;
+
+ let hasEntryDate = item.entryDate != null;
+ if (hasEntryDate) {
+ startTime = item.entryDate;
+ gStartTimezone = startTime.timezone;
+ startTime = startTime.getInTimezone(kDefaultTimezone);
+ } else {
+ gStartTimezone = kDefaultTimezone;
+ }
+ let hasDueDate = item.dueDate != null;
+ if (hasDueDate) {
+ endTime = item.dueDate;
+ gEndTimezone = endTime.timezone;
+ endTime = endTime.getInTimezone(kDefaultTimezone);
+ } else {
+ gEndTimezone = kDefaultTimezone;
+ }
+ if (hasEntryDate && hasDueDate) {
+ duration = endTime.subtractDate(startTime);
+ }
+ document.getElementById("cmd_attendees").setAttribute("disabled", true);
+ document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate);
+ sendMessage({
+ command: "updateConfigState",
+ argument: { attendeesCommand: false },
+ });
+ gStartTime = startTime;
+ gEndTime = endTime;
+ gItemDuration = duration;
+ } else {
+ sendMessage({
+ command: "updateConfigState",
+ argument: { attendeesCommand: true },
+ });
+ }
+}
+
+/**
+ * Toggles the "keep" attribute every time the keepduration-button is pressed.
+ */
+function toggleKeepDuration() {
+ let kdb = document.getElementById("keepduration-button");
+ let keepAttribute = kdb.getAttribute("keep") == "true";
+ // To make the "keep" attribute persistent, it mustn't be removed when in
+ // false state (bug 15232).
+ kdb.setAttribute("keep", keepAttribute ? "false" : "true");
+ document.getElementById("link-image-top").setAttribute("keep", !keepAttribute);
+}
+
+/**
+ * Handler function to be used when the Start time or End time of the event have
+ * changed.
+ * When changing the Start date, the End date changes automatically so the
+ * event/task's duration stays the same. Instead the End date is not linked
+ * to the Start date unless the the keepDurationButton has the "keep" attribute
+ * set to true. In this case modifying the End date changes the Start date in
+ * order to keep the same duration.
+ *
+ * @param aStartDatepicker If true the Start or Entry datepicker has changed,
+ * otherwise the End or Due datepicker has changed.
+ */
+function dateTimeControls2State(aStartDatepicker) {
+ if (gIgnoreUpdate) {
+ return;
+ }
+ let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true";
+ let allDay = document.getElementById("event-all-day").checked;
+ let startWidgetId;
+ let endWidgetId;
+ if (window.calendarItem.isEvent()) {
+ startWidgetId = "event-starttime";
+ endWidgetId = "event-endtime";
+ } else {
+ if (!document.getElementById("todo-has-entrydate").checked) {
+ gItemDuration = null;
+ }
+ if (!document.getElementById("todo-has-duedate").checked) {
+ gItemDuration = null;
+ }
+ startWidgetId = "todo-entrydate";
+ endWidgetId = "todo-duedate";
+ }
+
+ let saveStartTime = gStartTime;
+ let saveEndTime = gEndTime;
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ if (gStartTime) {
+ // jsDate is always in OS timezone, thus we create a calIDateTime
+ // object from the jsDate representation then we convert the timezone
+ // in order to keep gStartTime in default timezone.
+ if (gTimezonesEnabled || allDay) {
+ gStartTime = cal.dtz.jsDateToDateTime(
+ document.getElementById(startWidgetId).value,
+ gStartTimezone
+ );
+ gStartTime = gStartTime.getInTimezone(kDefaultTimezone);
+ } else {
+ gStartTime = cal.dtz.jsDateToDateTime(
+ document.getElementById(startWidgetId).value,
+ kDefaultTimezone
+ );
+ }
+ gStartTime.isDate = allDay;
+ }
+ if (gEndTime) {
+ if (aStartDatepicker) {
+ // Change the End date in order to keep the duration.
+ gEndTime = gStartTime.clone();
+ if (gItemDuration) {
+ gEndTime.addDuration(gItemDuration);
+ }
+ } else {
+ let timezone = gEndTimezone;
+ if (timezone.isUTC) {
+ if (gStartTime && !cal.data.compareObjects(gStartTimezone, gEndTimezone)) {
+ timezone = gStartTimezone;
+ }
+ }
+ if (gTimezonesEnabled || allDay) {
+ gEndTime = cal.dtz.jsDateToDateTime(document.getElementById(endWidgetId).value, timezone);
+ gEndTime = gEndTime.getInTimezone(kDefaultTimezone);
+ } else {
+ gEndTime = cal.dtz.jsDateToDateTime(
+ document.getElementById(endWidgetId).value,
+ kDefaultTimezone
+ );
+ }
+ gEndTime.isDate = allDay;
+ if (keepAttribute && gItemDuration) {
+ // Keepduration-button links the the Start to the End date. We
+ // have to change the Start date in order to keep the duration.
+ let fduration = gItemDuration.clone();
+ fduration.isNegative = true;
+ gStartTime = gEndTime.clone();
+ gStartTime.addDuration(fduration);
+ }
+ }
+ }
+
+ if (allDay) {
+ gStartTime.isDate = true;
+ gEndTime.isDate = true;
+ gItemDuration = gEndTime.subtractDate(gStartTime);
+ }
+
+ // calculate the new duration of start/end-time.
+ // don't allow for negative durations.
+ let warning = false;
+ let stringWarning = "";
+ if (!aStartDatepicker && gStartTime && gEndTime) {
+ if (gEndTime.compare(gStartTime) >= 0) {
+ gItemDuration = gEndTime.subtractDate(gStartTime);
+ } else {
+ gStartTime = saveStartTime;
+ gEndTime = saveEndTime;
+ warning = true;
+ stringWarning = cal.l10n.getCalString("warningEndBeforeStart");
+ }
+ }
+
+ let startChanged = false;
+ if (gStartTime && saveStartTime) {
+ startChanged = gStartTime.compare(saveStartTime) != 0;
+ }
+ // Preset the date in the until-datepicker's minimonth to the new start
+ // date if it has changed.
+ if (startChanged) {
+ let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating));
+ document.getElementById("repeat-until-datepicker").extraDate = startDate;
+ }
+
+ // Sort out and verify the until date if the start date has changed.
+ if (gUntilDate && startChanged) {
+ // Make the time part of the until date equal to the time of start date.
+ updateUntildateRecRule();
+
+ // Don't allow for until date earlier than the start date.
+ if (gUntilDate.compare(gStartTime) < 0) {
+ // We have to restore valid dates. Since the user has intentionally
+ // changed the start date, it looks reasonable to restore a valid
+ // until date equal to the start date.
+ gUntilDate = gStartTime.clone();
+ // Update the until-date-picker. In case of "custom" rule, the
+ // recurrence string is going to be changed by updateDateTime() below.
+ if (
+ !document.getElementById("repeat-untilDate").hidden &&
+ document.getElementById("repeat-details").hidden
+ ) {
+ document.getElementById("repeat-until-datepicker").value = cal.dtz.dateTimeToJsDate(
+ gUntilDate.getInTimezone(cal.dtz.floating)
+ );
+ }
+
+ warning = true;
+ stringWarning = cal.l10n.getCalString("warningUntilDateBeforeStart");
+ }
+ }
+
+ updateDateTime();
+ updateTimezone();
+ updateAccept();
+
+ if (warning) {
+ // Disable the "Save" and "Save and Close" commands as long as the
+ // warning dialog is showed.
+ enableAcceptCommand(false);
+ gWarning = true;
+ let callback = function () {
+ Services.prompt.alert(null, document.title, stringWarning);
+ gWarning = false;
+ updateAccept();
+ };
+ setTimeout(callback, 1);
+ }
+}
+
+/**
+ * Updates the entry date checkboxes, used for example when choosing an alarm:
+ * the entry date needs to be checked in that case.
+ */
+function updateEntryDate() {
+ updateDateCheckboxes("todo-entrydate", "todo-has-entrydate", {
+ isValid() {
+ return gStartTime != null;
+ },
+ setDateTime(date) {
+ gStartTime = date;
+ },
+ });
+}
+
+/**
+ * Updates the due date checkboxes.
+ */
+function updateDueDate() {
+ updateDateCheckboxes("todo-duedate", "todo-has-duedate", {
+ isValid() {
+ return gEndTime != null;
+ },
+ setDateTime(date) {
+ gEndTime = date;
+ },
+ });
+}
+
+/**
+ * Common function used by updateEntryDate and updateDueDate to set up the
+ * checkboxes correctly.
+ *
+ * @param aDatePickerId The XUL id of the datepicker to update.
+ * @param aCheckboxId The XUL id of the corresponding checkbox.
+ * @param aDateTime An object implementing the isValid and setDateTime
+ * methods. XXX explain.
+ */
+function updateDateCheckboxes(aDatePickerId, aCheckboxId, aDateTime) {
+ if (gIgnoreUpdate) {
+ return;
+ }
+
+ if (!window.calendarItem.isTodo()) {
+ return;
+ }
+
+ // force something to get set if there was nothing there before
+ aDatePickerId.value = document.getElementById(aDatePickerId).value;
+
+ // first of all disable the datetime picker if we don't have a date
+ let hasDate = document.getElementById(aCheckboxId).checked;
+ aDatePickerId.disabled = !hasDate;
+
+ // create a new datetime object if date is now checked for the first time
+ if (hasDate && !aDateTime.isValid()) {
+ let date = cal.dtz.jsDateToDateTime(
+ document.getElementById(aDatePickerId).value,
+ cal.dtz.defaultTimezone
+ );
+ aDateTime.setDateTime(date);
+ } else if (!hasDate && aDateTime.isValid()) {
+ aDateTime.setDateTime(null);
+ }
+
+ // calculate the duration if possible
+ let hasEntryDate = document.getElementById("todo-has-entrydate").checked;
+ let hasDueDate = document.getElementById("todo-has-duedate").checked;
+ if (hasEntryDate && hasDueDate) {
+ let start = cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value);
+ let end = cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value);
+ gItemDuration = end.subtractDate(start);
+ } else {
+ gItemDuration = null;
+ }
+ document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate);
+ updateDateTime();
+ updateTimezone();
+}
+
+/**
+ * Get the item's recurrence information for displaying in dialog controls.
+ *
+ * @param {object} aItem - The calendar item
+ * @returns {string[]} An array of two strings: [repeatType, untilDate]
+ */
+function getRepeatTypeAndUntilDate(aItem) {
+ let recurrenceInfo = window.recurrenceInfo;
+ let repeatType = "none";
+ let untilDate = "forever";
+
+ /**
+ * Updates the until date (locally and globally).
+ *
+ * @param aRule The recurrence rule
+ */
+ let updateUntilDate = aRule => {
+ if (!aRule.isByCount) {
+ if (aRule.isFinite) {
+ gUntilDate = aRule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone);
+ untilDate = cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating));
+ } else {
+ gUntilDate = null;
+ }
+ }
+ };
+
+ if (recurrenceInfo) {
+ repeatType = "custom";
+ let ritems = recurrenceInfo.getRecurrenceItems();
+ let rules = [];
+ let exceptions = [];
+ for (let ritem of ritems) {
+ if (ritem.isNegative) {
+ exceptions.push(ritem);
+ } else {
+ rules.push(ritem);
+ }
+ }
+ if (rules.length == 1) {
+ let rule = cal.wrapInstance(rules[0], Ci.calIRecurrenceRule);
+ if (rule) {
+ switch (rule.type) {
+ case "DAILY": {
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ "BYHOUR",
+ "BYMONTHDAY",
+ "BYYEARDAY",
+ "BYWEEKNO",
+ "BYMONTH",
+ "BYSETPOS",
+ ];
+ if (!checkRecurrenceRule(rule, byparts)) {
+ let ruleComp = rule.getComponent("BYDAY");
+ if (rule.interval == 1) {
+ if (ruleComp.length > 0) {
+ if (ruleComp.length == 5) {
+ let found = false;
+ for (let i = 0; i < 5; i++) {
+ if (ruleComp[i] != i + 2) {
+ found = true;
+ break;
+ }
+ }
+ if (!found && (!rule.isFinite || !rule.isByCount)) {
+ repeatType = "every.weekday";
+ updateUntilDate(rule);
+ }
+ }
+ } else if (!rule.isFinite || !rule.isByCount) {
+ repeatType = "daily";
+ updateUntilDate(rule);
+ }
+ }
+ }
+ break;
+ }
+ case "WEEKLY": {
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ "BYDAY",
+ "BYHOUR",
+ "BYMONTHDAY",
+ "BYYEARDAY",
+ "BYWEEKNO",
+ "BYMONTH",
+ "BYSETPOS",
+ ];
+ if (!checkRecurrenceRule(rule, byparts)) {
+ let weekType = ["weekly", "bi.weekly"];
+ if (
+ (rule.interval == 1 || rule.interval == 2) &&
+ (!rule.isFinite || !rule.isByCount)
+ ) {
+ repeatType = weekType[rule.interval - 1];
+ updateUntilDate(rule);
+ }
+ }
+ break;
+ }
+ case "MONTHLY": {
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ "BYDAY",
+ "BYHOUR",
+ "BYMONTHDAY",
+ "BYYEARDAY",
+ "BYWEEKNO",
+ "BYMONTH",
+ "BYSETPOS",
+ ];
+ if (!checkRecurrenceRule(rule, byparts)) {
+ if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) {
+ repeatType = "monthly";
+ updateUntilDate(rule);
+ }
+ }
+ break;
+ }
+ case "YEARLY": {
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ "BYDAY",
+ "BYHOUR",
+ "BYMONTHDAY",
+ "BYYEARDAY",
+ "BYWEEKNO",
+ "BYMONTH",
+ "BYSETPOS",
+ ];
+ if (!checkRecurrenceRule(rule, byparts)) {
+ if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) {
+ repeatType = "yearly";
+ updateUntilDate(rule);
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+ return [repeatType, untilDate];
+}
+
+/**
+ * Updates the XUL UI with the repeat type and the until date.
+ *
+ * @param {string} aRepeatType - The type of repeat
+ * @param {string} aUntilDate - The until date
+ * @param {object} aItem - The calendar item
+ */
+function loadRepeat(aRepeatType, aUntilDate, aItem) {
+ document.getElementById("item-repeat").value = aRepeatType;
+ let repeatMenu = document.getElementById("item-repeat");
+ gLastRepeatSelection = repeatMenu.selectedIndex;
+
+ if (aItem.parentItem != aItem) {
+ document.getElementById("item-repeat").setAttribute("disabled", "true");
+ document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true");
+ }
+ // Show the repeat-until-datepicker and set its date
+ document.getElementById("repeat-untilDate").hidden = false;
+ document.getElementById("repeat-details").hidden = true;
+ document.getElementById("repeat-until-datepicker").value = aUntilDate;
+}
+
+/**
+ * Update reminder related elements on the dialog.
+ *
+ * @param aSuppressDialogs If true, controls are updated without prompting
+ * for changes with the custom dialog
+ */
+function updateReminder(aSuppressDialogs) {
+ window.gLastAlarmSelection = commonUpdateReminder(
+ document.querySelector(".item-alarm"),
+ window.calendarItem,
+ window.gLastAlarmSelection,
+ getCurrentCalendar(),
+ document.querySelector(".reminder-details"),
+ window.gStartTimezone || window.gEndTimezone,
+ aSuppressDialogs
+ );
+ updateAccept();
+}
+
+/**
+ * Saves all values the user chose on the dialog to the passed item
+ *
+ * @param item The item to save to.
+ */
+function saveDialog(item) {
+ // Calendar
+ item.calendar = getCurrentCalendar();
+
+ cal.item.setItemProperty(item, "title", document.getElementById("item-title").value);
+ cal.item.setItemProperty(item, "LOCATION", document.getElementById("item-location").value);
+
+ saveDateTime(item);
+
+ if (item.isTodo()) {
+ let percentCompleteInteger = 0;
+ if (document.getElementById("percent-complete-textbox").value != "") {
+ percentCompleteInteger = parseInt(
+ document.getElementById("percent-complete-textbox").value,
+ 10
+ );
+ }
+ if (percentCompleteInteger < 0) {
+ percentCompleteInteger = 0;
+ } else if (percentCompleteInteger > 100) {
+ percentCompleteInteger = 100;
+ }
+ cal.item.setItemProperty(item, "PERCENT-COMPLETE", percentCompleteInteger);
+ }
+
+ // Categories
+ saveCategories(item);
+
+ // Attachment
+ // We want the attachments to be up to date, remove all first.
+ item.removeAllAttachments();
+
+ // Now add back the new ones
+ for (let hashId in gAttachMap) {
+ let att = gAttachMap[hashId];
+ item.addAttachment(att);
+ }
+
+ // Description
+ let editorElement = document.getElementById("item-description");
+ let editor = editorElement.getHTMLEditor(editorElement.contentWindow);
+ if (editor.documentModified) {
+ // Get editor output as HTML. We request raw output to avoid any
+ // pretty-printing which may cause issues with Google Calendar (see comments
+ // in calViewUtils.fixGoogleCalendarDescription() for more information).
+ let mode =
+ Ci.nsIDocumentEncoder.OutputRaw |
+ Ci.nsIDocumentEncoder.OutputDropInvisibleBreak |
+ Ci.nsIDocumentEncoder.OutputBodyOnly;
+
+ const editorOutput = editor.outputToString("text/html", mode);
+
+ // The editor gives us output wrapped in a body tag. We don't really want
+ // that, so strip it. (Yes, it's a regex with HTML, but a _very_ specific
+ // one.) We use the `s` flag to match across newlines in case there's a
+ // <pre/> tag, in which case <br/> will not be inserted.
+ item.descriptionHTML = editorOutput.replace(/^<body>(.+)<\/body>$/s, "$1");
+ }
+
+ // Event Status
+ if (item.isEvent()) {
+ if (gConfig.status && gConfig.status != "NONE") {
+ item.setProperty("STATUS", gConfig.status);
+ } else {
+ item.deleteProperty("STATUS");
+ }
+ } else {
+ let status = document.getElementById("todo-status").value;
+ if (status != "COMPLETED") {
+ item.completedDate = null;
+ }
+ cal.item.setItemProperty(item, "STATUS", status == "NONE" ? null : status);
+ }
+
+ // set the "PRIORITY" property if a valid priority has been
+ // specified (any integer value except *null*) OR the item
+ // already specifies a priority. in any other case we don't
+ // need this property and can safely delete it. we need this special
+ // handling since the WCAP provider always includes the priority
+ // with value *null* and we don't detect changes to this item if
+ // we delete this property.
+ if (capSupported("priority") && (gConfig.priority || item.hasProperty("PRIORITY"))) {
+ item.setProperty("PRIORITY", gConfig.priority);
+ } else {
+ item.deleteProperty("PRIORITY");
+ }
+
+ // Transparency
+ if (gConfig.showTimeAs) {
+ item.setProperty("TRANSP", gConfig.showTimeAs);
+ } else {
+ item.deleteProperty("TRANSP");
+ }
+
+ // Privacy
+ cal.item.setItemProperty(item, "CLASS", gConfig.privacy, "privacy");
+
+ if (item.status == "COMPLETED" && item.isTodo()) {
+ let elementValue = document.getElementById("completed-date-picker").value;
+ item.completedDate = cal.dtz.jsDateToDateTime(elementValue);
+ }
+
+ saveReminder(item, getCurrentCalendar(), document.querySelector(".item-alarm"));
+}
+
+/**
+ * Save date and time related values from the dialog to the passed item.
+ *
+ * @param item The item to save to.
+ */
+function saveDateTime(item) {
+ // Changes to the start date don't have to change the until date.
+ untilDateCompensation(item);
+
+ if (item.isEvent()) {
+ let startTime = gStartTime.getInTimezone(gStartTimezone);
+ let endTime = gEndTime.getInTimezone(gEndTimezone);
+ let isAllDay = document.getElementById("event-all-day").checked;
+ if (isAllDay) {
+ startTime = startTime.clone();
+ endTime = endTime.clone();
+ startTime.isDate = true;
+ endTime.isDate = true;
+ endTime.day += 1;
+ } else {
+ startTime = startTime.clone();
+ startTime.isDate = false;
+ endTime = endTime.clone();
+ endTime.isDate = false;
+ }
+ cal.item.setItemProperty(item, "startDate", startTime);
+ cal.item.setItemProperty(item, "endDate", endTime);
+ }
+ if (item.isTodo()) {
+ let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone);
+ let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone);
+ cal.item.setItemProperty(item, "entryDate", startTime);
+ cal.item.setItemProperty(item, "dueDate", endTime);
+ }
+}
+
+/**
+ * Changes the until date in the rule in order to compensate the automatic
+ * correction caused by the function onStartDateChange() when saving the
+ * item.
+ * It allows to keep the until date set in the dialog irrespective of the
+ * changes that the user has done to the start date.
+ */
+function untilDateCompensation(aItem) {
+ // The current start date in the item is always the date that we get
+ // when opening the dialog or after the last save.
+ let startDate = aItem[cal.dtz.startDateProp(aItem)];
+
+ if (aItem.recurrenceInfo) {
+ let rrules = splitRecurrenceRules(aItem.recurrenceInfo);
+ let rule = rrules[0][0];
+ if (!rule.isByCount && rule.isFinite && startDate) {
+ let compensation = startDate.subtractDate(gStartTime);
+ if (compensation != "PT0S") {
+ let untilDate = rule.untilDate.clone();
+ untilDate.addDuration(compensation);
+ rule.untilDate = untilDate;
+ }
+ }
+ }
+}
+
+/**
+ * Updates the dialog title based on item type and if the item is new or to be
+ * modified.
+ */
+function updateTitle() {
+ let strName;
+ if (window.calendarItem.isEvent()) {
+ strName = window.mode == "new" ? "newEventDialog" : "editEventDialog";
+ } else if (window.calendarItem.isTodo()) {
+ strName = window.mode == "new" ? "newTaskDialog" : "editTaskDialog";
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ sendMessage({
+ command: "updateTitle",
+ prefix: cal.l10n.getCalString(strName),
+ title: document.getElementById("item-title").value,
+ });
+}
+
+/**
+ * Update the disabled status of the accept button. The button is enabled if all
+ * parts of the dialog have options selected that make sense.
+ * constraining factors like
+ */
+function updateAccept() {
+ let enableAccept = true;
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ let startDate;
+ let endDate;
+ let isEvent = window.calendarItem.isEvent();
+
+ // don't allow for end dates to be before start dates
+ if (isEvent) {
+ startDate = cal.dtz.jsDateToDateTime(document.getElementById("event-starttime").value);
+ endDate = cal.dtz.jsDateToDateTime(document.getElementById("event-endtime").value);
+ } else {
+ startDate = document.getElementById("todo-has-entrydate").checked
+ ? cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value)
+ : null;
+ endDate = document.getElementById("todo-has-duedate").checked
+ ? cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value)
+ : null;
+ }
+
+ if (startDate && endDate) {
+ if (gTimezonesEnabled) {
+ let startTimezone = gStartTimezone;
+ let endTimezone = gEndTimezone;
+ if (endTimezone.isUTC) {
+ if (!cal.data.compareObjects(gStartTimezone, gEndTimezone)) {
+ endTimezone = gStartTimezone;
+ }
+ }
+
+ startDate = startDate.getInTimezone(kDefaultTimezone);
+ endDate = endDate.getInTimezone(kDefaultTimezone);
+
+ startDate.timezone = startTimezone;
+ endDate.timezone = endTimezone;
+ }
+
+ startDate = startDate.getInTimezone(kDefaultTimezone);
+ endDate = endDate.getInTimezone(kDefaultTimezone);
+
+ // For all-day events we are not interested in times and compare only
+ // dates.
+ if (isEvent && document.getElementById("event-all-day").checked) {
+ // jsDateToDateTime returns the values in UTC. Depending on the
+ // local timezone and the values selected in datetimepicker the date
+ // in UTC might be shifted to the previous or next day.
+ // For example: The user (with local timezone GMT+05) selected
+ // Feb 10 2006 00:00:00. The corresponding value in UTC is
+ // Feb 09 2006 19:00:00. If we now set isDate to true we end up with
+ // a date of Feb 09 2006 instead of Feb 10 2006 resulting in errors
+ // during the following comparison.
+ // Calling getInTimezone() ensures that we use the same dates as
+ // displayed to the user in datetimepicker for comparison.
+ startDate.isDate = true;
+ endDate.isDate = true;
+ }
+ }
+
+ if (endDate && startDate && endDate.compare(startDate) == -1) {
+ enableAccept = false;
+ }
+
+ enableAcceptCommand(enableAccept);
+
+ return enableAccept;
+}
+
+/**
+ * Enables/disables the commands cmd_accept and cmd_save related to the
+ * save operation.
+ *
+ * @param aEnable true: enables the command
+ */
+function enableAcceptCommand(aEnable) {
+ sendMessage({ command: "enableAcceptCommand", argument: aEnable });
+}
+
+// Global variables used to restore start and end date-time when changing the
+// "all day" status in the onUpdateAllday() function.
+var gOldStartTime = null;
+var gOldEndTime = null;
+var gOldStartTimezone = null;
+var gOldEndTimezone = null;
+
+/**
+ * Handler function to update controls and state in consequence of the "all
+ * day" checkbox being clicked.
+ */
+function onUpdateAllDay() {
+ if (!window.calendarItem.isEvent()) {
+ return;
+ }
+ let allDay = document.getElementById("event-all-day").checked;
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ if (allDay) {
+ // Store date-times and related timezones so we can restore
+ // if the user unchecks the "all day" checkbox.
+ gOldStartTime = gStartTime.clone();
+ gOldEndTime = gEndTime.clone();
+ gOldStartTimezone = gStartTimezone;
+ gOldEndTimezone = gEndTimezone;
+ // When events that end at 0:00 become all-day events, we need to
+ // subtract a day from the end date because the real end is midnight.
+ if (gEndTime.hour == 0 && gEndTime.minute == 0) {
+ let tempStartTime = gStartTime.clone();
+ let tempEndTime = gEndTime.clone();
+ tempStartTime.isDate = true;
+ tempEndTime.isDate = true;
+ tempStartTime.day++;
+ if (tempEndTime.compare(tempStartTime) >= 0) {
+ gEndTime.day--;
+ }
+ }
+ } else {
+ gStartTime.isDate = false;
+ gEndTime.isDate = false;
+ if (!gOldStartTime && !gOldEndTime) {
+ // The checkbox has been unchecked for the first time, the event
+ // was an "All day" type, so we have to set default values.
+ gStartTime.hour = cal.dtz.getDefaultStartDate(window.initialStartDateValue).hour;
+ gEndTime.hour = gStartTime.hour;
+ gEndTime.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ gOldStartTimezone = kDefaultTimezone;
+ gOldEndTimezone = kDefaultTimezone;
+ } else {
+ // Restore date-times previously stored.
+ gStartTime.hour = gOldStartTime.hour;
+ gStartTime.minute = gOldStartTime.minute;
+ gEndTime.hour = gOldEndTime.hour;
+ gEndTime.minute = gOldEndTime.minute;
+ // When we restore 0:00 as end time, we need to add one day to
+ // the end date in order to include the last day until midnight.
+ if (gEndTime.hour == 0 && gEndTime.minute == 0) {
+ gEndTime.day++;
+ }
+ }
+ }
+ gStartTimezone = allDay ? cal.dtz.floating : gOldStartTimezone;
+ gEndTimezone = allDay ? cal.dtz.floating : gOldEndTimezone;
+ setShowTimeAs(allDay);
+
+ updateAllDay();
+}
+
+/**
+ * This function sets the enabled/disabled state of the following controls:
+ * - 'event-starttime'
+ * - 'event-endtime'
+ * - 'timezone-starttime'
+ * - 'timezone-endtime'
+ * the state depends on whether or not the event is configured as 'all-day' or not.
+ */
+function updateAllDay() {
+ if (gIgnoreUpdate) {
+ return;
+ }
+
+ if (!window.calendarItem.isEvent()) {
+ return;
+ }
+
+ let allDay = document.getElementById("event-all-day").checked;
+ if (allDay) {
+ document.getElementById("event-starttime").setAttribute("timepickerdisabled", true);
+ document.getElementById("event-endtime").setAttribute("timepickerdisabled", true);
+ } else {
+ document.getElementById("event-starttime").removeAttribute("timepickerdisabled");
+ document.getElementById("event-endtime").removeAttribute("timepickerdisabled");
+ }
+
+ gStartTime.isDate = allDay;
+ gEndTime.isDate = allDay;
+ gItemDuration = gEndTime.subtractDate(gStartTime);
+
+ updateDateTime();
+ updateUntildateRecRule();
+ updateRepeatDetails();
+ updateAccept();
+}
+
+/**
+ * Use the window arguments to cause the opener to create a new event on the
+ * item's calendar
+ */
+function openNewEvent() {
+ let item = window.calendarItem;
+ let args = window.arguments[0];
+ args.onNewEvent(item.calendar);
+}
+
+/**
+ * Use the window arguments to cause the opener to create a new event on the
+ * item's calendar
+ */
+function openNewTask() {
+ let item = window.calendarItem;
+ let args = window.arguments[0];
+ args.onNewTodo(item.calendar);
+}
+
+/**
+ * Update the transparency status of this dialog, depending on if the event
+ * is all-day or not.
+ *
+ * @param allDay If true, the event is all-day
+ */
+function setShowTimeAs(allDay) {
+ gConfig.showTimeAs = cal.item.getEventDefaultTransparency(allDay);
+ updateConfigState({ showTimeAs: gConfig.showTimeAs });
+}
+
+function editAttendees() {
+ let savedWindow = window;
+ let calendar = getCurrentCalendar();
+
+ let callback = function (attendees, organizer, startTime, endTime) {
+ savedWindow.attendees = attendees;
+ savedWindow.organizer = organizer;
+
+ // if a participant was added or removed we switch to the attendee
+ // tab, so the user can see the change directly
+ let tabs = document.getElementById("event-grid-tabs");
+ let attendeeTab = document.getElementById("event-grid-tab-attendees");
+ tabs.selectedItem = attendeeTab;
+
+ let duration = endTime.subtractDate(startTime);
+ startTime = startTime.clone();
+ endTime = endTime.clone();
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ gStartTimezone = startTime.timezone;
+ gEndTimezone = endTime.timezone;
+ gStartTime = startTime.getInTimezone(kDefaultTimezone);
+ gEndTime = endTime.getInTimezone(kDefaultTimezone);
+ gItemDuration = duration;
+ updateAttendeeInterface();
+ updateDateTime();
+ updateAllDay();
+
+ if (isAllDay != gStartTime.isDate) {
+ setShowTimeAs(gStartTime.isDate);
+ }
+ };
+
+ let startTime = gStartTime.getInTimezone(gStartTimezone);
+ let endTime = gEndTime.getInTimezone(gEndTimezone);
+
+ let isAllDay = document.getElementById("event-all-day").checked;
+ if (isAllDay) {
+ startTime.isDate = true;
+ endTime.isDate = true;
+ endTime.day += 1;
+ } else {
+ startTime.isDate = false;
+ endTime.isDate = false;
+ }
+ let args = {};
+ args.startTime = startTime;
+ args.endTime = endTime;
+ args.displayTimezone = gTimezonesEnabled;
+ args.attendees = window.attendees;
+ args.organizer = window.organizer && window.organizer.clone();
+ args.calendar = calendar;
+ args.item = window.calendarItem;
+ args.onOk = callback;
+
+ // open the dialog modally
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+}
+
+/**
+ * Updates the UI outside of the iframe (toolbar, menu, statusbar, etc.)
+ * for changes in priority, privacy, status, showTimeAs/transparency,
+ * and/or other properties. This function should be called any time that
+ * gConfig.privacy, gConfig.priority, etc. are updated.
+ *
+ * Privacy and priority updates depend on the selected calendar. If the
+ * selected calendar does not support them, or only supports certain
+ * values, these are removed from the UI.
+ *
+ * @param {object} aArg - Container
+ * @param {string} aArg.privacy - (optional) The new privacy value
+ * @param {short} aArg.priority - (optional) The new priority value
+ * @param {string} aArg.status - (optional) The new status value
+ * @param {string} aArg.showTimeAs - (optional) The new transparency value
+ */
+function updateConfigState(aArg) {
+ // We include additional info for priority and privacy.
+ if (aArg.hasOwnProperty("priority")) {
+ aArg.hasPriority = capSupported("priority");
+ }
+ if (aArg.hasOwnProperty("privacy")) {
+ Object.assign(aArg, {
+ hasPrivacy: capSupported("privacy"),
+ calendarType: getCurrentCalendar().type,
+ privacyValues: capValues("privacy", ["PUBLIC", "CONFIDENTIAL", "PRIVATE"]),
+ });
+ }
+
+ // For tasks, do not include showTimeAs
+ if (aArg.hasOwnProperty("showTimeAs") && window.calendarItem.isTodo()) {
+ delete aArg.showTimeAs;
+ if (Object.keys(aArg).length == 0) {
+ return;
+ }
+ }
+
+ sendMessage({ command: "updateConfigState", argument: aArg });
+}
+
+/**
+ * Add menu items to the UI for attaching files using cloud providers.
+ */
+function loadCloudProviders() {
+ let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false);
+ let cmd = document.getElementById("cmd_attach_cloud");
+ let message = {
+ command: "setElementAttribute",
+ argument: { id: "cmd_attach_cloud", attribute: "hidden", value: null },
+ };
+
+ if (!cloudFileEnabled) {
+ // If cloud file support is disabled, just hide the attach item
+ cmd.hidden = true;
+ message.argument.value = true;
+ sendMessage(message);
+ return;
+ }
+
+ let isHidden = cloudFileAccounts.configuredAccounts.length == 0;
+ cmd.hidden = isHidden;
+ message.argument.value = isHidden;
+ sendMessage(message);
+
+ let itemObjects = [];
+
+ for (let cloudProvider of cloudFileAccounts.configuredAccounts) {
+ // Create a serializable object to pass in a message outside the iframe
+ let itemObject = {};
+ itemObject.displayName = cloudFileAccounts.getDisplayName(cloudProvider);
+ itemObject.label = cal.l10n.getString("calendar-event-dialog", "attachViaFilelink", [
+ itemObject.displayName,
+ ]);
+ itemObject.cloudProviderAccountKey = cloudProvider.accountKey;
+ if (cloudProvider.iconURL) {
+ itemObject.class = "menuitem-iconic";
+ itemObject.image = cloudProvider.iconURL;
+ }
+
+ itemObjects.push(itemObject);
+
+ // Create a menu item from the serializable object
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", itemObject.label);
+ item.setAttribute("observes", "cmd_attach_cloud");
+ item.setAttribute(
+ "oncommand",
+ "attachFile(event.target.cloudProvider); event.stopPropagation();"
+ );
+
+ if (itemObject.class) {
+ item.setAttribute("class", itemObject.class);
+ item.setAttribute("image", itemObject.image);
+ }
+
+ // Add the menu item to places inside the iframe where we advertise cloud providers
+ let attachmentPopup = document.getElementById("attachment-popup");
+ attachmentPopup.appendChild(item).cloudProvider = cloudProvider;
+ }
+
+ // Add the items to places outside the iframe where we advertise cloud providers
+ sendMessage({ command: "loadCloudProviders", items: itemObjects });
+}
+
+/**
+ * Prompts the user to attach an url to this item.
+ */
+function attachURL() {
+ if (Services.prompt) {
+ // ghost in an example...
+ let result = { value: "http://" };
+ let confirm = Services.prompt.prompt(
+ window,
+ cal.l10n.getString("calendar-event-dialog", "specifyLinkLocation"),
+ cal.l10n.getString("calendar-event-dialog", "enterLinkLocation"),
+ result,
+ null,
+ { value: 0 }
+ );
+
+ if (confirm) {
+ try {
+ // If something bogus was entered, Services.io.newURI may fail.
+ let attachment = new CalAttachment();
+ attachment.uri = Services.io.newURI(result.value);
+ addAttachment(attachment);
+ // we switch to the attachment tab if it is not already displayed
+ // to allow the user to see the attachment was added
+ let tabs = document.getElementById("event-grid-tabs");
+ let attachTab = document.getElementById("event-grid-tab-attachments");
+ tabs.selectedItem = attachTab;
+ } catch (e) {
+ // TODO We might want to show a warning instead of just not
+ // adding the file
+ }
+ }
+ }
+}
+
+/**
+ * Attach a file using a cloud provider, identified by its accountKey.
+ *
+ * @param {string} aAccountKey - The accountKey for a cloud provider
+ */
+function attachFileByAccountKey(aAccountKey) {
+ for (let cloudProvider of cloudFileAccounts.configuredAccounts) {
+ if (aAccountKey == cloudProvider.accountKey) {
+ attachFile(cloudProvider);
+ return;
+ }
+ }
+}
+
+/**
+ * Attach a file to the item. Not passing a cloud provider is currently unsupported.
+ *
+ * @param cloudProvider If set, the cloud provider will be used for attaching
+ */
+function attachFile(cloudProvider) {
+ if (!cloudProvider) {
+ cal.ERROR(
+ "[calendar-event-dialog] Could not attach file without cloud provider" + cal.STACK(10)
+ );
+ }
+
+ let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ filePicker.init(
+ window,
+ cal.l10n.getString("calendar-event-dialog", "selectAFile"),
+ Ci.nsIFilePicker.modeOpenMultiple
+ );
+
+ // Check for the last directory
+ let lastDir = lastDirectory();
+ if (lastDir) {
+ filePicker.displayDirectory = lastDir;
+ }
+
+ filePicker.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !filePicker.files) {
+ return;
+ }
+
+ // Create the attachment
+ for (let file of filePicker.files) {
+ let fileHandler = Services.io
+ .getProtocolHandler("file")
+ .QueryInterface(Ci.nsIFileProtocolHandler);
+ let uriSpec = fileHandler.getURLSpecFromActualFile(file);
+
+ if (!(uriSpec in gAttachMap)) {
+ // If the attachment hasn't been added, then set the last display
+ // directory.
+ lastDirectory(uriSpec);
+
+ // ... and add the attachment.
+ let attachment = new CalAttachment();
+ if (cloudProvider) {
+ attachment.uri = Services.io.newURI(uriSpec);
+ } else {
+ // TODO read file into attachment
+ }
+ addAttachment(attachment, cloudProvider);
+ }
+ }
+ });
+}
+
+/**
+ * Helper function to remember the last directory chosen when attaching files.
+ *
+ * @param aFileUri (optional) If passed, the last directory will be set and
+ * returned. If null, the last chosen directory
+ * will be returned.
+ * @returns The last directory that was set with this function.
+ */
+function lastDirectory(aFileUri) {
+ if (aFileUri) {
+ // Act similar to a setter, save the passed uri.
+ let uri = Services.io.newURI(aFileUri);
+ let file = uri.QueryInterface(Ci.nsIFileURL).file;
+ lastDirectory.mValue = file.parent.QueryInterface(Ci.nsIFile);
+ }
+
+ // In any case, return the value
+ return lastDirectory.mValue === undefined ? null : lastDirectory.mValue;
+}
+
+/**
+ * Turns an url into a string that can be used in UI.
+ * - For a file:// url, shows the filename.
+ * - For a http:// url, removes protocol and trailing slash
+ *
+ * @param aUri The uri to parse.
+ * @returns A string that can be used in UI.
+ */
+function makePrettyName(aUri) {
+ let name = aUri.spec;
+
+ if (aUri.schemeIs("file")) {
+ name = aUri.spec.split("/").pop();
+ } else if (aUri.schemeIs("http")) {
+ name = aUri.spec.replace(/\/$/, "").replace(/^http:\/\//, "");
+ }
+ return name;
+}
+
+/**
+ * Asynchronously uploads the given attachment to the cloud provider, updating
+ * the passed listItem as things progress.
+ *
+ * @param attachment A calIAttachment to upload.
+ * @param cloudFileAccount The cloud file account used for uploading.
+ * @param listItem The listitem in attachment-link listbox to update.
+ */
+function uploadCloudAttachment(attachment, cloudFileAccount, listItem) {
+ let file = attachment.uri.QueryInterface(Ci.nsIFileURL).file;
+ let image = listItem.querySelector("img");
+ listItem.attachCloudFileAccount = cloudFileAccount;
+ image.setAttribute("src", "chrome://global/skin/icons/loading.png");
+ // WebExtension APIs do not support calendar tabs.
+ cloudFileAccount.uploadFile(null, file, attachment.name).then(
+ upload => {
+ delete gAttachMap[attachment.hashId];
+ attachment.uri = Services.io.newURI(upload.url);
+ attachment.setParameter("FILENAME", file.leafName);
+ attachment.setParameter("X-SERVICE-ICONURL", upload.serviceIcon);
+ listItem.setAttribute("label", file.leafName);
+ gAttachMap[attachment.hashId] = attachment;
+ image.setAttribute("src", upload.serviceIcon);
+ listItem.attachCloudFileUpload = upload;
+ updateAttachment();
+ },
+ statusCode => {
+ cal.ERROR(
+ "[calendar-event-dialog] Uploading cloud attachment failed. Status code: " +
+ statusCode.result
+ );
+
+ // Uploading failed. First of all, show an error icon. Also,
+ // delete it from the attach map now, this will make sure it is
+ // not serialized if the user saves.
+ image.setAttribute("src", "chrome://messenger/skin/icons/error.png");
+ delete gAttachMap[attachment.hashId];
+
+ // Keep the item for a while so the user can see something failed.
+ // When we have a nice notification bar, we can show more info
+ // about the failure.
+ setTimeout(() => {
+ listItem.remove();
+ updateAttachment();
+ }, 5000);
+ }
+ );
+}
+
+/**
+ * Adds the given attachment to dialog controls.
+ *
+ * @param attachment The calIAttachment object to add
+ * @param cloudFileAccount (optional) If set, the given cloud file account will be used.
+ */
+function addAttachment(attachment, cloudFileAccount) {
+ if (!attachment || !attachment.hashId || attachment.hashId in gAttachMap) {
+ return;
+ }
+
+ // We currently only support uri attachments
+ if (attachment.uri) {
+ let documentLink = document.getElementById("attachment-link");
+ let listItem = document.createXULElement("richlistitem");
+ let image = document.createElement("img");
+ image.setAttribute("alt", "");
+ image.width = "24";
+ image.height = "24";
+ // Allow the moz-icon src to be invalid.
+ image.classList.add("invisible-on-broken");
+ listItem.appendChild(image);
+ let label = document.createXULElement("label");
+ label.setAttribute("value", makePrettyName(attachment.uri));
+ label.setAttribute("crop", "end");
+ listItem.appendChild(label);
+ listItem.setAttribute("tooltiptext", attachment.uri.spec);
+ if (cloudFileAccount) {
+ if (attachment.uri.schemeIs("file")) {
+ // Its still a local url, needs to be uploaded
+ image.setAttribute("src", "chrome://messenger/skin/icons/connecting.png");
+ uploadCloudAttachment(attachment, cloudFileAccount, listItem);
+ } else {
+ let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL");
+ image.setAttribute("src", cloudFileIconURL);
+ let leafName = attachment.getParameter("FILENAME");
+ if (leafName) {
+ listItem.setAttribute("label", leafName);
+ }
+ }
+ } else if (attachment.uri.schemeIs("file")) {
+ image.setAttribute("src", "moz-icon://" + attachment.uri.spec);
+ } else {
+ let leafName = attachment.getParameter("FILENAME");
+ let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL");
+ let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false);
+
+ if (leafName) {
+ // TODO security issues?
+ listItem.setAttribute("label", leafName);
+ }
+ if (cloudFileIconURL && cloudFileEnabled) {
+ image.setAttribute("src", cloudFileIconURL);
+ } else {
+ let iconSrc = attachment.uri.spec.length ? attachment.uri.spec : "dummy.html";
+ if (attachment.formatType) {
+ iconSrc = "goat?contentType=" + attachment.formatType;
+ } else {
+ // let's try to auto-detect
+ let parts = iconSrc.substr(attachment.uri.scheme.length + 2).split("/");
+ if (parts.length) {
+ iconSrc = parts[parts.length - 1];
+ }
+ }
+ image.setAttribute("src", "moz-icon://" + iconSrc);
+ }
+ }
+
+ // Now that everything is set up, add it to the attachment box.
+ documentLink.appendChild(listItem);
+
+ // full attachment object is stored here
+ listItem.attachment = attachment;
+
+ // Update the number of rows and save our attachment globally
+ documentLink.rows = documentLink.getRowCount();
+ }
+
+ gAttachMap[attachment.hashId] = attachment;
+ updateAttachment();
+}
+
+/**
+ * Removes the currently selected attachment from the dialog controls.
+ *
+ * XXX This could use a dialog maybe?
+ */
+function deleteAttachment() {
+ let documentLink = document.getElementById("attachment-link");
+ let item = documentLink.selectedItem;
+ delete gAttachMap[item.attachment.hashId];
+
+ if (item.attachCloudFileAccount && item.attachCloudFileUpload) {
+ try {
+ // WebExtension APIs do not support calendar tabs.
+ item.attachCloudFileAccount
+ .deleteFile(null, item.attachCloudFileUpload.id)
+ .catch(statusCode => {
+ // TODO With a notification bar, we could actually show this error.
+ cal.ERROR(
+ "[calendar-event-dialog] Deleting cloud attachment " +
+ "failed, file will remain on server. " +
+ " Status code: " +
+ statusCode
+ );
+ });
+ } catch (e) {
+ cal.ERROR(
+ "[calendar-event-dialog] Deleting cloud attachment " +
+ "failed, file will remain on server. " +
+ "Exception: " +
+ e
+ );
+ }
+ }
+ item.remove();
+
+ updateAttachment();
+}
+
+/**
+ * Removes all attachments from the dialog controls.
+ */
+function deleteAllAttachments() {
+ let documentLink = document.getElementById("attachment-link");
+ let itemCount = documentLink.getRowCount();
+ let canRemove = itemCount < 2;
+
+ if (itemCount > 1) {
+ let removeText = PluralForm.get(
+ itemCount,
+ cal.l10n.getString("calendar-event-dialog", "removeAttachmentsText")
+ );
+ let removeTitle = cal.l10n.getString("calendar-event-dialog", "removeCalendarsTitle");
+ canRemove = Services.prompt.confirm(
+ window,
+ removeTitle,
+ removeText.replace("#1", itemCount),
+ {}
+ );
+ }
+
+ if (canRemove) {
+ while (documentLink.lastChild) {
+ documentLink.lastChild.attachment = null;
+ documentLink.lastChild.remove();
+ }
+ gAttachMap = {};
+ }
+ updateAttachment();
+}
+
+/**
+ * Opens the selected attachment using the external protocol service.
+ *
+ * @see nsIExternalProtocolService
+ */
+function openAttachment() {
+ // Only one file has to be selected and we don't handle base64 files at all
+ let documentLink = document.getElementById("attachment-link");
+ if (documentLink.selectedItem) {
+ let attURI = documentLink.selectedItem.attachment.uri;
+ let externalLoader = Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService(
+ Ci.nsIExternalProtocolService
+ );
+ // TODO There should be a nicer dialog
+ externalLoader.loadURI(attURI);
+ }
+}
+
+/**
+ * Copies the link location of the first selected attachment to the clipboard
+ */
+function copyAttachment() {
+ let documentLink = document.getElementById("attachment-link");
+ if (documentLink.selectedItem) {
+ let attURI = documentLink.selectedItem.attachment.uri.spec;
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
+ clipboard.copyString(attURI);
+ }
+}
+
+/**
+ * Handler function to handle pressing keys in the attachment listbox.
+ *
+ * @param aEvent The DOM event caused by the key press.
+ */
+function attachmentLinkKeyPress(aEvent) {
+ switch (aEvent.key) {
+ case "Backspace":
+ case "Delete":
+ deleteAttachment();
+ break;
+ case "Enter":
+ openAttachment();
+ aEvent.preventDefault();
+ break;
+ }
+}
+
+/**
+ * Handler function to take care of double clicking on an attachment
+ *
+ * @param aEvent The DOM event caused by the clicking.
+ */
+function attachmentDblClick(aEvent) {
+ let item = aEvent.target;
+ while (item && item.localName != "richlistbox" && item.localName != "richlistitem") {
+ item = item.parentNode;
+ }
+
+ // left double click on a list item
+ if (item.localName == "richlistitem" && aEvent.button == 0) {
+ openAttachment();
+ }
+}
+
+/**
+ * Handler function to take care of right clicking on an attachment or the attachment list
+ *
+ * @param aEvent The DOM event caused by the clicking.
+ */
+function attachmentClick(aEvent) {
+ let item = aEvent.target.triggerNode;
+ while (item && item.localName != "richlistbox" && item.localName != "richlistitem") {
+ item = item.parentNode;
+ }
+
+ for (let node of aEvent.target.children) {
+ if (item.localName == "richlistitem" || node.id == "attachment-popup-attachPage") {
+ node.removeAttribute("hidden");
+ } else {
+ node.setAttribute("hidden", "true");
+ }
+ }
+}
+
+/**
+ * Helper function to show a notification in the event-dialog's notificationbox
+ *
+ * @param aMessage the message text to show
+ * @param aValue string identifying the notification
+ * @param aPriority (optional) the priority of the warning (info, critical), default is 'warn'
+ * @param aImage (optional) URL of image to appear on the notification
+ * @param aButtonset (optional) array of button descriptions to appear on the notification
+ * @param aCallback (optional) a function to handle events from the notificationbox
+ */
+function notifyUser(aMessage, aValue, aPriority, aImage, aButtonset, aCallback) {
+ // only append, if the notification does not already exist
+ if (gEventNotification.getNotificationWithValue(aValue) == null) {
+ const prioMap = {
+ info: gEventNotification.PRIORITY_INFO_MEDIUM,
+ critical: gEventNotification.PRIORITY_CRITICAL_MEDIUM,
+ };
+ let prio = prioMap[aPriority] || gEventNotification.PRIORITY_WARNING_MEDIUM;
+ gEventNotification.appendNotification(
+ aValue,
+ {
+ label: aMessage,
+ image: aImage,
+ priority: prio,
+ eventCallback: aCallback,
+ },
+ aButtonset
+ );
+ }
+}
+
+/**
+ * Remove a notification from the notifiactionBox
+ *
+ * @param {string} aValue - string identifying the notification to remove
+ */
+function removeNotification(aValue) {
+ let notification = gEventNotification.getNotificationWithValue(aValue);
+ if (notification) {
+ gEventNotification.removeNotification(notification);
+ }
+}
+
+/**
+ * Update the dialog controls related to the item's calendar.
+ */
+function updateCalendar() {
+ let item = window.calendarItem;
+ let calendar = getCurrentCalendar();
+
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ document
+ .getElementById("item-calendar")
+ .style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`);
+
+ gIsReadOnly = calendar.readOnly;
+
+ if (!gPreviousCalendarId) {
+ gPreviousCalendarId = item.calendar.id;
+ }
+
+ // We might have to change the organizer, let's see
+ let calendarOrgId = calendar.getProperty("organizerId");
+ if (window.organizer && calendarOrgId && calendar.id != gPreviousCalendarId) {
+ window.organizer.id = calendarOrgId;
+ window.organizer.commonName = calendar.getProperty("organizerCN");
+ gPreviousCalendarId = calendar.id;
+ updateAttendeeInterface();
+ }
+
+ if (!canNotifyAttendees(calendar, item) && calendar.getProperty("imip.identity")) {
+ document.getElementById("notify-attendees-checkbox").removeAttribute("disabled");
+ document.getElementById("undisclose-attendees-checkbox").removeAttribute("disabled");
+ } else {
+ document.getElementById("notify-attendees-checkbox").setAttribute("disabled", "true");
+ document.getElementById("undisclose-attendees-checkbox").setAttribute("disabled", "true");
+ }
+
+ // update the accept button
+ updateAccept();
+
+ // TODO: the code above decided about whether or not the item is readonly.
+ // below we enable/disable all controls based on this decision.
+ // unfortunately some controls need to be disabled based on some other
+ // criteria. this is why we enable all controls in case the item is *not*
+ // readonly and run through all those updateXXX() functions to disable
+ // them again based on the specific logic build into those function. is this
+ // really a good idea?
+ if (gIsReadOnly) {
+ let disableElements = document.getElementsByAttribute("disable-on-readonly", "true");
+ for (let element of disableElements) {
+ if (element.namespaceURI == "http://www.w3.org/1999/xhtml") {
+ element.setAttribute("disabled", "disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+
+ // we mark link-labels with the hyperlink attribute, since we need
+ // to remove their class in case they get disabled. TODO: it would
+ // be better to create a small binding for those link-labels
+ // instead of adding those special stuff.
+ if (element.hasAttribute("hyperlink")) {
+ element.removeAttribute("class");
+ element.removeAttribute("onclick");
+ }
+ }
+
+ let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true");
+ for (let element of collapseElements) {
+ element.setAttribute("collapsed", "true");
+ }
+ } else {
+ sendMessage({ command: "removeDisableAndCollapseOnReadonly" });
+
+ let enableElements = document.getElementsByAttribute("disable-on-readonly", "true");
+ for (let element of enableElements) {
+ element.removeAttribute("disabled");
+ if (element.hasAttribute("hyperlink")) {
+ element.classList.add("text-link");
+ }
+ }
+
+ let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true");
+ for (let element of collapseElements) {
+ element.removeAttribute("collapsed");
+ }
+
+ if (item.isTodo()) {
+ // Task completed date
+ if (item.completedDate) {
+ updateToDoStatus(item.status, cal.dtz.dateTimeToJsDate(item.completedDate));
+ } else {
+ updateToDoStatus(item.status);
+ }
+ }
+
+ // disable repeat menupopup if this is an occurrence
+ item = window.calendarItem;
+ if (item.parentItem != item) {
+ document.getElementById("item-repeat").setAttribute("disabled", "true");
+ document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true");
+ let repeatDetails = document.getElementById("repeat-details");
+ let numChilds = repeatDetails.children.length;
+ for (let i = 0; i < numChilds; i++) {
+ let node = repeatDetails.children[i];
+ node.setAttribute("disabled", "true");
+ node.removeAttribute("class");
+ node.removeAttribute("onclick");
+ }
+ }
+
+ // If the item is a proxy occurrence/instance, a few things aren't
+ // valid.
+ if (item.parentItem != item) {
+ document.getElementById("item-calendar").setAttribute("disabled", "true");
+
+ // don't allow to revoke the entrydate of recurring todo's.
+ disableElementWithLock("todo-has-entrydate", "permanent-lock");
+ }
+
+ // update datetime pickers, disable checkboxes if dates are required by
+ // recurrence or reminders.
+ updateRepeat(true);
+ updateReminder(true);
+ updateAllDay();
+ }
+
+ // Make sure capabilities are reflected correctly
+ updateCapabilities();
+}
+
+/**
+ * Opens the recurrence dialog modally to allow the user to edit the recurrence
+ * rules.
+ */
+function editRepeat() {
+ let args = {};
+ args.calendarEvent = window.calendarItem;
+ args.recurrenceInfo = window.recurrenceInfo;
+ args.startTime = gStartTime;
+ args.endTime = gEndTime;
+
+ let savedWindow = window;
+ args.onOk = function (recurrenceInfo) {
+ savedWindow.recurrenceInfo = recurrenceInfo;
+ };
+
+ window.setCursor("wait");
+
+ // open the dialog modally
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable,centerscreen",
+ args
+ );
+}
+
+/**
+ * This function is responsible for propagating UI state to controls
+ * depending on the repeat setting of an item. This functionality is used
+ * after the dialog has been loaded as well as if the repeat pattern has
+ * been changed.
+ *
+ * @param aSuppressDialogs If true, controls are updated without prompting
+ * for changes with the recurrence dialog
+ * @param aItemRepeatCall True when the function is being called from
+ * the item-repeat menu list. It allows to detect
+ * a change from the "custom" option.
+ */
+function updateRepeat(aSuppressDialogs, aItemRepeatCall) {
+ function setUpEntrydateForTask(item) {
+ // if this item is a task, we need to make sure that it has
+ // an entry-date, otherwise we can't create a recurrence.
+ if (item.isTodo()) {
+ // automatically check 'has entrydate' if needed.
+ if (!document.getElementById("todo-has-entrydate").checked) {
+ document.getElementById("todo-has-entrydate").checked = true;
+
+ // make sure gStartTime is properly initialized
+ updateEntryDate();
+ }
+
+ // disable the checkbox to indicate that we need
+ // the entry-date. the 'disabled' state will be
+ // revoked if the user turns off the repeat pattern.
+ disableElementWithLock("todo-has-entrydate", "repeat-lock");
+ }
+ }
+
+ let repeatMenu = document.getElementById("item-repeat");
+ let repeatValue = repeatMenu.selectedItem.getAttribute("value");
+ let repeatUntilDate = document.getElementById("repeat-untilDate");
+ let repeatDetails = document.getElementById("repeat-details");
+
+ if (repeatValue == "none") {
+ repeatUntilDate.hidden = true;
+ repeatDetails.hidden = true;
+ window.recurrenceInfo = null;
+ let item = window.calendarItem;
+ if (item.isTodo()) {
+ enableElementWithLock("todo-has-entrydate", "repeat-lock");
+ }
+ } else if (repeatValue == "custom") {
+ // the user selected custom repeat pattern. we now need to bring
+ // up the appropriate dialog in order to let the user specify the
+ // new rule. First of all, retrieve the item we want to specify
+ // the custom repeat pattern for.
+ let item = window.calendarItem;
+
+ setUpEntrydateForTask(item);
+
+ // retrieve the current recurrence info, we need this
+ // to find out whether or not the user really created
+ // a new repeat pattern.
+ let recurrenceInfo = window.recurrenceInfo;
+
+ // now bring up the recurrence dialog.
+ // don't pop up the dialog if aSuppressDialogs was specified or if
+ // called during initialization of the dialog.
+ if (!aSuppressDialogs && repeatMenu.hasAttribute("last-value")) {
+ editRepeat();
+ }
+
+ // Assign gUntilDate on the first run or when returning from the
+ // edit recurrence dialog.
+ if (window.recurrenceInfo) {
+ let rrules = splitRecurrenceRules(window.recurrenceInfo);
+ let rule = rrules[0][0];
+ gUntilDate = null;
+ if (!rule.isByCount && rule.isFinite && rule.untilDate) {
+ gUntilDate = rule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone);
+ }
+ }
+
+ // we need to address two separate cases here.
+ // 1)- We need to revoke the selection of the repeat
+ // drop down list in case the user didn't specify
+ // a new repeat pattern (i.e. canceled the dialog);
+ // - re-enable the 'has entrydate' option in case
+ // we didn't end up with a recurrence rule.
+ // 2) Check whether the new recurrence rule needs the
+ // recurrence details text or it can be displayed
+ // only with the repeat-until-datepicker.
+ if (recurrenceInfo == window.recurrenceInfo) {
+ repeatMenu.selectedIndex = gLastRepeatSelection;
+ if (item.isTodo()) {
+ if (!window.recurrenceInfo) {
+ enableElementWithLock("todo-has-entrydate", "repeat-lock");
+ }
+ }
+ } else {
+ repeatUntilDate.hidden = true;
+ repeatDetails.hidden = false;
+ // From the Edit Recurrence dialog, the rules "every day" and
+ // "every weekday" don't need the recurrence details text when they
+ // have only the until date. The getRepeatTypeAndUntilDate()
+ // function verifies whether this is the case.
+ let [repeatType, untilDate] = getRepeatTypeAndUntilDate(item);
+ loadRepeat(repeatType, untilDate, window.calendarItem);
+ }
+ } else {
+ let item = window.calendarItem;
+ let recurrenceInfo = window.recurrenceInfo || item.recurrenceInfo;
+ let proposedUntilDate = (gStartTime || window.initialStartDateValue).clone();
+
+ if (recurrenceInfo) {
+ recurrenceInfo = recurrenceInfo.clone();
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ let rule = rrules[0][0];
+
+ // If the previous rule was "custom" we have to recover the until
+ // date, or the last occurrence's date in order to set the
+ // repeat-until-datepicker with the same date.
+ if (aItemRepeatCall && repeatUntilDate.hidden && !repeatDetails.hidden) {
+ let repeatDate;
+ if (!rule.isByCount || !rule.isFinite) {
+ if (rule.isFinite) {
+ repeatDate = rule.untilDate.getInTimezone(cal.dtz.floating);
+ repeatDate = cal.dtz.dateTimeToJsDate(repeatDate);
+ } else {
+ repeatDate = "forever";
+ }
+ } else {
+ // Try to recover the last occurrence in 10(?) years.
+ let endDate = gStartTime.clone();
+ endDate.year += 10;
+ let lastOccurrenceDate = null;
+ let dates = recurrenceInfo.getOccurrenceDates(gStartTime, endDate, 0);
+ if (dates) {
+ lastOccurrenceDate = dates[dates.length - 1];
+ }
+ repeatDate = (lastOccurrenceDate || proposedUntilDate).getInTimezone(cal.dtz.floating);
+ repeatDate = cal.dtz.dateTimeToJsDate(repeatDate);
+ }
+ document.getElementById("repeat-until-datepicker").value = repeatDate;
+ }
+ if (rrules[0].length > 0) {
+ recurrenceInfo.deleteRecurrenceItem(rule);
+ }
+ } else {
+ // New event proposes "forever" as default until date.
+ recurrenceInfo = new CalRecurrenceInfo(item);
+ document.getElementById("repeat-until-datepicker").value = "forever";
+ }
+
+ repeatUntilDate.hidden = false;
+ repeatDetails.hidden = true;
+
+ let recRule = cal.createRecurrenceRule();
+ recRule.interval = 1;
+ switch (repeatValue) {
+ case "daily":
+ recRule.type = "DAILY";
+ break;
+ case "weekly":
+ recRule.type = "WEEKLY";
+ break;
+ case "every.weekday":
+ recRule.type = "DAILY";
+ recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]);
+ break;
+ case "bi.weekly":
+ recRule.type = "WEEKLY";
+ recRule.interval = 2;
+ break;
+ case "monthly":
+ recRule.type = "MONTHLY";
+ break;
+ case "yearly":
+ recRule.type = "YEARLY";
+ break;
+ }
+
+ setUpEntrydateForTask(item);
+ updateUntildateRecRule(recRule);
+
+ recurrenceInfo.insertRecurrenceItemAt(recRule, 0);
+ window.recurrenceInfo = recurrenceInfo;
+
+ if (item.isTodo()) {
+ if (!document.getElementById("todo-has-entrydate").checked) {
+ document.getElementById("todo-has-entrydate").checked = true;
+ }
+ disableElementWithLock("todo-has-entrydate", "repeat-lock");
+ }
+
+ // Preset the until-datepicker's minimonth to the start date.
+ let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating));
+ document.getElementById("repeat-until-datepicker").extraDate = startDate;
+ }
+
+ gLastRepeatSelection = repeatMenu.selectedIndex;
+ repeatMenu.setAttribute("last-value", repeatValue);
+
+ updateRepeatDetails();
+ updateEntryDate();
+ updateDueDate();
+ updateAccept();
+}
+
+/**
+ * Update the until date in the recurrence rule in order to set
+ * the same time of the start date.
+ *
+ * @param recRule (optional) The recurrence rule
+ */
+function updateUntildateRecRule(recRule) {
+ if (!recRule) {
+ let recurrenceInfo = window.recurrenceInfo;
+ if (!recurrenceInfo) {
+ return;
+ }
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ recRule = rrules[0][0];
+ }
+ let defaultTimezone = cal.dtz.defaultTimezone;
+ let repeatUntilDate = null;
+
+ let itemRepeat = document.getElementById("item-repeat").selectedItem.value;
+ if (itemRepeat == "none") {
+ return;
+ } else if (itemRepeat == "custom") {
+ repeatUntilDate = gUntilDate;
+ } else {
+ let untilDatepickerDate = document.getElementById("repeat-until-datepicker").value;
+ if (untilDatepickerDate != "forever") {
+ repeatUntilDate = cal.dtz.jsDateToDateTime(untilDatepickerDate, defaultTimezone);
+ }
+ }
+
+ if (repeatUntilDate) {
+ if (onLoad.hasLoaded) {
+ repeatUntilDate.isDate = gStartTime.isDate; // Enforce same value type as DTSTART
+ if (!gStartTime.isDate) {
+ repeatUntilDate.hour = gStartTime.hour;
+ repeatUntilDate.minute = gStartTime.minute;
+ repeatUntilDate.second = gStartTime.second;
+ }
+ }
+ recRule.untilDate = repeatUntilDate.clone();
+ gUntilDate = repeatUntilDate.clone().getInTimezone(defaultTimezone);
+ } else {
+ // Rule that recurs forever or with a "count" number of recurrences.
+ gUntilDate = null;
+ }
+}
+
+/**
+ * Updates the UI controls related to a task's completion status.
+ *
+ * @param {string} aStatus - The item's completion status or a string
+ * that allows to identify a change in the
+ * percent-complete's textbox.
+ * @param {Date} aCompletedDate - The item's completed date (as a JSDate).
+ */
+function updateToDoStatus(aStatus, aCompletedDate = null) {
+ // RFC2445 doesn't support completedDates without the todo's status
+ // being "COMPLETED", however twiddling the status menulist shouldn't
+ // destroy that information at this point (in case you change status
+ // back to COMPLETED). When we go to store this VTODO as .ics the
+ // date will get lost.
+
+ // remember the original values
+ let oldPercentComplete = parseInt(document.getElementById("percent-complete-textbox").value, 10);
+ let oldCompletedDate = document.getElementById("completed-date-picker").value;
+
+ // If the percent completed has changed to 100 or from 100 to another
+ // value, the status must change.
+ if (aStatus == "percent-changed") {
+ let selectedIndex = document.getElementById("todo-status").selectedIndex;
+ let menuItemCompleted = selectedIndex == 3;
+ let menuItemNotSpecified = selectedIndex == 0;
+ if (oldPercentComplete == 100) {
+ aStatus = "COMPLETED";
+ } else if (menuItemCompleted || menuItemNotSpecified) {
+ aStatus = "IN-PROCESS";
+ }
+ }
+
+ switch (aStatus) {
+ case null:
+ case "":
+ case "NONE":
+ oldPercentComplete = 0;
+ document.getElementById("todo-status").selectedIndex = 0;
+ document.getElementById("percent-complete-textbox").setAttribute("disabled", "true");
+ document.getElementById("percent-complete-label").setAttribute("disabled", "true");
+ break;
+ case "CANCELLED":
+ document.getElementById("todo-status").selectedIndex = 4;
+ document.getElementById("percent-complete-textbox").setAttribute("disabled", "true");
+ document.getElementById("percent-complete-label").setAttribute("disabled", "true");
+ break;
+ case "COMPLETED":
+ document.getElementById("todo-status").selectedIndex = 3;
+ document.getElementById("percent-complete-textbox").removeAttribute("disabled");
+ document.getElementById("percent-complete-label").removeAttribute("disabled");
+ // if there is no aCompletedDate, set it to the previous value
+ if (!aCompletedDate) {
+ aCompletedDate = oldCompletedDate;
+ }
+ break;
+ case "IN-PROCESS":
+ document.getElementById("todo-status").selectedIndex = 2;
+ document.getElementById("completed-date-picker").setAttribute("disabled", "true");
+ document.getElementById("percent-complete-textbox").removeAttribute("disabled");
+ document.getElementById("percent-complete-label").removeAttribute("disabled");
+ break;
+ case "NEEDS-ACTION":
+ document.getElementById("todo-status").selectedIndex = 1;
+ document.getElementById("percent-complete-textbox").removeAttribute("disabled");
+ document.getElementById("percent-complete-label").removeAttribute("disabled");
+ break;
+ }
+
+ let newPercentComplete;
+ if ((aStatus == "IN-PROCESS" || aStatus == "NEEDS-ACTION") && oldPercentComplete == 100) {
+ newPercentComplete = 0;
+ document.getElementById("completed-date-picker").value = oldCompletedDate;
+ document.getElementById("completed-date-picker").setAttribute("disabled", "true");
+ } else if (aStatus == "COMPLETED") {
+ newPercentComplete = 100;
+ document.getElementById("completed-date-picker").value = aCompletedDate;
+ document.getElementById("completed-date-picker").removeAttribute("disabled");
+ } else {
+ newPercentComplete = oldPercentComplete;
+ document.getElementById("completed-date-picker").value = oldCompletedDate;
+ document.getElementById("completed-date-picker").setAttribute("disabled", "true");
+ }
+
+ gConfig.percentComplete = newPercentComplete;
+ document.getElementById("percent-complete-textbox").value = newPercentComplete;
+ if (gInTab) {
+ sendMessage({
+ command: "updateConfigState",
+ argument: { percentComplete: newPercentComplete },
+ });
+ }
+}
+
+/**
+ * Saves all dialog controls back to the item.
+ *
+ * @returns a copy of the original item with changes made.
+ */
+function saveItem() {
+ // we need to clone the item in order to apply the changes.
+ // it is important to not apply the changes to the original item
+ // (even if it happens to be mutable) in order to guarantee
+ // that providers see a proper oldItem/newItem pair in case
+ // they rely on this fact (e.g. WCAP does).
+ let originalItem = window.calendarItem;
+ let item = originalItem.clone();
+
+ // override item's recurrenceInfo *before* serializing date/time-objects.
+ if (!item.recurrenceId) {
+ item.recurrenceInfo = window.recurrenceInfo;
+ }
+
+ // serialize the item
+ saveDialog(item);
+
+ item.organizer = window.organizer;
+
+ item.removeAllAttendees();
+ if (window.attendees && window.attendees.length > 0) {
+ for (let attendee of window.attendees) {
+ item.addAttendee(attendee);
+ }
+
+ let notifyCheckbox = document.getElementById("notify-attendees-checkbox");
+ if (notifyCheckbox.disabled) {
+ item.deleteProperty("X-MOZ-SEND-INVITATIONS");
+ } else {
+ item.setProperty("X-MOZ-SEND-INVITATIONS", notifyCheckbox.checked ? "TRUE" : "FALSE");
+ }
+ let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
+ if (undiscloseCheckbox.disabled) {
+ item.deleteProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
+ } else {
+ item.setProperty(
+ "X-MOZ-SEND-INVITATIONS-UNDISCLOSED",
+ undiscloseCheckbox.checked ? "TRUE" : "FALSE"
+ );
+ }
+ let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
+ let xProp = window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ // we want to leave an existing x-prop in case the checkbox is disabled as we need to
+ // roundtrip x-props that are not exclusively under our control
+ if (!disallowcounterCheckbox.disabled) {
+ // we only set the prop if we need to
+ if (disallowcounterCheckbox.checked) {
+ item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "TRUE");
+ } else if (xProp) {
+ item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "FALSE");
+ }
+ }
+ }
+
+ // We check if the organizerID is different from our
+ // calendar-user-address-set. The organzerID is the owner of the calendar.
+ // If it's different, that is because someone is acting on behalf of
+ // the organizer.
+ if (item.organizer && item.calendar.aclEntry) {
+ let userAddresses = item.calendar.aclEntry.getUserAddresses();
+ if (
+ userAddresses.length > 0 &&
+ !cal.email.attendeeMatchesAddresses(item.organizer, userAddresses)
+ ) {
+ let organizer = item.organizer.clone();
+ organizer.setProperty("SENT-BY", "mailto:" + userAddresses[0]);
+ item.organizer = organizer;
+ }
+ }
+ return item;
+}
+
+/**
+ * Action to take when the user chooses to save. This can happen either by
+ * saving directly or the user selecting to save after being prompted when
+ * closing the dialog.
+ *
+ * This function also takes care of notifying this dialog's caller that the item
+ * is saved.
+ *
+ * @param aIsClosing If true, the save action originates from the
+ * save prompt just before the window is closing.
+ */
+function onCommandSave(aIsClosing) {
+ // The datepickers need to remove the focus in order to trigger the
+ // validation of the values just edited, with the keyboard, but not yet
+ // confirmed (i.e. not followed by a click, a tab or enter keys pressure).
+ document.documentElement.focus();
+
+ // Don't save if a warning dialog about a wrong input date must be showed.
+ if (gWarning) {
+ return;
+ }
+
+ eventDialogCalendarObserver.cancel();
+
+ let originalItem = window.calendarItem;
+ let item = saveItem();
+ let calendar = getCurrentCalendar();
+ adaptScheduleAgent(item);
+
+ item.makeImmutable();
+ // Set the item for now, the callback below will set the full item when the
+ // call succeeded
+ window.calendarItem = item;
+
+ // When the call is complete, we need to set the new item, so that the
+ // dialog is up to date.
+
+ // XXX Do we want to disable the dialog or at least the save button until
+ // the call is complete? This might help when the user tries to save twice
+ // before the call is complete. In that case, we do need a progress bar and
+ // the ability to cancel the operation though.
+ let listener = {
+ onTransactionComplete(aItem) {
+ let aId = aItem.id;
+ let aCalendar = aItem.calendar;
+ // Check if the current window has a calendarItem first, because in case of undo
+ // window refers to the main window and we would get a 'calendarItem is undefined' warning.
+ if (!aIsClosing && "calendarItem" in window) {
+ // If we changed the calendar of the item, onOperationComplete will be called multiple
+ // times. We need to make sure we're receiving the update on the right calendar.
+ if (
+ (!window.calendarItem.id || aId == window.calendarItem.id) &&
+ aCalendar.id == window.calendarItem.calendar.id
+ ) {
+ if (window.calendarItem.recurrenceId) {
+ // TODO This workaround needs to be removed in bug 396182
+ // We are editing an occurrence. Make sure that the returned
+ // item is the same occurrence, not its parent item.
+ let occ = aItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId);
+ window.calendarItem = occ;
+ } else {
+ // We are editing the parent item, no workarounds needed
+ window.calendarItem = aItem;
+ }
+
+ // We now have an item, so we must change to an edit.
+ window.mode = "modify";
+ updateTitle();
+ eventDialogCalendarObserver.observe(window.calendarItem.calendar);
+ }
+ }
+ // this triggers the update of the imipbar in case this is a rescheduling case
+ if (window.counterProposal && window.counterProposal.onReschedule) {
+ window.counterProposal.onReschedule();
+ }
+ },
+ onGetResult(calendarItem, status, itemType, detail, items) {},
+ };
+ let resp = document.getElementById("notify-attendees-checkbox").checked
+ ? Ci.calIItipItem.AUTO
+ : Ci.calIItipItem.NONE;
+ let extResponse = { responseMode: resp };
+ window.onAcceptCallback(item, calendar, originalItem, listener, extResponse);
+}
+
+/**
+ * This function is called when the user chooses to delete an Item
+ * from the Event/Task dialog
+ *
+ */
+function onCommandDeleteItem() {
+ // only ask for confirmation, if the User changed anything on a new item or we modify an existing item
+ if (isItemChanged() || window.mode != "new") {
+ if (!cal.window.promptDeleteItems(window.calendarItem, true)) {
+ return;
+ }
+ }
+
+ if (window.mode == "new") {
+ cancelItem();
+ } else {
+ let deleteListener = {
+ // when deletion of item is complete, close the dialog
+ onTransactionComplete(item) {
+ // Check if the current window has a calendarItem first, because in case of undo
+ // window refers to the main window and we would get a 'calendarItem is undefined' warning.
+ if ("calendarItem" in window) {
+ if (item.id == window.calendarItem.id) {
+ cancelItem();
+ } else {
+ eventDialogCalendarObserver.observe(window.calendarItem.calendar);
+ }
+ }
+ },
+ };
+
+ eventDialogCalendarObserver.cancel();
+ if (window.calendarItem.parentItem.recurrenceInfo && window.calendarItem.recurrenceId) {
+ // if this is a single occurrence of a recurring item
+ if (countOccurrences(window.calendarItem) == 1) {
+ // this is the last occurrence, hence we delete the parent item
+ // to not leave a parent item without children in the calendar
+ gMainWindow.doTransaction(
+ "delete",
+ window.calendarItem.parentItem,
+ window.calendarItem.calendar,
+ null,
+ deleteListener
+ );
+ } else {
+ // we just need to remove the occurrence
+ let newItem = window.calendarItem.parentItem.clone();
+ newItem.recurrenceInfo.removeOccurrenceAt(window.calendarItem.recurrenceId);
+ gMainWindow.doTransaction(
+ "modify",
+ newItem,
+ newItem.calendar,
+ window.calendarItem.parentItem,
+ deleteListener
+ );
+ }
+ } else {
+ gMainWindow.doTransaction(
+ "delete",
+ window.calendarItem,
+ window.calendarItem.calendar,
+ null,
+ deleteListener
+ );
+ }
+ }
+}
+
+/**
+ * Postpone the task's start date/time and due date/time. ISO 8601
+ * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We
+ * use this format intentionally instead of a calIDuration object because
+ * those objects cannot be serialized for message passing with iframes.)
+ *
+ * @param {string} aDuration - A duration in ISO 8601 format
+ */
+function postponeTask(aDuration) {
+ let duration = cal.createDuration(aDuration);
+ if (gStartTime != null) {
+ gStartTime.addDuration(duration);
+ }
+ if (gEndTime != null) {
+ gEndTime.addDuration(duration);
+ }
+ updateDateTime();
+}
+
+/**
+ * Prompts the user to change the start timezone.
+ */
+function editStartTimezone() {
+ editTimezone(
+ "timezone-starttime",
+ gStartTime.getInTimezone(gStartTimezone),
+ editStartTimezone.complete
+ );
+}
+editStartTimezone.complete = function (datetime) {
+ let equalTimezones = false;
+ if (gStartTimezone && gEndTimezone) {
+ if (gStartTimezone == gEndTimezone) {
+ equalTimezones = true;
+ }
+ }
+ gStartTimezone = datetime.timezone;
+ if (equalTimezones) {
+ gEndTimezone = datetime.timezone;
+ }
+ updateDateTime();
+};
+
+/**
+ * Prompts the user to change the end timezone.
+ */
+function editEndTimezone() {
+ editTimezone("timezone-endtime", gEndTime.getInTimezone(gEndTimezone), editEndTimezone.complete);
+}
+editEndTimezone.complete = function (datetime) {
+ gEndTimezone = datetime.timezone;
+ updateDateTime();
+};
+
+/**
+ * Called to choose a recent timezone from the timezone popup.
+ *
+ * @param event The event with a target that holds the timezone id value.
+ */
+function chooseRecentTimezone(event) {
+ let tzid = event.target.value;
+ let timezonePopup = document.getElementById("timezone-popup");
+
+ if (tzid != "custom") {
+ let zone = cal.timezoneService.getTimezone(tzid);
+ let datetime = timezonePopup.dateTime.getInTimezone(zone);
+ timezonePopup.editTimezone.complete(datetime);
+ }
+}
+
+/**
+ * Opens the timezone popup on the node the event target points at.
+ *
+ * @param event The event causing the popup to open
+ * @param dateTime The datetime for which the timezone should be modified
+ * @param editFunc The function to be called when the custom menuitem is clicked.
+ */
+function showTimezonePopup(event, dateTime, editFunc) {
+ // Don't do anything for right/middle-clicks. Also, don't show the popup if
+ // the opening node is disabled.
+ if (event.button != 0 || event.target.disabled) {
+ return;
+ }
+
+ let timezonePopup = document.getElementById("timezone-popup");
+ let timezoneDefaultItem = document.getElementById("timezone-popup-defaulttz");
+ let timezoneSeparator = document.getElementById("timezone-popup-menuseparator");
+ let defaultTimezone = cal.dtz.defaultTimezone;
+ let recentTimezones = cal.dtz.getRecentTimezones(true);
+
+ // Set up the right editTimezone function, so the custom item can use it.
+ timezonePopup.editTimezone = editFunc;
+ timezonePopup.dateTime = dateTime;
+
+ // Set up the default timezone item
+ timezoneDefaultItem.value = defaultTimezone.tzid;
+ timezoneDefaultItem.label = defaultTimezone.displayName;
+
+ // Clear out any old recent timezones
+ while (timezoneDefaultItem.nextElementSibling != timezoneSeparator) {
+ timezoneDefaultItem.nextElementSibling.remove();
+ }
+
+ // Fill in the new recent timezones
+ for (let timezone of recentTimezones) {
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.setAttribute("value", timezone.tzid);
+ menuItem.setAttribute("label", timezone.displayName);
+ timezonePopup.insertBefore(menuItem, timezoneDefaultItem.nextElementSibling);
+ }
+
+ // Show the popup
+ timezonePopup.openPopup(event.target, "after_start", 0, 0, true);
+}
+
+/**
+ * Common function of edit(Start|End)Timezone() to prompt the user for a
+ * timezone change.
+ *
+ * @param aElementId The XUL element id of the timezone label.
+ * @param aDateTime The Date/Time of the time to change zone on.
+ * @param aCallback What to do when the user has chosen a zone.
+ */
+function editTimezone(aElementId, aDateTime, aCallback) {
+ if (document.getElementById(aElementId).hasAttribute("disabled")) {
+ return;
+ }
+
+ // prepare the arguments that will be passed to the dialog
+ let args = {};
+ args.time = aDateTime;
+ args.calendar = getCurrentCalendar();
+ args.onOk = function (datetime) {
+ cal.dtz.saveRecentTimezone(datetime.timezone.tzid);
+ return aCallback(datetime);
+ };
+
+ // open the dialog modally
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-timezone.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable,centerscreen",
+ args
+ );
+}
+
+/**
+ * This function initializes the following controls:
+ * - 'event-starttime'
+ * - 'event-endtime'
+ * - 'event-all-day'
+ * - 'todo-has-entrydate'
+ * - 'todo-entrydate'
+ * - 'todo-has-duedate'
+ * - 'todo-duedate'
+ * The date/time-objects are either displayed in their respective
+ * timezone or in the default timezone. This decision is based
+ * on whether or not 'cmd_timezone' is checked.
+ * the necessary information is taken from the following variables:
+ * - 'gStartTime'
+ * - 'gEndTime'
+ * - 'window.calendarItem' (used to decide about event/task)
+ */
+function updateDateTime() {
+ gIgnoreUpdate = true;
+
+ let item = window.calendarItem;
+ // Convert to default timezone if the timezone option
+ // is *not* checked, otherwise keep the specific timezone
+ // and display the labels in order to modify the timezone.
+ if (gTimezonesEnabled) {
+ if (item.isEvent()) {
+ let startTime = gStartTime.getInTimezone(gStartTimezone);
+ let endTime = gEndTime.getInTimezone(gEndTimezone);
+
+ document.getElementById("event-all-day").checked = startTime.isDate;
+
+ // In the case where the timezones are different but
+ // the timezone of the endtime is "UTC", we convert
+ // the endtime into the timezone of the starttime.
+ if (startTime && endTime) {
+ if (!cal.data.compareObjects(startTime.timezone, endTime.timezone)) {
+ if (endTime.timezone.isUTC) {
+ endTime = endTime.getInTimezone(startTime.timezone);
+ }
+ }
+ }
+
+ // before feeding the date/time value into the control we need
+ // to set the timezone to 'floating' in order to avoid the
+ // automatic conversion back into the OS timezone.
+ startTime.timezone = cal.dtz.floating;
+ endTime.timezone = cal.dtz.floating;
+
+ document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime);
+ document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime);
+ }
+
+ if (item.isTodo()) {
+ let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone);
+ let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone);
+ let hasEntryDate = startTime != null;
+ let hasDueDate = endTime != null;
+
+ if (hasEntryDate && hasDueDate) {
+ document.getElementById("todo-has-entrydate").checked = hasEntryDate;
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+
+ document.getElementById("todo-has-duedate").checked = hasDueDate;
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ } else if (hasEntryDate) {
+ document.getElementById("todo-has-entrydate").checked = hasEntryDate;
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime);
+ } else if (hasDueDate) {
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime);
+
+ document.getElementById("todo-has-duedate").checked = hasDueDate;
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ } else {
+ startTime = window.initialStartDateValue;
+ startTime.timezone = cal.dtz.floating;
+ endTime = startTime.clone();
+
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ }
+ }
+ } else {
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ if (item.isEvent()) {
+ let startTime = gStartTime.getInTimezone(kDefaultTimezone);
+ let endTime = gEndTime.getInTimezone(kDefaultTimezone);
+ document.getElementById("event-all-day").checked = startTime.isDate;
+
+ // before feeding the date/time value into the control we need
+ // to set the timezone to 'floating' in order to avoid the
+ // automatic conversion back into the OS timezone.
+ startTime.timezone = cal.dtz.floating;
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime);
+ document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime);
+ }
+
+ if (item.isTodo()) {
+ let startTime = gStartTime && gStartTime.getInTimezone(kDefaultTimezone);
+ let endTime = gEndTime && gEndTime.getInTimezone(kDefaultTimezone);
+ let hasEntryDate = startTime != null;
+ let hasDueDate = endTime != null;
+
+ if (hasEntryDate && hasDueDate) {
+ document.getElementById("todo-has-entrydate").checked = hasEntryDate;
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+
+ document.getElementById("todo-has-duedate").checked = hasDueDate;
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ } else if (hasEntryDate) {
+ document.getElementById("todo-has-entrydate").checked = hasEntryDate;
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+
+ startTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime);
+ } else if (hasDueDate) {
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime);
+
+ document.getElementById("todo-has-duedate").checked = hasDueDate;
+ endTime.timezone = cal.dtz.floating;
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ } else {
+ startTime = window.initialStartDateValue;
+ startTime.timezone = cal.dtz.floating;
+ endTime = startTime.clone();
+
+ document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime);
+ document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime);
+ }
+ }
+ }
+
+ updateTimezone();
+ updateAllDay();
+ updateRepeatDetails();
+
+ gIgnoreUpdate = false;
+}
+
+/**
+ * This function initializes the following controls:
+ * - 'timezone-starttime'
+ * - 'timezone-endtime'
+ * the timezone-links show the corrosponding names of the
+ * start/end times. If 'cmd_timezone' is not checked
+ * the links will be collapsed.
+ */
+function updateTimezone() {
+ function updateTimezoneElement(aTimezone, aId, aDateTime) {
+ let element = document.getElementById(aId);
+ if (!element) {
+ return;
+ }
+
+ if (aTimezone) {
+ element.removeAttribute("collapsed");
+ element.value = aTimezone.displayName || aTimezone.tzid;
+ if (!aDateTime || !aDateTime.isValid || gIsReadOnly || aDateTime.isDate) {
+ if (element.hasAttribute("class")) {
+ element.setAttribute("class-on-enabled", element.getAttribute("class"));
+ element.removeAttribute("class");
+ }
+ if (element.hasAttribute("onclick")) {
+ element.setAttribute("onclick-on-enabled", element.getAttribute("onclick"));
+ element.removeAttribute("onclick");
+ }
+ element.setAttribute("disabled", "true");
+ } else {
+ if (element.hasAttribute("class-on-enabled")) {
+ element.setAttribute("class", element.getAttribute("class-on-enabled"));
+ element.removeAttribute("class-on-enabled");
+ }
+ if (element.hasAttribute("onclick-on-enabled")) {
+ element.setAttribute("onclick", element.getAttribute("onclick-on-enabled"));
+ element.removeAttribute("onclick-on-enabled");
+ }
+ element.removeAttribute("disabled");
+ }
+ } else {
+ element.setAttribute("collapsed", "true");
+ }
+ }
+
+ // convert to default timezone if the timezone option
+ // is *not* checked, otherwise keep the specific timezone
+ // and display the labels in order to modify the timezone.
+ if (gTimezonesEnabled) {
+ updateTimezoneElement(gStartTimezone, "timezone-starttime", gStartTime);
+ updateTimezoneElement(gEndTimezone, "timezone-endtime", gEndTime);
+ } else {
+ document.getElementById("timezone-starttime").setAttribute("collapsed", "true");
+ document.getElementById("timezone-endtime").setAttribute("collapsed", "true");
+ }
+}
+
+/**
+ * Updates dialog controls related to item attachments
+ */
+function updateAttachment() {
+ let hasAttachments = capSupported("attachments");
+ document.getElementById("cmd_attach_url").setAttribute("disabled", !hasAttachments);
+
+ // update the attachment tab label to make the number of (uri) attachments visible
+ // even if another tab is displayed
+ let attachments = Object.values(gAttachMap).filter(aAtt => aAtt.uri);
+ let attachmentTab = document.getElementById("event-grid-tab-attachments");
+ if (attachments.length) {
+ attachmentTab.label = cal.l10n.getString("calendar-event-dialog", "attachmentsTabLabel", [
+ attachments.length,
+ ]);
+ } else {
+ attachmentTab.label = window.attachmentTabLabel;
+ }
+
+ sendMessage({
+ command: "updateConfigState",
+ argument: { attachUrlCommand: hasAttachments },
+ });
+}
+
+/**
+ * Returns whether to show or hide the related link on the dialog
+ * (rfc2445 URL property).
+ *
+ * @param {string} aUrl - The url in question.
+ * @returns {boolean} true for show and false for hide
+ */
+function showOrHideItemURL(url) {
+ if (!url) {
+ return false;
+ }
+ let handler;
+ let uri;
+ try {
+ uri = Services.io.newURI(url);
+ handler = Services.io.getProtocolHandler(uri.scheme);
+ } catch (e) {
+ // No protocol handler for the given protocol, or invalid uri
+ // hideOrShow(false);
+ return false;
+ }
+ // Only show if its either an internal protocol handler, or its external
+ // and there is an external app for the scheme
+ handler = cal.wrapInstance(handler, Ci.nsIExternalProtocolHandler);
+ return !handler || handler.externalAppExistsForScheme(uri.scheme);
+}
+
+/**
+ * Updates the related link on the dialog (rfc2445 URL property).
+ *
+ * @param {boolean} aShow - Show the link (true) or not (false)
+ * @param {string} aUrl - The url
+ */
+function updateItemURL(aShow, aUrl) {
+ // Hide or show the link
+ document.getElementById("event-grid-link-separator").toggleAttribute("hidden", !aShow);
+ document.getElementById("event-grid-link-row").toggleAttribute("hidden", !aShow);
+
+ // Set the url for the link
+ if (aShow && aUrl.length) {
+ setTimeout(() => {
+ // HACK the url-link doesn't crop when setting the value in onLoad
+ let label = document.getElementById("url-link");
+ label.setAttribute("value", aUrl);
+ label.setAttribute("href", aUrl);
+ }, 0);
+ }
+}
+
+/**
+ * This function updates dialog controls related to attendees.
+ */
+function updateAttendeeInterface() {
+ // sending email invitations currently only supported for events
+ let attendeeTab = document.getElementById("event-grid-tab-attendees");
+ let attendeePanel = document.getElementById("event-grid-tabpanel-attendees");
+ let notifyOptions = document.getElementById("notify-options");
+ if (window.calendarItem.isEvent()) {
+ attendeeTab.removeAttribute("collapsed");
+ attendeePanel.removeAttribute("collapsed");
+ notifyOptions.removeAttribute("collapsed");
+
+ let organizerRow = document.getElementById("item-organizer-row");
+ if (window.organizer && window.organizer.id) {
+ let existingLabel = organizerRow.querySelector(":scope > .attendee-label");
+ if (existingLabel) {
+ organizerRow.removeChild(existingLabel);
+ }
+ organizerRow.appendChild(
+ cal.invitation.createAttendeeLabel(document, window.organizer, window.attendees)
+ );
+ organizerRow.hidden = false;
+ } else {
+ organizerRow.hidden = true;
+ }
+
+ let attendeeContainer = document.querySelector(".item-attendees-list-container");
+ if (attendeeContainer.firstChild) {
+ attendeeContainer.firstChild.remove();
+ }
+ attendeeContainer.appendChild(cal.invitation.createAttendeesList(document, window.attendees));
+ for (let label of attendeeContainer.querySelectorAll(".attendee-label")) {
+ label.addEventListener("dblclick", attendeeDblClick);
+ label.setAttribute("tabindex", "0");
+ }
+
+ // update the attendee tab label to make the number of attendees
+ // visible even if another tab is displayed
+ if (window.attendees.length) {
+ attendeeTab.label = cal.l10n.getString("calendar-event-dialog", "attendeesTabLabel", [
+ window.attendees.length,
+ ]);
+ } else {
+ attendeeTab.label = window.attendeeTabLabel;
+ }
+ } else {
+ attendeeTab.setAttribute("collapsed", "true");
+ attendeePanel.setAttribute("collapsed", "true");
+ }
+ updateParentSaveControls();
+}
+
+/**
+ * Update the save controls in parent context depending on the whether attendees
+ * exist for this event and notifying is enabled
+ */
+function updateParentSaveControls() {
+ let mode =
+ window.calendarItem.isEvent() &&
+ window.organizer &&
+ window.organizer.id &&
+ window.attendees &&
+ window.attendees.length > 0 &&
+ document.getElementById("notify-attendees-checkbox").checked;
+
+ sendMessage({
+ command: "updateSaveControls",
+ argument: { sendNotSave: mode },
+ });
+}
+
+/**
+ * This function updates dialog controls related to recurrence, in this case the
+ * text describing the recurrence rule.
+ */
+function updateRepeatDetails() {
+ // Don't try to show the details text for
+ // anything but a custom recurrence rule.
+ let recurrenceInfo = window.recurrenceInfo;
+ let itemRepeat = document.getElementById("item-repeat");
+ let repeatDetails = document.getElementById("repeat-details");
+ if (itemRepeat.value == "custom" && recurrenceInfo && !hasUnsupported(recurrenceInfo)) {
+ let item = window.calendarItem;
+ document.getElementById("repeat-untilDate").hidden = true;
+ // Try to create a descriptive string from the rule(s).
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ let event = item.isEvent();
+
+ let startDate = document.getElementById(event ? "event-starttime" : "todo-entrydate").value;
+ let endDate = document.getElementById(event ? "event-endtime" : "todo-duedate").value;
+ startDate = cal.dtz.jsDateToDateTime(startDate, kDefaultTimezone);
+ endDate = cal.dtz.jsDateToDateTime(endDate, kDefaultTimezone);
+
+ let allDay = document.getElementById("event-all-day").checked;
+ let detailsString = recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay);
+
+ if (!detailsString) {
+ detailsString = cal.l10n.getString("calendar-event-dialog", "ruleTooComplex");
+ }
+ repeatDetails.hidden = false;
+
+ // Now display the string.
+ let lines = detailsString.split("\n");
+ while (repeatDetails.children.length > lines.length) {
+ repeatDetails.lastChild.remove();
+ }
+ let numChilds = repeatDetails.children.length;
+ for (let i = 0; i < lines.length; i++) {
+ if (i >= numChilds) {
+ let newNode = repeatDetails.children[0].cloneNode(true);
+ repeatDetails.appendChild(newNode);
+ }
+ repeatDetails.children[i].value = lines[i];
+ repeatDetails.children[i].setAttribute("tooltiptext", detailsString);
+ }
+ } else {
+ repeatDetails.hidden = true;
+ }
+}
+
+/**
+ * This function does not strictly check if the given attendee has the status
+ * TENTATIVE, but also if he hasn't responded.
+ *
+ * @param aAttendee The attendee to check.
+ * @returns True, if the attendee hasn't responded.
+ */
+function isAttendeeUndecided(aAttendee) {
+ return (
+ aAttendee.participationStatus != "ACCEPTED" &&
+ aAttendee.participationStatus != "DECLINED" &&
+ aAttendee.participationStatus != "DELEGATED"
+ );
+}
+
+/**
+ * Event handler for dblclick on attendee items.
+ *
+ * @param aEvent The popupshowing event
+ */
+function attendeeDblClick(aEvent) {
+ // left mouse button
+ if (aEvent.button == 0) {
+ editAttendees();
+ }
+}
+
+/**
+ * Event handler to set up the attendee-popup. This builds the popup menuitems.
+ *
+ * @param aEvent The popupshowing event
+ */
+function setAttendeeContext(aEvent) {
+ if (window.attendees.length == 0) {
+ // we just need the option to open the attendee dialog in this case
+ let popup = document.getElementById("attendee-popup");
+ let invite = document.getElementById("attendee-popup-invite-menuitem");
+ for (let node of popup.children) {
+ if (node == invite) {
+ node.removeAttribute("hidden");
+ } else {
+ node.setAttribute("hidden", "true");
+ }
+ }
+ } else {
+ if (window.attendees.length > 1) {
+ let removeall = document.getElementById("attendee-popup-removeallattendees-menuitem");
+ removeall.removeAttribute("hidden");
+ }
+ document.getElementById("attendee-popup-sendemail-menuitem").removeAttribute("hidden");
+ document.getElementById("attendee-popup-sendtentativeemail-menuitem").removeAttribute("hidden");
+ document.getElementById("attendee-popup-first-separator").removeAttribute("hidden");
+
+ // setup attendee specific menu items if appropriate otherwise hide respective menu items
+ let mailto = document.getElementById("attendee-popup-emailattendee-menuitem");
+ let remove = document.getElementById("attendee-popup-removeattendee-menuitem");
+ let secondSeparator = document.getElementById("attendee-popup-second-separator");
+ let attId =
+ aEvent.target.getAttribute("attendeeid") ||
+ aEvent.target.parentNode.getAttribute("attendeeid");
+ let attendee = window.attendees.find(aAtt => aAtt.id == attId);
+ if (attendee) {
+ mailto.removeAttribute("hidden");
+ remove.removeAttribute("hidden");
+ secondSeparator.removeAttribute("hidden");
+
+ mailto.setAttribute("label", attendee.toString());
+ mailto.attendee = attendee;
+ remove.attendee = attendee;
+ } else {
+ mailto.setAttribute("hidden", "true");
+ remove.setAttribute("hidden", "true");
+ secondSeparator.setAttribute("hidden", "true");
+ }
+
+ if (window.attendees.some(isAttendeeUndecided)) {
+ document.getElementById("cmd_email_undecided").removeAttribute("disabled");
+ } else {
+ document.getElementById("cmd_email_undecided").setAttribute("disabled", "true");
+ }
+ }
+}
+
+/**
+ * Removes the selected attendee from the window
+ *
+ * @param aAttendee
+ */
+function removeAttendee(aAttendee) {
+ if (aAttendee) {
+ window.attendees = window.attendees.filter(aAtt => aAtt != aAttendee);
+ updateAttendeeInterface();
+ }
+}
+
+/**
+ * Removes all attendees from the window
+ */
+function removeAllAttendees() {
+ window.attendees = [];
+ window.organizer = null;
+ updateAttendeeInterface();
+}
+
+/**
+ * Send Email to all attendees that haven't responded or are tentative.
+ *
+ * @param aAttendees The attendees to check.
+ */
+function sendMailToUndecidedAttendees(aAttendees) {
+ let targetAttendees = aAttendees.filter(isAttendeeUndecided);
+ sendMailToAttendees(targetAttendees);
+}
+
+/**
+ * Send Email to all given attendees.
+ *
+ * @param aAttendees The attendees to send mail to.
+ */
+function sendMailToAttendees(aAttendees) {
+ let toList = cal.email.createRecipientList(aAttendees);
+ let item = saveItem();
+ let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]);
+ let identity = window.calendarItem.calendar.getProperty("imip.identity");
+ cal.email.sendTo(toList, emailSubject, null, identity);
+}
+
+/**
+ * Make sure all fields that may have calendar specific capabilities are updated
+ */
+function updateCapabilities() {
+ updateAttachment();
+ updateConfigState({
+ priority: gConfig.priority,
+ privacy: gConfig.privacy,
+ });
+ updateReminderDetails(
+ document.querySelector(".reminder-details"),
+ document.querySelector(".item-alarm"),
+ getCurrentCalendar()
+ );
+ updateCategoryMenulist();
+}
+
+/**
+ * find out if the User already changed values in the Dialog
+ *
+ * @return: true if the values in the Dialog have changed. False otherwise.
+ */
+function isItemChanged() {
+ let newItem = saveItem();
+ let oldItem = window.calendarItem;
+
+ if (newItem.calendar.id == oldItem.calendar.id && cal.item.compareContent(newItem, oldItem)) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Test if a specific capability is supported
+ *
+ * @param aCap The capability from "capabilities.<aCap>.supported"
+ */
+function capSupported(aCap) {
+ let calendar = getCurrentCalendar();
+ return calendar.getProperty("capabilities." + aCap + ".supported") !== false;
+}
+
+/**
+ * Return the values for a certain capability.
+ *
+ * @param aCap The capability from "capabilities.<aCap>.values"
+ * @returns The values for this capability
+ */
+function capValues(aCap, aDefault) {
+ let calendar = getCurrentCalendar();
+ let vals = calendar.getProperty("capabilities." + aCap + ".values");
+ return vals === null ? aDefault : vals;
+}
+
+/**
+ * Checks the until date just entered in the datepicker in order to avoid
+ * setting a date earlier than the start date.
+ * Restores the previous correct date; sets the warning flag to prevent closing
+ * the dialog when the user enters a wrong until date.
+ */
+function checkUntilDate() {
+ let repeatUntilDate = document.getElementById("repeat-until-datepicker").value;
+ if (repeatUntilDate == "forever") {
+ updateRepeat();
+ // "forever" is never earlier than another date.
+ return;
+ }
+
+ // Check whether the date is valid. Set the correct time just in this case.
+ let untilDate = cal.dtz.jsDateToDateTime(repeatUntilDate, gStartTime.timezone);
+ let startDate = gStartTime.clone();
+ startDate.isDate = true;
+ if (untilDate.compare(startDate) < 0) {
+ // Invalid date: restore the previous date. Since we are checking an
+ // until date, a null value for gUntilDate means repeat "forever".
+ document.getElementById("repeat-until-datepicker").value = gUntilDate
+ ? cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating))
+ : "forever";
+ gWarning = true;
+ let callback = function () {
+ // Disable the "Save" and "Save and Close" commands as long as the
+ // warning dialog is showed.
+ enableAcceptCommand(false);
+
+ Services.prompt.alert(
+ null,
+ document.title,
+ cal.l10n.getCalString("warningUntilDateBeforeStart")
+ );
+ enableAcceptCommand(true);
+ gWarning = false;
+ };
+ setTimeout(callback, 1);
+ } else {
+ // Valid date: set the time equal to start date time.
+ gUntilDate = untilDate;
+ updateUntildateRecRule();
+ }
+}
+
+/**
+ * Displays a counterproposal if any
+ */
+function displayCounterProposal() {
+ if (
+ !window.counterProposal ||
+ !window.counterProposal.attendee ||
+ !window.counterProposal.proposal
+ ) {
+ return;
+ }
+
+ let propLabels = document.getElementById("counter-proposal-property-labels");
+ let propValues = document.getElementById("counter-proposal-property-values");
+ let idCounter = 0;
+ let comment;
+
+ for (let proposal of window.counterProposal.proposal) {
+ if (proposal.property == "COMMENT") {
+ if (proposal.proposed && !proposal.original) {
+ comment = proposal.proposed;
+ }
+ } else {
+ let label = lookupCounterLabel(proposal);
+ let value = formatCounterValue(proposal);
+ if (label && value) {
+ // setup label node
+ let propLabel = propLabels.firstElementChild.cloneNode(false);
+ propLabel.id = propLabel.id + "-" + idCounter;
+ propLabel.control = propLabel.control + "-" + idCounter;
+ propLabel.removeAttribute("collapsed");
+ propLabel.value = label;
+ // setup value node
+ let propValue = propValues.firstElementChild.cloneNode(false);
+ propValue.id = propLabel.control;
+ propValue.removeAttribute("collapsed");
+ propValue.value = value;
+ // append nodes
+ propLabels.appendChild(propLabel);
+ propValues.appendChild(propValue);
+ idCounter++;
+ }
+ }
+ }
+
+ let attendeeId =
+ window.counterProposal.attendee.CN ||
+ cal.email.removeMailTo(window.counterProposal.attendee.id || "");
+ let partStat = window.counterProposal.attendee.participationStatus;
+ if (partStat == "DECLINED") {
+ partStat = "counterSummaryDeclined";
+ } else if (partStat == "TENTATIVE") {
+ partStat = "counterSummaryTentative";
+ } else if (partStat == "ACCEPTED") {
+ partStat = "counterSummaryAccepted";
+ } else if (partStat == "DELEGATED") {
+ partStat = "counterSummaryDelegated";
+ } else if (partStat == "NEEDS-ACTION") {
+ partStat = "counterSummaryNeedsAction";
+ } else {
+ cal.LOG("Unexpected partstat " + partStat + " detected.");
+ // we simply reset partStat not display the summary text of the counter box
+ // to avoid the window of death
+ partStat = null;
+ }
+
+ if (idCounter > 0) {
+ if (partStat && attendeeId.length) {
+ document.getElementById("counter-proposal-summary").value = cal.l10n.getString(
+ "calendar-event-dialog",
+ partStat,
+ [attendeeId]
+ );
+ document.getElementById("counter-proposal-summary").removeAttribute("collapsed");
+ }
+ if (comment) {
+ document.getElementById("counter-proposal-comment").value = comment;
+ document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+ }
+ document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+
+ if (window.counterProposal.oldVersion) {
+ // this is a counterproposal to a previous version of the event - we should notify the
+ // user accordingly
+ notifyUser(
+ "counterProposalOnPreviousVersion",
+ cal.l10n.getString("calendar-event-dialog", "counterOnPreviousVersionNotification"),
+ "warn"
+ );
+ }
+ if (window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER") == "TRUE") {
+ // this is a counterproposal although the user disallowed countering when sending the
+ // invitation, so we notify the user accordingly
+ notifyUser(
+ "counterProposalOnCounteringDisallowed",
+ cal.l10n.getString("calendar-event-dialog", "counterOnCounterDisallowedNotification"),
+ "warn"
+ );
+ }
+ }
+}
+
+/**
+ * Get the property label to display for a counterproposal based on the respective label used in
+ * the dialog
+ *
+ * @param {JSObject} aProperty The property to check for a label
+ * @returns {string | null} The label to display or null if no such label
+ */
+function lookupCounterLabel(aProperty) {
+ let nodeIds = getPropertyMap();
+ let labels =
+ nodeIds.has(aProperty.property) &&
+ document.getElementsByAttribute("control", nodeIds.get(aProperty.property));
+ let labelValue;
+ if (labels && labels.length) {
+ // as label control assignment should be unique, we can just take the first result
+ labelValue = labels[0].value;
+ } else {
+ cal.LOG(
+ "Unsupported property " +
+ aProperty.property +
+ " detected when setting up counter " +
+ "box labels."
+ );
+ }
+ return labelValue;
+}
+
+/**
+ * Get the property value to display for a counterproposal as currently supported
+ *
+ * @param {JSObject} aProperty The property to check for a label
+ * @returns {string | null} The value to display or null if the property is not supported
+ */
+function formatCounterValue(aProperty) {
+ const dateProps = ["DTSTART", "DTEND"];
+ const stringProps = ["SUMMARY", "LOCATION"];
+
+ let val;
+ if (dateProps.includes(aProperty.property)) {
+ let localTime = aProperty.proposed.getInTimezone(cal.dtz.defaultTimezone);
+ val = cal.dtz.formatter.formatDateTime(localTime);
+ if (gTimezonesEnabled) {
+ let tzone = localTime.timezone.displayName || localTime.timezone.tzid;
+ val += " " + tzone;
+ }
+ } else if (stringProps.includes(aProperty.property)) {
+ val = aProperty.proposed;
+ } else {
+ cal.LOG(
+ "Unsupported property " + aProperty.property + " detected when setting up counter box values."
+ );
+ }
+ return val;
+}
+
+/**
+ * Get a map of property names and labels of currently supported properties
+ *
+ * @returns {Map}
+ */
+function getPropertyMap() {
+ let map = new Map();
+ map.set("SUMMARY", "item-title");
+ map.set("LOCATION", "item-location");
+ map.set("DTSTART", "event-starttime");
+ map.set("DTEND", "event-endtime");
+ return map;
+}
+
+/**
+ * Applies the proposal or original data to the respective dialog fields
+ *
+ * @param {string} aType Either 'proposed' or 'original'
+ */
+function applyValues(aType) {
+ if (!window.counterProposal || (aType != "proposed" && aType != "original")) {
+ return;
+ }
+ let originalBtn = document.getElementById("counter-original-btn");
+ if (originalBtn.disabled) {
+ // The button is disabled when opening the dialog/tab, which makes it more obvious to the
+ // user that he/she needs to apply the proposal values prior to saving & sending.
+ // Once that happened, we leave both options to the user without toggling the button states
+ // to avoid needing to listen to manual changes to do that correctly
+ originalBtn.removeAttribute("disabled");
+ }
+ let nodeIds = getPropertyMap();
+ window.counterProposal.proposal.forEach(aProperty => {
+ if (aProperty.property != "COMMENT") {
+ let valueNode =
+ nodeIds.has(aProperty.property) && document.getElementById(nodeIds.get(aProperty.property));
+ if (valueNode) {
+ if (["DTSTART", "DTEND"].includes(aProperty.property)) {
+ valueNode.value = cal.dtz.dateTimeToJsDate(aProperty[aType]);
+ } else {
+ valueNode.value = aProperty[aType];
+ }
+ }
+ }
+ });
+}
+
+/**
+ * Opens the context menu for the editor element.
+ *
+ * Since its content is, well, content, its contextmenu event is
+ * eaten by the context menu actor before the element's default
+ * context menu processing. Since we know that the editor runs
+ * in the parent process, we can just listen directly to the event.
+ */
+function openEditorContextMenu(event) {
+ let popup = document.getElementById("editorContext");
+ popup.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ event.preventDefault();
+}
+
+// Thunderbird's dialog is mail-centric, but we just want a lightweight prompt.
+function insertLink() {
+ let href = { value: "" };
+ let editor = GetCurrentEditor();
+ let existingLink = editor.getSelectedElement("href");
+ if (existingLink) {
+ editor.selectElement(existingLink);
+ href.value = existingLink.getAttribute("href");
+ }
+ let text = GetSelectionAsText().trim() || href.value || GetString("EmptyHREFError");
+ let title = GetString("Link");
+ if (Services.prompt.prompt(window, title, text, href, null, {})) {
+ if (!href.value) {
+ // Remove the link
+ EditorRemoveTextProperty("href", "");
+ } else if (editor.selection.isCollapsed) {
+ // Insert a link with its href as the text
+ let link = editor.createElementWithDefaults("a");
+ link.setAttribute("href", href.value);
+ link.textContent = href.value;
+ editor.insertElementAtSelection(link, false);
+ } else {
+ // Change the href of the selection
+ let link = editor.createElementWithDefaults("a");
+ link.setAttribute("href", href.value);
+ editor.insertLinkAroundSelection(link);
+ }
+ }
+}
diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml
new file mode 100644
index 0000000000..60180a0e4b
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml
@@ -0,0 +1,1225 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- XXX some of these css files may not be needed here. -->
+<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?>
+<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?>
+
+<!DOCTYPE html [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
+<!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd">
+<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd">
+<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd">
+<!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" >
+<!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd">
+%brandDTD; %globalDTD; %calendarDTD; %eventDialogDTD; %messengercomposeDTD; %editorOverlayDTD; ]>
+<html
+ id="calendar-event-dialog-inner"
+ xmlns="http://www.w3.org/1999/xhtml"
+ lightweightthemes="true"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title></title>
+ <link rel="localization" href="toolkit/global/textActions.ftl" />
+ <link rel="localization" href="calendar/calendar-editable-item.ftl" />
+ <link rel="localization" href="calendar/calendar-widgets.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script>
+ <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script>
+ <script defer="defer" src="chrome://messenger/content/messengercompose/editor.js"></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/editorUtilities.js"
+ ></script>
+ <script
+ defer="defer"
+ src="chrome://messenger/content/messengercompose/ComposerCommands.js"
+ ></script>
+ <script defer="defer" src="chrome://calendar/content/calendar-item-iframe.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <commandset id="">
+ <command id="cmd_recurrence" oncommand="editRepeat();" />
+ <command id="cmd_attendees" oncommand="editAttendees();" />
+ <command id="cmd_email" oncommand="sendMailToAttendees(window.attendees);" />
+ <command
+ id="cmd_email_undecided"
+ oncommand="sendMailToUndecidedAttendees(window.attendees);"
+ />
+ <command id="cmd_attach_url" disable-on-readonly="true" oncommand="attachURL()" />
+ <command id="cmd_attach_cloud" disable-on-readonly="true" />
+ <command id="cmd_openAttachment" oncommand="openAttachment()" />
+ <command id="cmd_copyAttachment" oncommand="copyAttachment()" />
+ <command
+ id="cmd_deleteAttachment"
+ disable-on-readonly="true"
+ oncommand="deleteAttachment()"
+ />
+ <command
+ id="cmd_deleteAllAttachments"
+ disable-on-readonly="true"
+ oncommand="deleteAllAttachments()"
+ />
+ <command
+ id="cmd_applyProposal"
+ disable-on-readonly="true"
+ oncommand="applyValues('proposed')"
+ />
+ <command
+ id="cmd_applyOriginal"
+ disable-on-readonly="true"
+ oncommand="applyValues('original')"
+ />
+ </commandset>
+
+ <!-- style related commands that update on creation, and on selection change -->
+ <!-- not using commandupdater directly, as it has to listen to the parent -->
+ <commandset id="styleMenuItems" oncommandupdate="goUpdateComposerMenuItems(this)">
+ <command id="cmd_renderedHTMLEnabler" disabled="true" />
+ <command
+ id="cmd_bold"
+ state="false"
+ oncommand="doStyleUICommand('cmd_bold')"
+ disabled="true"
+ />
+ <command
+ id="cmd_italic"
+ state="false"
+ oncommand="doStyleUICommand('cmd_italic')"
+ disabled="true"
+ />
+ <command
+ id="cmd_underline"
+ state="false"
+ oncommand="doStyleUICommand('cmd_underline')"
+ disabled="true"
+ />
+
+ <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')" disabled="true" />
+ <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')" disabled="true" />
+
+ <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')" disabled="true" />
+ <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')" disabled="true" />
+
+ <command id="cmd_align" state="" disabled="true" />
+ </commandset>
+
+ <keyset id="editorKeys">
+ <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel" />
+ <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel" />
+ <key
+ id="underlinekb"
+ key="&styleUnderlineCmd.key;"
+ observes="cmd_underline"
+ modifiers="accel"
+ />
+ <key
+ id="increaseindentkb"
+ key="&increaseIndent.key;"
+ observes="cmd_indent"
+ modifiers="accel"
+ />
+ <key
+ id="decreaseindentkb"
+ key="&decreaseIndent.key;"
+ observes="cmd_outdent"
+ modifiers="accel"
+ />
+ </keyset>
+
+ <menupopup id="editorContext" onpopupshowing="goUpdateGlobalEditMenuItems(true);">
+ <menuitem data-l10n-id="text-action-undo" command="cmd_undo" />
+ <menuseparator />
+ <menuitem data-l10n-id="text-action-cut" command="cmd_cut" />
+ <menuitem data-l10n-id="text-action-copy" command="cmd_copy" />
+ <menuitem data-l10n-id="text-action-paste" command="cmd_paste" />
+ <menuitem data-l10n-id="text-action-delete" command="cmd_item_delete" />
+ <menuseparator />
+ <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll" />
+ </menupopup>
+
+ <!-- Counter information section -->
+ <hbox id="counter-proposal-box" collapsed="true">
+ <vbox>
+ <description id="counter-proposal-summary" collapsed="true" crop="end" />
+ <hbox id="counter-proposal">
+ <vbox id="counter-proposal-property-labels">
+ <label
+ id="counter-proposal-property-label"
+ control="counter-proposal-property-value"
+ collapsed="true"
+ value=""
+ />
+ </vbox>
+ <vbox id="counter-proposal-property-values">
+ <description
+ id="counter-proposal-property-value"
+ crop="end"
+ collapsed="true"
+ value=""
+ />
+ </vbox>
+ </hbox>
+ <description id="counter-proposal-comment" collapsed="true" crop="end" />
+ </vbox>
+ <spacer flex="1" />
+ <vbox id="counter-buttons">
+ <button
+ id="counter-proposal-btn"
+ label="&counter.button.proposal.label;"
+ crop="end"
+ command="cmd_applyProposal"
+ orient="horizontal"
+ class="counter-buttons"
+ accesskey="&counter.button.proposal.accesskey;"
+ tooltip="&counter.button.proposal.tooltip2;"
+ />
+ <button
+ id="counter-original-btn"
+ label="&counter.button.original.label;"
+ crop="end"
+ command="cmd_applyOriginal"
+ orient="horizontal"
+ disabled="true"
+ class="counter-buttons"
+ accesskey="&counter.button.original.accesskey;"
+ tooltip="&counter.button.original.tooltip2;"
+ />
+ </vbox>
+ </hbox>
+
+ <vbox id="event-dialog-notifications">
+ <!-- notificationbox will be added here lazily. -->
+ </vbox>
+
+ <html:table id="event-grid">
+ <!-- Calendar -->
+ <html:tr>
+ <html:th>
+ <label
+ id="item-calendar-label"
+ value="&event.calendar.label;"
+ accesskey="&event.calendar.accesskey;"
+ control="item-calendar"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td id="event-grid-item-calendar-td">
+ <menulist id="item-calendar" disable-on-readonly="true" oncommand="updateCalendar();" />
+ </html:td>
+ </html:tr>
+
+ <!-- Title -->
+ <html:tr id="event-grid-title-row">
+ <html:th>
+ <label
+ id="item-title-label"
+ value="&event.title.textbox.label;"
+ accesskey="&event.title.textbox.accesskey;"
+ control="item-title"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td class="event-input-td">
+ <html:input
+ id="item-title"
+ disable-on-readonly="true"
+ oninput="updateTitle()"
+ aria-labelledby="item-title-label"
+ />
+ </html:td>
+ </html:tr>
+
+ <!-- Location -->
+ <html:tr id="event-grid-location-row">
+ <html:th>
+ <label
+ id="item-location-label"
+ value="&event.location.label;"
+ accesskey="&event.location.accesskey;"
+ control="item-location"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td class="event-input-td">
+ <html:input
+ id="item-location"
+ disable-on-readonly="true"
+ aria-labelledby="item-location-label"
+ />
+ </html:td>
+ </html:tr>
+
+ <!-- Category -->
+ <html:tr id="event-grid-category-row">
+ <html:th>
+ <hbox id="event-grid-category-labels-box">
+ <label
+ id="item-categories-label"
+ value="&event.categories.label;"
+ accesskey="&event.categories.accesskey;"
+ control="item-categories"
+ disable-on-readonly="true"
+ />
+ </hbox>
+ </html:th>
+ <html:td id="event-grid-category-td">
+ <menulist id="item-categories" type="panel-menulist" disable-on-readonly="true">
+ <menupopup
+ id="item-categories-popup"
+ onpopuphiding="return categoryPopupHiding(event);"
+ >
+ <html:input
+ id="item-categories-textbox"
+ placeholder="&event.categories.textbox.label;"
+ onblur="this.parentNode.removeAttribute('ignorekeys');"
+ onfocus="this.parentNode.setAttribute('ignorekeys', 'true');"
+ onkeypress="categoryTextboxKeypress(event);"
+ />
+ <menuseparator />
+ </menupopup>
+ </menulist>
+ </html:td>
+ </html:tr>
+
+ <html:tr class="separator">
+ <html:td colspan="2"></html:td>
+ </html:tr>
+
+ <!-- All-Day -->
+ <html:tr id="event-grid-allday-row" class="event-only">
+ <html:th> </html:th>
+ <html:td>
+ <checkbox
+ id="event-all-day"
+ disable-on-readonly="true"
+ label="&event.alldayevent.label;"
+ accesskey="&event.alldayevent.accesskey;"
+ oncommand="onUpdateAllDay();"
+ />
+ </html:td>
+ </html:tr>
+
+ <!-- StartDate -->
+ <html:tr id="event-grid-startdate-row">
+ <html:th id="event-grid-startdate-th">
+ <hbox id="event-grid-startdate-label-box" align="center">
+ <label
+ value="&event.from.label;"
+ accesskey="&event.from.accesskey;"
+ control="event-starttime"
+ class="event-only"
+ disable-on-readonly="true"
+ />
+ <label
+ value="&task.from.label;"
+ accesskey="&task.from.accesskey;"
+ control="todo-has-entrydate"
+ class="todo-only"
+ disable-on-readonly="true"
+ />
+ </hbox>
+ </html:th>
+ <html:td id="event-grid-startdate-td">
+ <hbox id="event-grid-startdate-picker-box">
+ <datetimepicker
+ id="event-starttime"
+ class="event-only"
+ disable-on-readonly="true"
+ onchange="dateTimeControls2State(true);"
+ />
+ <checkbox
+ id="todo-has-entrydate"
+ class="todo-only checkbox-no-label"
+ disable-on-readonly="true"
+ oncommand="updateEntryDate();"
+ />
+ <datetimepicker
+ id="todo-entrydate"
+ class="todo-only"
+ disable-on-readonly="true"
+ onchange="dateTimeControls2State(true);"
+ />
+ <vbox>
+ <hbox>
+ <html:img
+ id="link-image-top"
+ src="chrome://calendar/skin/shared/link-image-top.svg"
+ alt=""
+ class="keepduration-link-image"
+ keep="true"
+ />
+ </hbox>
+ <spacer flex="1" />
+ <toolbarbutton
+ id="keepduration-button"
+ accesskey="&event.dialog.keepDurationButton.accesskey;"
+ oncommand="toggleKeepDuration();"
+ persist="keep"
+ keep="false"
+ tooltiptext="&event.dialog.keepDurationButton.tooltip;"
+ />
+ </vbox>
+ <hbox align="center">
+ <label
+ id="timezone-starttime"
+ class="text-link"
+ collapsed="true"
+ crop="end"
+ disable-on-readonly="true"
+ hyperlink="true"
+ onclick="showTimezonePopup(event, gStartTime.getInTimezone(gStartTimezone), editStartTimezone)"
+ />
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+
+ <!-- EndDate -->
+ <html:tr id="event-grid-enddate-row">
+ <html:th>
+ <hbox id="event-grid-enddate-label-box" align="center">
+ <label
+ value="&event.to.label;"
+ accesskey="&event.to.accesskey;"
+ control="event-endtime"
+ class="event-only"
+ disable-on-readonly="true"
+ />
+ <label
+ value="&task.to.label;"
+ accesskey="&task.to.accesskey;"
+ control="todo-has-duedate"
+ class="todo-only"
+ disable-on-readonly="true"
+ />
+ </hbox>
+ </html:th>
+ <html:td id="event-grid-enddate-td">
+ <vbox id="event-grid-enddate-vbox">
+ <hbox id="event-grid-enddate-picker-box">
+ <datetimepicker
+ id="event-endtime"
+ class="event-only"
+ disable-on-readonly="true"
+ onchange="dateTimeControls2State(false);"
+ />
+ <checkbox
+ id="todo-has-duedate"
+ class="todo-only checkbox-no-label"
+ disable-on-readonly="true"
+ oncommand="updateDueDate();"
+ />
+ <datetimepicker
+ id="todo-duedate"
+ class="todo-only"
+ disable-on-readonly="true"
+ onchange="dateTimeControls2State(false);"
+ />
+ <vbox pack="end">
+ <html:img
+ id="link-image-bottom"
+ alt=""
+ src="chrome://calendar/skin/shared/link-image-bottom.svg"
+ class="keepduration-link-image"
+ />
+ </vbox>
+ <hbox align="center">
+ <label
+ id="timezone-endtime"
+ class="text-link"
+ collapsed="true"
+ crop="end"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"
+ onclick="showTimezonePopup(event, gEndTime.getInTimezone(gEndTimezone), editEndTimezone)"
+ />
+ </hbox>
+ </hbox>
+ </vbox>
+ </html:td>
+ </html:tr>
+
+ <html:tr id="event-grid-todo-status-row" class="todo-only">
+ <html:th>
+ <label
+ id="todo-status-label"
+ value="&task.status.label;"
+ accesskey="&task.status.accesskey;"
+ control="todo-status"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td id="event-grid-todo-status-td">
+ <hbox id="event-grid-todo-status-picker-box" align="center">
+ <menulist
+ id="todo-status"
+ class="todo-only"
+ disable-on-readonly="true"
+ oncommand="updateToDoStatus(this.value);"
+ >
+ <menupopup id="todo-status-menupopup">
+ <menuitem
+ id="todo-status-none-menuitem"
+ label="&newevent.todoStatus.none.label;"
+ value="NONE"
+ />
+ <menuitem
+ id="todo-status-needsaction-menuitem"
+ label="&newevent.status.needsaction.label;"
+ value="NEEDS-ACTION"
+ />
+ <menuitem
+ id="todo-status-inprogress-menuitem"
+ label="&newevent.status.inprogress.label;"
+ value="IN-PROCESS"
+ />
+ <menuitem
+ id="todo-status-completed-menuitem"
+ label="&newevent.status.completed.label;"
+ value="COMPLETED"
+ />
+ <menuitem
+ id="todo-status-canceled-menuitem"
+ label="&newevent.todoStatus.cancelled.label;"
+ value="CANCELLED"
+ />
+ </menupopup>
+ </menulist>
+ <datepicker
+ id="completed-date-picker"
+ class="todo-only"
+ disable-on-readonly="true"
+ disabled="true"
+ value=""
+ />
+ <html:input
+ id="percent-complete-textbox"
+ type="number"
+ class="size3 input-inline"
+ min="0"
+ max="100"
+ disable-on-readonly="true"
+ oninput="updateToDoStatus('percent-changed')"
+ onselect="updateToDoStatus('percent-changed')"
+ />
+ <label
+ id="percent-complete-label"
+ class="todo-only"
+ disable-on-readonly="true"
+ value="&newtodo.percentcomplete.label;"
+ />
+ </hbox>
+ </html:td>
+ </html:tr>
+
+ <!-- Recurrence -->
+ <html:tr id="event-grid-recurrence-row">
+ <html:th>
+ <label
+ value="&event.repeat.label;"
+ accesskey="&event.repeat.accesskey;"
+ control="item-repeat"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td id="event-grid-recurrence-td">
+ <hbox id="event-grid-recurrence-picker-box" align="center" flex="1">
+ <menulist
+ id="item-repeat"
+ disable-on-readonly="true"
+ oncommand="updateRepeat(null, true)"
+ >
+ <menupopup id="item-repeat-menupopup">
+ <menuitem
+ id="repeat-none-menuitem"
+ label="&event.repeat.does.not.repeat.label;"
+ selected="true"
+ value="none"
+ />
+ <menuitem
+ id="repeat-daily-menuitem"
+ label="&event.repeat.daily.label;"
+ value="daily"
+ />
+ <menuitem
+ id="repeat-weekly-menuitem"
+ label="&event.repeat.weekly.label;"
+ value="weekly"
+ />
+ <menuitem
+ id="repeat-weekday-menuitem"
+ label="&event.repeat.every.weekday.label;"
+ value="every.weekday"
+ />
+ <menuitem
+ id="repeat-biweekly-menuitem"
+ label="&event.repeat.bi.weekly.label;"
+ value="bi.weekly"
+ />
+ <menuitem
+ id="repeat-monthly-menuitem"
+ label="&event.repeat.monthly.label;"
+ value="monthly"
+ />
+ <menuitem
+ id="repeat-yearly-menuitem"
+ label="&event.repeat.yearly.label;"
+ value="yearly"
+ />
+ <menuseparator id="item-repeat-separator" />
+ <menuitem
+ id="repeat-custom-menuitem"
+ label="&event.repeat.custom.label;"
+ value="custom"
+ />
+ </menupopup>
+ </menulist>
+ <hbox id="repeat-untilDate" align="center" hidden="true">
+ <label
+ value="&event.until.label;"
+ accesskey="&event.until.accesskey;"
+ control="repeat-until-datepicker"
+ disable-on-readonly="true"
+ />
+ <datepicker
+ id="repeat-until-datepicker"
+ flex="1"
+ type="forever"
+ disable-on-readonly="true"
+ onchange="if (onLoad.hasLoaded) { checkUntilDate(); }"
+ value=""
+ />
+ </hbox>
+ <vbox id="repeat-details" flex="1" hidden="true">
+ <label
+ id="repeat-details-label"
+ class="text-link"
+ crop="end"
+ disable-on-readonly="true"
+ hyperlink="true"
+ flex="1"
+ onclick="if (onLoad.hasLoaded) { updateRepeat(); }"
+ />
+ </vbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+
+ <html:tr class="separator">
+ <html:td colspan="2"></html:td>
+ </html:tr>
+
+ <!-- Reminder (Alarm) -->
+ <html:tr id="event-grid-alarm-row">
+ <html:th>
+ <label
+ value="&event.reminder.label;"
+ accesskey="&event.reminder.accesskey;"
+ control="item-alarm"
+ disable-on-readonly="true"
+ />
+ </html:th>
+ <html:td>
+ <hbox id="event-grid-alarm-picker-box" align="center">
+ <menulist
+ id="item-alarm"
+ class="item-alarm"
+ disable-on-readonly="true"
+ oncommand="updateReminder()"
+ >
+ <menupopup id="item-alarm-menupopup">
+ <menuitem
+ id="reminder-none-menuitem"
+ label="&event.reminder.none.label;"
+ selected="true"
+ value="none"
+ />
+ <menuseparator id="reminder-none-separator" />
+ <menuitem
+ id="reminder-0minutes-menuitem"
+ label="&event.reminder.0minutes.before.label;"
+ length="0"
+ origin="before"
+ relation="START"
+ unit="minutes"
+ />
+ <menuitem
+ id="reminder-5minutes-menuitem"
+ label="&event.reminder.5minutes.before.label;"
+ length="5"
+ origin="before"
+ relation="START"
+ unit="minutes"
+ />
+ <menuitem
+ id="reminder-15minutes-menuitem"
+ label="&event.reminder.15minutes.before.label;"
+ length="15"
+ origin="before"
+ relation="START"
+ unit="minutes"
+ />
+ <menuitem
+ id="reminder-30minutes-menuitem"
+ label="&event.reminder.30minutes.before.label;"
+ length="30"
+ origin="before"
+ relation="START"
+ unit="minutes"
+ />
+ <menuseparator id="reminder-minutes-separator" />
+ <menuitem
+ id="reminder-1hour-menuitem"
+ label="&event.reminder.1hour.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="hours"
+ />
+ <menuitem
+ id="reminder-2hours-menuitem"
+ label="&event.reminder.2hours.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="hours"
+ />
+ <menuitem
+ id="reminder-12hours-menuitem"
+ label="&event.reminder.12hours.before.label;"
+ length="12"
+ origin="before"
+ relation="START"
+ unit="hours"
+ />
+ <menuseparator id="reminder-hours-separator" />
+ <menuitem
+ id="reminder-1day-menuitem"
+ label="&event.reminder.1day.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="days"
+ />
+ <menuitem
+ id="reminder-2days-menuitem"
+ label="&event.reminder.2days.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="days"
+ />
+ <menuitem
+ id="reminder-1week-menuitem"
+ label="&event.reminder.1week.before.label;"
+ length="7"
+ origin="before"
+ relation="START"
+ unit="days"
+ />
+ <menuseparator id="reminder-custom-separator" />
+ <menuitem
+ class="reminder-custom-menuitem"
+ label="&event.reminder.custom.label;"
+ value="custom"
+ />
+ </menupopup>
+ </menulist>
+ <hbox class="reminder-details">
+ <hbox class="alarm-icons-box" align="center" />
+ <!-- TODO oncommand? onkeypress? -->
+ <label
+ class="reminder-multiple-alarms-label text-link"
+ hidden="true"
+ value="&event.reminder.multiple.label;"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"
+ onclick="updateReminder()"
+ />
+ <label
+ class="reminder-single-alarms-label text-link"
+ hidden="true"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"
+ onclick="updateReminder()"
+ />
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+
+ <html:tr id="event-grid-link-separator" class="separator" hidden="hidden">
+ <html:td colspan="2"></html:td>
+ </html:tr>
+
+ <html:tr id="event-grid-link-row" hidden="hidden">
+ <html:th>
+ <label value="&event.url.label;" control="url-link" />
+ </html:th>
+ <html:td>
+ <label
+ id="url-link"
+ class="text-link"
+ onclick="launchBrowser(this.getAttribute('href'), event)"
+ oncommand="launchBrowser(this.getAttribute('href'), event)"
+ crop="end"
+ />
+ </html:td>
+ </html:tr>
+
+ <html:tr class="separator">
+ <html:td colspan="2"></html:td>
+ </html:tr>
+ </html:table>
+
+ <vbox id="event-grid-tab-vbox" flex="1">
+ <!-- Multi purpose tab box -->
+ <hbox id="event-grid-tab-box-row">
+ <tabbox id="event-grid-tabbox" selectedIndex="0" flex="1">
+ <tabs id="event-grid-tabs">
+ <tab
+ id="event-grid-tab-description"
+ label="&event.description.label;"
+ accesskey="&event.description.accesskey;"
+ />
+ <tab
+ id="event-grid-tab-attachments"
+ label="&event.attachments.label;"
+ accesskey="&event.attachments.accesskey;"
+ />
+ <tab
+ id="event-grid-tab-attendees"
+ label="&event.attendees.label;"
+ accesskey="&event.attendees.accesskey;"
+ collapsed="true"
+ />
+ </tabs>
+ <tabpanels id="event-grid-tabpanels" flex="1">
+ <tabpanel id="event-grid-tabpanel-description" orient="vertical">
+ <toolbox id="FormatToolbox" mode="icons">
+ <toolbar
+ id="FormatToolbar"
+ class="inline-toolbar chromeclass-toolbar themeable-full"
+ nowindowdrag="true"
+ >
+ <toolbarbutton
+ id="paragraphButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&ParagraphSelect.tooltip;"
+ oncommand="goDoCommandParams('cmd_paragraphState', event.target.value);"
+ observes="cmd_renderedHTMLEnabler"
+ >
+ <menupopup id="paragraphPopup">
+ <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value="" />
+ <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1" />
+ <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2" />
+ <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3" />
+ <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4" />
+ <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5" />
+ <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6" />
+ <menuitem
+ id="toolbarmenu_pre"
+ label="&paragraphPreformatCmd.label;"
+ value="pre"
+ />
+ </menupopup>
+ </toolbarbutton>
+ <toolbarseparator class="toolbarseparator-standard" />
+ <toolbarbutton
+ id="boldButton"
+ class="formatting-button"
+ tooltiptext="&boldToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_bold"
+ />
+ <toolbarbutton
+ id="italicButton"
+ class="formatting-button"
+ tooltiptext="&italicToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_italic"
+ />
+ <toolbarbutton
+ id="underlineButton"
+ class="formatting-button"
+ tooltiptext="&underlineToolbarCmd.tooltip;"
+ type="checkbox"
+ autoCheck="false"
+ observes="cmd_underline"
+ />
+ <toolbarseparator class="toolbarseparator-standard" />
+ <toolbarbutton
+ id="ulButton"
+ class="formatting-button"
+ tooltiptext="&bulletListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ul"
+ />
+ <toolbarbutton
+ id="olButton"
+ class="formatting-button"
+ tooltiptext="&numberListToolbarCmd.tooltip;"
+ type="radio"
+ group="lists"
+ autoCheck="false"
+ observes="cmd_ol"
+ />
+ <toolbarbutton
+ id="outdentButton"
+ class="formatting-button"
+ tooltiptext="&outdentToolbarCmd.tooltip;"
+ observes="cmd_outdent"
+ />
+ <toolbarbutton
+ id="indentButton"
+ class="formatting-button"
+ tooltiptext="&indentToolbarCmd.tooltip;"
+ observes="cmd_indent"
+ />
+ <toolbarseparator class="toolbarseparator-standard" />
+ <toolbarbutton
+ id="AlignPopupButton"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&AlignPopupButton.tooltip;"
+ observes="cmd_align"
+ >
+ <menupopup id="AlignPopup">
+ <menuitem
+ id="AlignLeftItem"
+ class="menuitem-iconic"
+ label="&alignLeft.label;"
+ oncommand="doStatefulCommand('cmd_align', 'left')"
+ tooltiptext="&alignLeftButton.tooltip;"
+ />
+ <menuitem
+ id="AlignCenterItem"
+ class="menuitem-iconic"
+ label="&alignCenter.label;"
+ oncommand="doStatefulCommand('cmd_align', 'center')"
+ tooltiptext="&alignCenterButton.tooltip;"
+ />
+ <menuitem
+ id="AlignRightItem"
+ class="menuitem-iconic"
+ label="&alignRight.label;"
+ oncommand="doStatefulCommand('cmd_align', 'right')"
+ tooltiptext="&alignRightButton.tooltip;"
+ />
+ <menuitem
+ id="AlignJustifyItem"
+ class="menuitem-iconic"
+ label="&alignJustify.label;"
+ oncommand="doStatefulCommand('cmd_align', 'justify')"
+ tooltiptext="&alignJustifyButton.tooltip;"
+ />
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton
+ id="linkButton"
+ class="formatting-button"
+ tooltiptext="&linkToolbarCmd.label;"
+ oncommand="insertLink();"
+ observes="cmd_renderedHTMLEnabler"
+ />
+ <toolbarbutton
+ id="smileButtonMenu"
+ type="menu"
+ wantdropmarker="true"
+ class="formatting-button"
+ tooltiptext="&SmileButton.tooltip;"
+ observes="cmd_renderedHTMLEnabler"
+ >
+ <menupopup id="smileyPopup" class="no-icon-menupopup">
+ <menuitem
+ id="smileySmile"
+ class="menuitem-iconic"
+ label="&#128578; &smiley1Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128578;')"
+ />
+ <menuitem
+ id="smileyFrown"
+ class="menuitem-iconic"
+ label="&#128577; &smiley2Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128577;')"
+ />
+ <menuitem
+ id="smileyWink"
+ class="menuitem-iconic"
+ label="&#128521; &smiley3Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128521;')"
+ />
+ <menuitem
+ id="smileyTongue"
+ class="menuitem-iconic"
+ label="&#128539; &smiley4Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128539;')"
+ />
+ <menuitem
+ id="smileyLaughing"
+ class="menuitem-iconic"
+ label="&#128514; &smiley5Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128514;')"
+ />
+ <menuitem
+ id="smileyEmbarassed"
+ class="menuitem-iconic"
+ label="&#128563; &smiley6Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128563;')"
+ />
+ <menuitem
+ id="smileyUndecided"
+ class="menuitem-iconic"
+ label="&#128533; &smiley7Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128533;')"
+ />
+ <menuitem
+ id="smileySurprise"
+ class="menuitem-iconic"
+ label="&#128558; &smiley8Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128558;')"
+ />
+ <menuitem
+ id="smileyKiss"
+ class="menuitem-iconic"
+ label="&#128536; &smiley9Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128536;')"
+ />
+ <menuitem
+ id="smileyYell"
+ class="menuitem-iconic"
+ label="&#128544; &smiley10Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128544;')"
+ />
+ <menuitem
+ id="smileyCool"
+ class="menuitem-iconic"
+ label="&#128526; &smiley11Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128526;')"
+ />
+ <menuitem
+ id="smileyMoney"
+ class="menuitem-iconic"
+ label="&#129297; &smiley12Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129297;')"
+ />
+ <menuitem
+ id="smileyFoot"
+ class="menuitem-iconic"
+ label="&#128556; &smiley13Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128556;')"
+ />
+ <menuitem
+ id="smileyInnocent"
+ class="menuitem-iconic"
+ label="&#128519; &smiley14Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128519;')"
+ />
+ <menuitem
+ id="smileyCry"
+ class="menuitem-iconic"
+ label="&#128557; &smiley15Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#128557;')"
+ />
+ <menuitem
+ id="smileySealed"
+ class="menuitem-iconic"
+ label="&#129296; &smiley16Cmd.label;"
+ oncommand="goDoCommandParams('cmd_smiley', '&#129296;')"
+ />
+ </menupopup>
+ </toolbarbutton>
+ </toolbar>
+ </toolbox>
+ <editor
+ id="item-description"
+ type="content"
+ primary="true"
+ editortype="html"
+ oncontextmenu="openEditorContextMenu(event);"
+ disable-on-readonly="true"
+ flex="1"
+ />
+ </tabpanel>
+ <tabpanel id="event-grid-tabpanel-attachments">
+ <vbox flex="1">
+ <richlistbox
+ id="attachment-link"
+ seltype="single"
+ context="attachment-popup"
+ rows="3"
+ flex="1"
+ disable-on-readonly="true"
+ onkeypress="attachmentLinkKeyPress(event)"
+ ondblclick="attachmentDblClick(event);"
+ />
+ </vbox>
+ </tabpanel>
+ <tabpanel id="event-grid-tabpanel-attendees" collapsed="true">
+ <vbox flex="1">
+ <hbox id="item-organizer-row" hidden="true" align="start">
+ <label value="&read.only.organizer.label;" />
+ </hbox>
+ <vbox
+ class="item-attendees-list-container"
+ dialog-type="event"
+ flex="1"
+ context="attendee-popup"
+ oncontextmenu="setAttendeeContext(event)"
+ disable-on-readonly="true"
+ />
+ </vbox>
+ </tabpanel>
+ </tabpanels>
+ <hbox
+ id="notify-options"
+ dialog-type="event"
+ align="center"
+ collapsed="true"
+ disable-on-readonly="true"
+ >
+ <checkbox
+ id="notify-attendees-checkbox"
+ label="&event.attendees.notify.label;"
+ accesskey="&event.attendees.notify.accesskey;"
+ oncommand="changeUndiscloseCheckboxStatus();"
+ pack="start"
+ />
+ <checkbox
+ id="undisclose-attendees-checkbox"
+ label="&event.attendees.notifyundisclosed.label;"
+ accesskey="&event.attendees.notifyundisclosed.accesskey;"
+ tooltiptext="&event.attendees.notifyundisclosed.tooltip;"
+ pack="start"
+ />
+ <checkbox
+ id="disallow-counter-checkbox"
+ label="&event.attendees.disallowcounter.label;"
+ accesskey="&event.attendees.disallowcounter.accesskey;"
+ tooltiptext="&event.attendees.disallowcounter.tooltip;"
+ pack="start"
+ />
+ </hbox>
+ </tabbox>
+ </hbox>
+ </vbox>
+
+ <popupset id="event-dialog-popupset">
+ <menupopup id="attendee-popup">
+ <menuitem
+ id="attendee-popup-invite-menuitem"
+ label="&event.invite.attendees.label;"
+ accesskey="&event.invite.attendees.accesskey;"
+ command="cmd_attendees"
+ disable-on-readonly="true"
+ />
+ <menuitem
+ id="attendee-popup-removeallattendees-menuitem"
+ label="&event.remove.attendees.label2;"
+ accesskey="&event.remove.attendees.accesskey;"
+ oncommand="removeAllAttendees()"
+ disable-on-readonly="true"
+ crop="end"
+ />
+ <menuitem
+ id="attendee-popup-removeattendee-menuitem"
+ label="&event.remove.attendee.label;"
+ accesskey="&event.remove.attendee.accesskey;"
+ oncommand="removeAttendee(event.target.attendee)"
+ crop="end"
+ />
+ <menuseparator id="attendee-popup-first-separator" />
+ <menuitem
+ id="attendee-popup-sendemail-menuitem"
+ label="&event.email.attendees.label;"
+ accesskey="&event.email.attendees.accesskey;"
+ command="cmd_email"
+ />
+ <menuitem
+ id="attendee-popup-sendtentativeemail-menuitem"
+ label="&event.email.tentative.attendees.label;"
+ accesskey="&event.email.tentative.attendees.accesskey;"
+ command="cmd_email_undecided"
+ />
+ <menuseparator id="attendee-popup-second-separator" />
+ <menuitem
+ id="attendee-popup-emailattendee-menuitem"
+ oncommand="sendMailToAttendees([event.target.attendee])"
+ crop="end"
+ />
+ </menupopup>
+ <menupopup id="attachment-popup" onpopupshowing="attachmentClick(event)">
+ <menuitem
+ id="attachment-popup-open"
+ label="&event.attachments.popup.open.label;"
+ accesskey="&event.attachments.popup.open.accesskey;"
+ command="cmd_openAttachment"
+ />
+ <menuitem
+ id="attachment-popup-copy"
+ label="&calendar.copylink.label;"
+ accesskey="&calendar.copylink.accesskey;"
+ command="cmd_copyAttachment"
+ />
+ <menuitem
+ id="attachment-popup-delete"
+ label="&event.attachments.popup.remove.label;"
+ accesskey="&event.attachments.popup.remove.accesskey;"
+ command="cmd_deleteAttachment"
+ />
+ <menuitem
+ id="attachment-popup-deleteAll"
+ label="&event.attachments.popup.removeAll.label;"
+ accesskey="&event.attachments.popup.removeAll.accesskey;"
+ command="cmd_deleteAllAttachments"
+ />
+ <menuseparator />
+ <menuitem
+ id="attachment-popup-attachPage"
+ label="&event.attachments.popup.attachPage.label;"
+ accesskey="&event.attachments.popup.attachPage.accesskey;"
+ command="cmd_attach_url"
+ />
+ </menupopup>
+ <menupopup id="timezone-popup" position="after_start" oncommand="chooseRecentTimezone(event)">
+ <menuitem id="timezone-popup-defaulttz" />
+ <menuseparator id="timezone-popup-menuseparator" />
+ <menuitem
+ id="timezone-custom-menuitem"
+ label="&event.timezone.custom.label;"
+ value="custom"
+ oncommand="this.parentNode.editTimezone()"
+ />
+ </menupopup>
+ </popupset>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml
new file mode 100644
index 0000000000..c567053ee6
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml
@@ -0,0 +1,130 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file requires the following localization files:
+# chrome://calendar/locale/global.dtd
+# chrome://calendar/locale/calendar.dtd
+# chrome://calendar/locale/calendar-event-dialog.dtd
+# chrome://lightning/locale/lightning-toolbar.dtd
+
+ <vbox id="calendarItemPanel" collapsed="true">
+
+ <!-- The id of the inner vbox and the iframe are set dynamically
+ when a tab is created. -->
+ <vbox flex="1"
+ id="dummy-calendar-event-dialog-tab"
+ class="calendar-event-dialog-tab">
+
+ <!-- Commands -->
+ <commandset id="itemCommands">
+ <command id="cmd_save"
+ disable-on-readonly="true"
+ oncommand="onCommandSave()"/>
+ <command id="cmd_item_delete"
+ disable-on-readonly="true"
+ oncommand="onCommandDeleteItem()"/>
+
+ <!-- View menu -->
+ <command id="cmd_customize"
+ oncommand="onCommandCustomize()"/>
+
+ <!-- status -->
+ <command id="cmd_status_none"
+ oncommand="editStatus(event.target)"
+ hidden="true"
+ value="NONE"/>
+ <command id="cmd_status_tentative"
+ oncommand="editStatus(event.target)"
+ value="TENTATIVE"/>
+ <command id="cmd_status_confirmed"
+ oncommand="editStatus(event.target)"
+ value="CONFIRMED"/>
+ <command id="cmd_status_cancelled"
+ oncommand="editStatus(event.target)"
+ value="CANCELLED"/>
+
+ <!-- priority -->
+ <command id="cmd_priority_none"
+ oncommand="editPriority(event.target)"
+ value="0"/>
+ <command id="cmd_priority_low"
+ oncommand="editPriority(event.target)"
+ value="9"/>
+ <command id="cmd_priority_normal"
+ oncommand="editPriority(event.target)"
+ value="5"/>
+ <command id="cmd_priority_high"
+ oncommand="editPriority(event.target)"
+ value="1"/>
+
+ <!-- freebusy -->
+ <command id="cmd_showtimeas_busy"
+ oncommand="editShowTimeAs(event.target)"
+ value="OPAQUE"/>
+ <command id="cmd_showtimeas_free"
+ oncommand="editShowTimeAs(event.target)"
+ value="TRANSPARENT"/>
+
+ <!-- attendees -->
+ <command id="cmd_attendees"
+ oncommand="editAttendees();"/>
+
+ <!-- accept, attachments, timezone -->
+ <command id="cmd_accept"
+ disable-on-readonly="true"
+ oncommand="sendMessage({ command: 'onAccept' });"/>
+ <command id="cmd_attach_url"
+ disable-on-readonly="true"
+ oncommand="attachURL()"/>
+ <command id="cmd_attach_cloud"
+ disable-on-readonly="true"/>
+ <command id="cmd_timezone"
+ persist="checked"
+ checked="false"
+ oncommand="toggleTimezoneLinks()"/>
+ </commandset>
+
+ <keyset id="calendar-event-dialog-keyset">
+ <key id="save-key"
+ modifiers="accel, shift"
+ key="&event.dialog.save.key;"
+ command="cmd_save"/>
+ <key id="saveandclose-key"
+ modifiers="accel"
+ key="&event.dialog.saveandclose.key;"
+ command="cmd_accept"/>
+ <key id="saveandclose-key2"
+ modifiers="accel"
+ keycode="VK_RETURN"
+ command="cmd_accept"/>
+ </keyset>
+
+ <toolbox id="event-toolbox"
+ class="mail-toolbox"
+ mode="full"
+ defaultmode="full"
+ iconsize="small"
+ defaulticonsize="small"
+ labelalign="end"
+ defaultlabelalign="end">
+ <toolbarpalette id="event-toolbarpalette">
+#include calendar-item-toolbar.inc.xhtml
+ </toolbarpalette>
+ <!-- toolboxid is set here since we move the toolbar around for tabs -->
+ <toolbar is="customizable-toolbar" id="event-tab-toolbar"
+ toolbarname="&event.menu.view.toolbars.event.label;"
+ accesskey="&event.menu.view.toolbars.event.accesskey;"
+ toolboxid="event-toolbox"
+ class="chromeclass-toolbar inline-toolbar themeable-full"
+ customizable="true"
+ labelalign="end"
+ defaultlabelalign="end"
+ context="event-dialog-toolbar-context-menu"
+ defaultset="button-saveandclose,button-attendees,button-privacy,button-url,button-priority,button-status,button-freebusy,button-delete,spring"/>
+ </toolbox>
+
+ <iframe id="calendar-item-panel-iframe" flex="1"/>
+
+ </vbox>
+ </vbox>
diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.js b/comm/calendar/base/content/item-editing/calendar-item-panel.js
new file mode 100644
index 0000000000..f2550d509a
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-panel.js
@@ -0,0 +1,1143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported onLoadCalendarItemPanel, onCancel, onCommandSave,
+ * onCommandDeleteItem, editAttendees, editPrivacy, editPriority,
+ * editStatus, editShowTimeAs, updateShowTimeAs, editToDoStatus,
+ * postponeTask, toggleTimezoneLinks, attachURL,
+ * onCommandViewToolbar, onCommandCustomize, attachFileByAccountKey,
+ * onUnloadCalendarItemPanel, openNewEvent, openNewTask,
+ * openNewMessage
+ */
+
+/* import-globals-from ../../../../mail/base/content/globalOverlay.js */
+/* import-globals-from ../dialogs/calendar-dialog-utils.js */
+/* import-globals-from ../calendar-ui-utils.js */
+
+// XXX Need to determine which of these we really need here.
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var gTabmail;
+window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ // gTabmail is null if we are in a dialog window and not in a tab.
+ gTabmail = document.getElementById("tabmail") || null;
+
+ if (!gTabmail) {
+ // In a dialog window the following menu item functions need to be
+ // defined. In a tab they are defined elsewhere. To prevent errors in
+ // the log they are defined here (before the onLoad function is called).
+ /**
+ * Update menu items that rely on focus.
+ */
+ window.goUpdateGlobalEditMenuItems = () => {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_paste");
+ goUpdateCommand("cmd_selectAll");
+ };
+ /**
+ * Update menu items that rely on the current selection.
+ */
+ window.goUpdateSelectEditMenuItems = () => {
+ goUpdateCommand("cmd_cut");
+ goUpdateCommand("cmd_copy");
+ goUpdateCommand("cmd_delete");
+ goUpdateCommand("cmd_selectAll");
+ };
+ /**
+ * Update menu items that relate to undo/redo.
+ */
+ window.goUpdateUndoEditMenuItems = () => {
+ goUpdateCommand("cmd_undo");
+ goUpdateCommand("cmd_redo");
+ };
+ /**
+ * Update menu items that depend on clipboard contents.
+ */
+ window.goUpdatePasteMenuItems = () => {
+ goUpdateCommand("cmd_paste");
+ };
+ }
+ },
+ { once: true }
+);
+
+// Stores the ids of the iframes of currently open event/task tabs, used
+// when window is closed to prompt for saving changes.
+var gItemTabIds = [];
+var gItemTabIdsCopy;
+
+// gConfig is used when switching tabs to restore the state of
+// toolbar, statusbar, and menubar for the current tab.
+var gConfig = {
+ isEvent: null,
+ privacy: null,
+ hasPrivacy: null,
+ calendarType: null,
+ privacyValues: null,
+ priority: null,
+ hasPriority: null,
+ status: null,
+ percentComplete: null,
+ showTimeAs: null,
+ // whether commands are enabled or disabled
+ attendeesCommand: null, // cmd_attendees
+ attachUrlCommand: null, // cmd_attach_url
+ timezonesEnabled: false, // cmd_timezone
+};
+
+/**
+ * Receive an asynchronous message from the iframe.
+ *
+ * @param {MessageEvent} aEvent - Contains the message being received
+ */
+function receiveMessage(aEvent) {
+ if (aEvent.origin !== "chrome://calendar") {
+ return;
+ }
+ switch (aEvent.data.command) {
+ case "initializeItemMenu":
+ initializeItemMenu(aEvent.data.label, aEvent.data.accessKey);
+ break;
+ case "cancelDialog":
+ document.querySelector("dialog").cancelDialog();
+ break;
+ case "closeWindowOrTab":
+ closeWindowOrTab(aEvent.data.iframeId);
+ break;
+ case "showCmdStatusNone":
+ document.getElementById("cmd_status_none").removeAttribute("hidden");
+ break;
+ case "updateTitle":
+ updateTitle(aEvent.data.prefix, aEvent.data.title);
+ break;
+ case "updateConfigState":
+ updateItemTabState(aEvent.data.argument);
+ Object.assign(gConfig, aEvent.data.argument);
+ break;
+ case "enableAcceptCommand":
+ enableAcceptCommand(aEvent.data.argument);
+ break;
+ case "replyToClosingWindowWithTabs":
+ handleWindowClose(aEvent.data.response);
+ break;
+ case "removeDisableAndCollapseOnReadonly":
+ removeDisableAndCollapseOnReadonly();
+ break;
+ case "setElementAttribute": {
+ let arg = aEvent.data.argument;
+ document.getElementById(arg.id)[arg.attribute] = arg.value;
+ break;
+ }
+ case "loadCloudProviders": {
+ loadCloudProviders(aEvent.data.items);
+ break;
+ }
+ case "updateSaveControls": {
+ updateSaveControls(aEvent.data.argument.sendNotSave);
+ break;
+ }
+ }
+}
+
+window.addEventListener("message", receiveMessage);
+
+/**
+ * Send an asynchronous message to an iframe. Additional properties of
+ * aMessage are generally arguments that will be passed to the function
+ * named in aMessage.command. If aIframeId is omitted, the message will
+ * be sent to the iframe of the current tab.
+ *
+ * @param {object} aMessage - Contains the message being sent
+ * @param {string} aMessage.command - The name of a function to call
+ * @param {string} aIframeId - (optional) id of an iframe to send the message to
+ */
+function sendMessage(aMessage, aIframeId) {
+ let iframeId = gTabmail
+ ? aIframeId || gTabmail.currentTabInfo.iframe.id
+ : "calendar-item-panel-iframe";
+ let iframe = document.getElementById(iframeId);
+ iframe.contentWindow.postMessage(aMessage, "*");
+}
+
+/**
+ * When the user closes the window, this function handles prompting them
+ * to save any unsaved changes for any open item tabs, before closing the
+ * window, or not if 'cancel' was clicked. Requires sending and receiving
+ * async messages from the iframes of all open item tabs.
+ *
+ * @param {boolean} aResponse - The response from the tab's iframe
+ */
+function handleWindowClose(aResponse) {
+ if (!aResponse) {
+ // Cancel was clicked, just leave the window open. We're done.
+ } else if (gItemTabIdsCopy.length > 0) {
+ // There are more unsaved changes in tabs to prompt the user about.
+ let nextId = gItemTabIdsCopy.shift();
+ sendMessage({ command: "closingWindowWithTabs", id: nextId }, nextId);
+ } else {
+ // Close the window, there are no more unsaved changes in tabs.
+ window.removeEventListener("close", windowCloseListener);
+ window.close();
+ }
+}
+
+/**
+ * Listener function for window close. We prevent the window from
+ * closing, then for each open tab we prompt the user to save any
+ * unsaved changes with handleWindowClose.
+ *
+ * @param {object} aEvent - The window close event
+ */
+function windowCloseListener(aEvent) {
+ aEvent.preventDefault();
+ gItemTabIdsCopy = gItemTabIds.slice();
+ handleWindowClose(true);
+}
+
+/**
+ * Load handler for the outer parent context that contains the iframe.
+ *
+ * @param {string} aIframeId - (optional) Id of the iframe in this tab
+ * @param {string} aUrl - (optional) The url to load in the iframe
+ */
+function onLoadCalendarItemPanel(aIframeId, aUrl) {
+ let iframe;
+ let iframeSrc;
+ let dialog = document.querySelector("dialog");
+
+ if (!gTabmail) {
+ gTabmail = document.getElementById("tabmail") || null;
+ // This should not happen.
+ if (gTabmail) {
+ console.warn(
+ "gTabmail was undefined on document load and is defined now, that should not happen."
+ );
+ }
+ }
+ if (gTabmail) {
+ // tab case
+ let iframeId = aIframeId || gTabmail.currentTabInfo.iframe.id;
+ iframe = document.getElementById(iframeId);
+ iframeSrc = aUrl;
+
+ // Add a listener to detect close events, prompt user about saving changes.
+ window.addEventListener("close", windowCloseListener);
+ } else {
+ // window dialog case
+ iframe = document.createXULElement("iframe");
+ iframeSrc = "chrome://calendar/content/calendar-item-iframe.xhtml";
+
+ iframe.setAttribute("id", "calendar-item-panel-iframe");
+ iframe.setAttribute("flex", "1");
+
+ // Note: iframe.contentWindow is undefined before the iframe is inserted here.
+ dialog.insertBefore(iframe, document.getElementById("status-bar"));
+
+ iframe.contentWindow.addEventListener(
+ "load",
+ () => {
+ // Push setting dimensions to the end of the event queue.
+ setTimeout(() => {
+ let body = iframe.contentDocument.body;
+ // Make sure the body does not exceed its content's size.
+ body.style.width = "fit-content";
+ body.style.height = "fit-content";
+ let { scrollHeight, scrollWidth } = body;
+ iframe.style.minHeight = `${scrollHeight}px`;
+ iframe.style.minWidth = `${scrollWidth}px`;
+ // Reset the body.
+ body.style.width = null;
+ body.style.height = null;
+ });
+ },
+ { once: true }
+ );
+
+ // Move the args so they are positioned relative to the iframe,
+ // for the window dialog just as they are for the tab.
+ // XXX Should we delete the arguments here in the parent context
+ // so they are only accessible in one place?
+ iframe.contentWindow.arguments = [window.arguments[0]];
+
+ // hide the ok and cancel dialog buttons
+ let accept = dialog.getButton("accept");
+ let cancel = dialog.getButton("cancel");
+ accept.setAttribute("collapsed", "true");
+ cancel.setAttribute("collapsed", "true");
+ cancel.parentNode.setAttribute("collapsed", "true");
+
+ document.addEventListener("dialogaccept", event => {
+ let itemTitle = iframe.contentDocument.documentElement.querySelector("#item-title");
+ // Prevent dialog from saving if title is empty.
+ if (!itemTitle.value) {
+ event.preventDefault();
+ return;
+ }
+ sendMessage({ command: "onAccept" });
+ event.preventDefault();
+ });
+
+ document.addEventListener("dialogcancel", event => {
+ sendMessage({ command: "onCancel" });
+ event.preventDefault();
+ });
+
+ // set toolbar icon color for light or dark themes
+ if (typeof window.ToolbarIconColor !== "undefined") {
+ window.ToolbarIconColor.init();
+ }
+ }
+
+ // event or task
+ let calendarItem = iframe.contentWindow.arguments[0].calendarEvent;
+ gConfig.isEvent = calendarItem.isEvent();
+
+ // for tasks in a window dialog, set the dialog id for CSS selection.
+ if (!gTabmail) {
+ if (gConfig.isEvent) {
+ setDialogId(dialog, "calendar-event-dialog");
+ } else {
+ setDialogId(dialog, "calendar-task-dialog");
+ }
+ }
+
+ // timezones enabled
+ gConfig.timezonesEnabled = getTimezoneCommandState();
+ iframe.contentWindow.gTimezonesEnabled = gConfig.timezonesEnabled;
+
+ // set the iframe src, which loads the iframe's contents
+ iframe.setAttribute("src", iframeSrc);
+}
+
+/**
+ * Unload handler for the outer parent context that contains the iframe.
+ * Currently only called for windows and not tabs.
+ */
+function onUnloadCalendarItemPanel() {
+ if (!gTabmail) {
+ // window dialog case
+ if (typeof window.ToolbarIconColor !== "undefined") {
+ window.ToolbarIconColor.uninit();
+ }
+ }
+}
+
+/**
+ * Updates the UI. Called when a user makes a change and when an
+ * event/task tab is shown. When a tab is shown aArg contains the gConfig
+ * data for that event/task. We pass the full tab state object to the
+ * update functions and they just use the properties they need from it.
+ *
+ * @param {object} aArg - Its properties hold data about the event/task
+ */
+function updateItemTabState(aArg) {
+ const lookup = {
+ privacy: updatePrivacy,
+ priority: updatePriority,
+ status: updateStatus,
+ showTimeAs: updateShowTimeAs,
+ percentComplete: updateMarkCompletedMenuItem,
+ attendeesCommand: updateAttendeesCommand,
+ attachUrlCommand: updateAttachment,
+ timezonesEnabled: updateTimezoneCommand,
+ };
+ for (let key of Object.keys(aArg)) {
+ let procedure = lookup[key];
+ if (procedure) {
+ procedure(aArg);
+ }
+ }
+}
+
+/**
+ * When in a window, set Item-Menu label to Event or Task.
+ *
+ * @param {string} aLabel - The new name for the menu
+ * @param {string} aAccessKey - The access key for the menu
+ */
+function initializeItemMenu(aLabel, aAccessKey) {
+ let menuItem = document.getElementById("item-menu");
+ menuItem.setAttribute("label", aLabel);
+ menuItem.setAttribute("accesskey", aAccessKey);
+}
+
+/**
+ * Handler for when tab is cancelled. (calendar.item.editInTab = true)
+ *
+ * @param {string} aIframeId - The id of the iframe
+ */
+function onCancel(aIframeId) {
+ sendMessage({ command: "onCancel", iframeId: aIframeId }, aIframeId);
+ // We return false to prevent closing of a window until we
+ // can ask the user about saving any unsaved changes.
+ return false;
+}
+
+/**
+ * Closes tab or window. Called after prompting to save any unsaved changes.
+ *
+ * @param {string} aIframeId - The id of the iframe
+ */
+function closeWindowOrTab(iframeId) {
+ if (gTabmail) {
+ if (iframeId) {
+ // Find the tab associated with this iframeId, and close it.
+ let myTabInfo = gTabmail.tabInfo.filter(x => "iframe" in x && x.iframe.id == iframeId)[0];
+ myTabInfo.allowTabClose = true;
+ gTabmail.closeTab(myTabInfo);
+ } else {
+ gTabmail.currentTabInfo.allowTabClose = true;
+ gTabmail.removeCurrentTab();
+ }
+ } else {
+ window.close();
+ }
+}
+
+/**
+ * Handler for saving the event or task.
+ *
+ * @param {boolean} aIsClosing - Is the tab or window closing
+ */
+function onCommandSave(aIsClosing) {
+ sendMessage({ command: "onCommandSave", isClosing: aIsClosing });
+}
+
+/**
+ * Handler for deleting the event or task.
+ */
+function onCommandDeleteItem() {
+ sendMessage({ command: "onCommandDeleteItem" });
+}
+
+/**
+ * Disable the saving options according to the item title.
+ *
+ * @param {boolean} disabled - True if the save options needs to be disabled else false.
+ */
+function disableSaving(disabled) {
+ let cmdSave = document.getElementById("cmd_save");
+ if (cmdSave) {
+ cmdSave.setAttribute("disabled", disabled);
+ }
+ let cmdAccept = document.getElementById("cmd_accept");
+ if (cmdAccept) {
+ cmdAccept.setAttribute("disabled", disabled);
+ }
+}
+
+/**
+ * Update the title of the tab or window.
+ *
+ * @param {string} prefix - The prefix string according to the item.
+ * @param {string} title - The item title.
+ */
+function updateTitle(prefix, title) {
+ disableSaving(!title);
+ let newTitle = prefix + ": " + title;
+ if (gTabmail) {
+ gTabmail.currentTabInfo.title = newTitle;
+ gTabmail.setTabTitle(gTabmail.currentTabInfo);
+ } else {
+ document.title = newTitle;
+ }
+}
+
+/**
+ * Open a new event.
+ */
+function openNewEvent() {
+ sendMessage({ command: "openNewEvent" });
+}
+
+/**
+ * Open a new task.
+ */
+function openNewTask() {
+ sendMessage({ command: "openNewTask" });
+}
+
+/**
+ * Open a new Thunderbird compose window.
+ */
+function openNewMessage() {
+ MailServices.compose.OpenComposeWindow(
+ null,
+ null,
+ null,
+ Ci.nsIMsgCompType.New,
+ Ci.nsIMsgCompFormat.Default,
+ null,
+ null,
+ null
+ );
+}
+
+/**
+ * Handler for edit attendees command.
+ */
+function editAttendees() {
+ sendMessage({ command: "editAttendees" });
+}
+
+/**
+ * Sends a message to set the gConfig values in the iframe.
+ *
+ * @param {object} aArg - Container
+ * @param {string} aArg.privacy - (optional) New privacy value
+ * @param {short} aArg.priority - (optional) New priority value
+ * @param {string} aArg.status - (optional) New status value
+ * @param {string} aArg.showTimeAs - (optional) New showTimeAs / transparency value
+ */
+function editConfigState(aArg) {
+ sendMessage({ command: "editConfigState", argument: aArg });
+}
+
+/**
+ * Handler for changing privacy. aEvent is used for the popup menu
+ * event-privacy-menupopup in the Privacy toolbar button.
+ *
+ * @param {Node} aTarget Has the new privacy in its "value" attribute
+ * @param {XULCommandEvent} aEvent - (optional) the UI element selection event
+ */
+function editPrivacy(aTarget, aEvent) {
+ if (aEvent) {
+ aEvent.stopPropagation();
+ }
+ // "privacy" is indeed the correct attribute to use here
+ let newPrivacy = aTarget.getAttribute("privacy");
+ editConfigState({ privacy: newPrivacy });
+}
+
+/**
+ * Updates the UI according to the privacy setting and the selected
+ * calendar. If the selected calendar does not support privacy or only
+ * certain values, these are removed from the UI. This function should
+ * be called any time that privacy setting is updated.
+ *
+ * @param {object} aArg Contains privacy properties
+ * @param {string} aArg.privacy The new privacy value
+ * @param {boolean} aArg.hasPrivacy Whether privacy is supported
+ * @param {string} aArg.calendarType The type of calendar
+ * @param {string[]} aArg.privacyValues The possible privacy values
+ */
+function updatePrivacy(aArg) {
+ if (aArg.hasPrivacy) {
+ // Update privacy capabilities (toolbar)
+ let menupopup = document.getElementById("event-privacy-menupopup");
+ if (menupopup) {
+ // Only update the toolbar if the button is actually there
+ for (let node of menupopup.children) {
+ let currentProvider = node.getAttribute("provider");
+ if (node.hasAttribute("privacy")) {
+ let currentPrivacyValue = node.getAttribute("privacy");
+ // Collapsed state
+
+ // Hide the toolbar if the value is unsupported or is for a
+ // specific provider and doesn't belong to the current provider.
+ if (
+ !aArg.privacyValues.includes(currentPrivacyValue) ||
+ (currentProvider && currentProvider != aArg.calendarType)
+ ) {
+ node.setAttribute("collapsed", "true");
+ } else {
+ node.removeAttribute("collapsed");
+ }
+
+ // Checked state
+ if (aArg.privacy == currentPrivacyValue) {
+ node.setAttribute("checked", "true");
+ } else {
+ node.removeAttribute("checked");
+ }
+ }
+ }
+ }
+
+ // Update privacy capabilities (menu) but only if we are not in a tab.
+ if (!gTabmail) {
+ menupopup = document.getElementById("options-privacy-menupopup");
+ for (let node of menupopup.children) {
+ let currentProvider = node.getAttribute("provider");
+ if (node.hasAttribute("privacy")) {
+ let currentPrivacyValue = node.getAttribute("privacy");
+ // Collapsed state
+
+ // Hide the menu if the value is unsupported or is for a
+ // specific provider and doesn't belong to the current provider.
+ if (
+ !aArg.privacyValues.includes(currentPrivacyValue) ||
+ (currentProvider && currentProvider != aArg.calendarType)
+ ) {
+ node.setAttribute("collapsed", "true");
+ } else {
+ node.removeAttribute("collapsed");
+ }
+
+ // Checked state
+ if (aArg.privacy == currentPrivacyValue) {
+ node.setAttribute("checked", "true");
+ } else {
+ node.removeAttribute("checked");
+ }
+ }
+ }
+ }
+
+ // Update privacy capabilities (statusbar)
+ let privacyPanel = document.getElementById("status-privacy");
+ let hasAnyPrivacyValue = false;
+ for (let node of privacyPanel.children) {
+ let currentProvider = node.getAttribute("provider");
+ if (node.hasAttribute("privacy")) {
+ let currentPrivacyValue = node.getAttribute("privacy");
+
+ // Hide the panel if the value is unsupported or is for a
+ // specific provider and doesn't belong to the current provider,
+ // or is not the items privacy value
+ if (
+ !aArg.privacyValues.includes(currentPrivacyValue) ||
+ (currentProvider && currentProvider != aArg.calendarType) ||
+ aArg.privacy != currentPrivacyValue
+ ) {
+ node.setAttribute("collapsed", "true");
+ } else {
+ node.removeAttribute("collapsed");
+ hasAnyPrivacyValue = true;
+ }
+ }
+ }
+
+ // Don't show the status panel if no valid privacy value is selected
+ if (hasAnyPrivacyValue) {
+ privacyPanel.removeAttribute("collapsed");
+ } else {
+ privacyPanel.setAttribute("collapsed", "true");
+ }
+ } else {
+ // aArg.hasPrivacy is false
+ document.getElementById("button-privacy").disabled = true;
+ document.getElementById("status-privacy").collapsed = true;
+ // in the tab case the menu item does not exist
+ let privacyMenuItem = document.getElementById("options-privacy-menu");
+ if (privacyMenuItem) {
+ document.getElementById("options-privacy-menu").disabled = true;
+ }
+ }
+}
+
+/**
+ * Handler to change the priority.
+ *
+ * @param {Node} aTarget - Has the new priority in its "value" attribute
+ */
+function editPriority(aTarget) {
+ let newPriority = parseInt(aTarget.getAttribute("value"), 10);
+ editConfigState({ priority: newPriority });
+}
+
+/**
+ * Updates the dialog controls related to priority.
+ *
+ * @param {object} aArg Contains priority properties
+ * @param {string} aArg.priority The new priority value
+ * @param {boolean} aArg.hasPriority - Whether priority is supported
+ */
+function updatePriority(aArg) {
+ // Set up capabilities
+ if (document.getElementById("button-priority")) {
+ document.getElementById("button-priority").disabled = !aArg.hasPriority;
+ }
+ if (!gTabmail && document.getElementById("options-priority-menu")) {
+ document.getElementById("options-priority-menu").disabled = !aArg.hasPriority;
+ }
+ document.getElementById("status-priority").collapsed = !aArg.hasPriority;
+
+ if (aArg.hasPriority) {
+ let priorityLevel = "none";
+ if (aArg.priority >= 1 && aArg.priority <= 4) {
+ priorityLevel = "high";
+ } else if (aArg.priority == 5) {
+ priorityLevel = "normal";
+ } else if (aArg.priority >= 6 && aArg.priority <= 9) {
+ priorityLevel = "low";
+ }
+
+ let priorityNone = document.getElementById("cmd_priority_none");
+ let priorityLow = document.getElementById("cmd_priority_low");
+ let priorityNormal = document.getElementById("cmd_priority_normal");
+ let priorityHigh = document.getElementById("cmd_priority_high");
+
+ priorityNone.setAttribute("checked", priorityLevel == "none" ? "true" : "false");
+ priorityLow.setAttribute("checked", priorityLevel == "low" ? "true" : "false");
+ priorityNormal.setAttribute("checked", priorityLevel == "normal" ? "true" : "false");
+ priorityHigh.setAttribute("checked", priorityLevel == "high" ? "true" : "false");
+
+ // Status bar panel
+ let priorityPanel = document.getElementById("status-priority");
+ let image = priorityPanel.querySelector("img");
+ if (priorityLevel === "none") {
+ // If the priority is none, don't show the status bar panel
+ priorityPanel.setAttribute("collapsed", "true");
+ image.removeAttribute("data-l10n-id");
+ image.setAttribute("alt", "");
+ image.removeAttribute("src");
+ } else {
+ priorityPanel.removeAttribute("collapsed");
+ image.setAttribute("alt", cal.l10n.getString("calendar", `${priorityLevel}Priority`));
+ image.setAttribute(
+ "src",
+ `chrome://calendar/skin/shared/statusbar-priority-${priorityLevel}.svg`
+ );
+ }
+ }
+}
+
+/**
+ * Handler for changing the status.
+ *
+ * @param {Node} aTarget - Has the new status in its "value" attribute
+ */
+function editStatus(aTarget) {
+ let newStatus = aTarget.getAttribute("value");
+ editConfigState({ status: newStatus });
+}
+
+/**
+ * Update the dialog controls related to status.
+ *
+ * @param {object} aArg - Contains the new status value
+ * @param {string} aArg.status - The new status value
+ */
+function updateStatus(aArg) {
+ const statusLabels = [
+ "status-status-tentative-label",
+ "status-status-confirmed-label",
+ "status-status-cancelled-label",
+ ];
+ const commands = [
+ "cmd_status_none",
+ "cmd_status_tentative",
+ "cmd_status_confirmed",
+ "cmd_status_cancelled",
+ ];
+ let found = false;
+ document.getElementById("status-status").collapsed = true;
+ commands.forEach((aElement, aIndex, aArray) => {
+ let node = document.getElementById(aElement);
+ let matches = node.getAttribute("value") == aArg.status;
+ found = found || matches;
+
+ node.setAttribute("checked", matches ? "true" : "false");
+
+ if (aIndex > 0) {
+ statusLabels[aIndex - 1].hidden = !matches;
+ if (matches) {
+ document.getElementById("status-status").collapsed = false;
+ }
+ }
+ });
+ if (!found) {
+ // The current Status value is invalid. Change the status to
+ // "not specified" and update the status again.
+ sendMessage({ command: "editStatus", value: "NONE" });
+ }
+}
+
+/**
+ * Handler for changing the transparency.
+ *
+ * @param {Node} aTarget - Has the new transparency in its "value" attribute
+ */
+function editShowTimeAs(aTarget) {
+ let newValue = aTarget.getAttribute("value");
+ editConfigState({ showTimeAs: newValue });
+}
+
+/**
+ * Update the dialog controls related to transparency.
+ *
+ * @param {object} aArg - Contains the new transparency value
+ * @param {string} aArg.showTimeAs - The new transparency value
+ */
+function updateShowTimeAs(aArg) {
+ let showAsBusy = document.getElementById("cmd_showtimeas_busy");
+ let showAsFree = document.getElementById("cmd_showtimeas_free");
+
+ showAsBusy.setAttribute("checked", aArg.showTimeAs == "OPAQUE" ? "true" : "false");
+ showAsFree.setAttribute("checked", aArg.showTimeAs == "TRANSPARENT" ? "true" : "false");
+
+ document.getElementById("status-freebusy").collapsed =
+ aArg.showTimeAs != "OPAQUE" && aArg.showTimeAs != "TRANSPARENT";
+ document.getElementById("status-freebusy-free-label").hidden = aArg.showTimeAs == "OPAQUE";
+ document.getElementById("status-freebusy-busy-label").hidden = aArg.showTimeAs == "TRANSPARENT";
+}
+
+/**
+ * Change the task percent complete (and thus task status).
+ *
+ * @param {short} aPercentComplete - The new percent complete value
+ */
+function editToDoStatus(aPercentComplete) {
+ sendMessage({ command: "editToDoStatus", value: aPercentComplete });
+}
+
+/**
+ * Check or uncheck the "Mark updated" menu item in "Events and Tasks"
+ * menu based on the percent complete value.
+ *
+ * @param {object} aArg - Container
+ * @param {short} aArg.percentComplete - The percent complete value
+ */
+function updateMarkCompletedMenuItem(aArg) {
+ // Command only for tab case, function only to be executed in dialog windows.
+ if (gTabmail) {
+ let completedCommand = document.getElementById("calendar_toggle_completed_command");
+ let isCompleted = aArg.percentComplete == 100;
+ completedCommand.setAttribute("checked", isCompleted);
+ }
+}
+
+/**
+ * Postpone the task's start date/time and due date/time. ISO 8601
+ * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We
+ * use this format intentionally instead of a calIDuration object because
+ * those objects cannot be serialized for message passing with iframes.)
+ *
+ * @param {string} aDuration - A duration in ISO 8601 format
+ */
+function postponeTask(aDuration) {
+ sendMessage({ command: "postponeTask", value: aDuration });
+}
+
+/**
+ * Get the timezone button state.
+ *
+ * @returns {boolean} True is active/checked and false is inactive/unchecked
+ */
+function getTimezoneCommandState() {
+ let cmdTimezone = document.getElementById("cmd_timezone");
+ return cmdTimezone.getAttribute("checked") == "true";
+}
+
+/**
+ * Set the timezone button state. Used to keep the toolbar button in
+ * sync when switching tabs.
+ *
+ * @param {object} aArg - Contains timezones property
+ * @param {boolean} aArg.timezonesEnabled - Are timezones enabled?
+ */
+function updateTimezoneCommand(aArg) {
+ let cmdTimezone = document.getElementById("cmd_timezone");
+ cmdTimezone.setAttribute("checked", aArg.timezonesEnabled);
+ gConfig.timezonesEnabled = aArg.timezonesEnabled;
+}
+
+/**
+ * Toggles the command that allows enabling the timezone links in the dialog.
+ */
+function toggleTimezoneLinks() {
+ let cmdTimezone = document.getElementById("cmd_timezone");
+ let currentState = getTimezoneCommandState();
+ cmdTimezone.setAttribute("checked", currentState ? "false" : "true");
+ gConfig.timezonesEnabled = !currentState;
+ sendMessage({ command: "toggleTimezoneLinks", checked: !currentState });
+}
+
+/**
+ * Prompts the user to attach an url to this item.
+ */
+function attachURL() {
+ sendMessage({ command: "attachURL" });
+}
+
+/**
+ * Updates dialog controls related to item attachments.
+ *
+ * @param {object} aArg Container
+ * @param {boolean} aArg.attachUrlCommand - Enable the attach url command?
+ */
+function updateAttachment(aArg) {
+ document.getElementById("cmd_attach_url").setAttribute("disabled", !aArg.attachUrlCommand);
+}
+
+/**
+ * Updates attendees command enabled/disabled state.
+ *
+ * @param {object} aArg Container
+ * @param {boolean} aArg.attendeesCommand - Enable the attendees command?
+ */
+function updateAttendeesCommand(aArg) {
+ document.getElementById("cmd_attendees").setAttribute("disabled", !aArg.attendeesCommand);
+}
+
+/**
+ * Enables/disables the commands cmd_accept and cmd_save related to the
+ * save operation.
+ *
+ * @param {boolean} aEnable - Enable the commands?
+ */
+function enableAcceptCommand(aEnable) {
+ document.getElementById("cmd_accept").setAttribute("disabled", !aEnable);
+ document.getElementById("cmd_save").setAttribute("disabled", !aEnable);
+}
+
+/**
+ * Enable and un-collapse all elements that are disable-on-readonly and
+ * collapse-on-readonly.
+ */
+function removeDisableAndCollapseOnReadonly() {
+ let enableElements = document.getElementsByAttribute("disable-on-readonly", "true");
+ for (let element of enableElements) {
+ element.removeAttribute("disabled");
+ }
+ let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true");
+ for (let element of collapseElements) {
+ element.removeAttribute("collapsed");
+ }
+}
+
+/**
+ * Handler to toggle toolbar visibility.
+ *
+ * @param {string} aToolbarId - The id of the toolbar node to toggle
+ * @param {string} aMenuitemId - The corresponding menuitem in the view menu
+ */
+function onCommandViewToolbar(aToolbarId, aMenuItemId) {
+ let toolbar = document.getElementById(aToolbarId);
+ let menuItem = document.getElementById(aMenuItemId);
+
+ if (!toolbar || !menuItem) {
+ return;
+ }
+
+ let toolbarCollapsed = toolbar.collapsed;
+
+ // toggle the checkbox
+ menuItem.setAttribute("checked", toolbarCollapsed);
+
+ // toggle visibility of the toolbar
+ toolbar.collapsed = !toolbarCollapsed;
+
+ Services.xulStore.persist(toolbar, "collapsed");
+ Services.xulStore.persist(menuItem, "checked");
+}
+
+/**
+ * Called after the customize toolbar dialog has been closed by the
+ * user. We need to restore the state of all buttons and commands of
+ * all customizable toolbars.
+ *
+ * @param {boolean} aToolboxChanged - When true the toolbox has changed
+ */
+function dialogToolboxCustomizeDone(aToolboxChanged) {
+ // Re-enable menu items (disabled during toolbar customization).
+ let menubarId = gTabmail ? "mail-menubar" : "event-menubar";
+ let menubar = document.getElementById(menubarId);
+ for (let menuitem of menubar.children) {
+ menuitem.removeAttribute("disabled");
+ }
+
+ // make sure our toolbar buttons have the correct enabled state restored to them...
+ document.commandDispatcher.updateCommands("itemCommands");
+
+ // Enable the toolbar context menu items
+ document.getElementById("cmd_customize").removeAttribute("disabled");
+
+ // Update privacy items to make sure the toolbarbutton's menupopup is set
+ // correctly
+ updatePrivacy(gConfig);
+}
+
+/**
+ * Handler to start the customize toolbar dialog for the event dialog's toolbar.
+ */
+function onCommandCustomize() {
+ // install the callback that handles what needs to be
+ // done after a toolbar has been customized.
+ let toolboxId = "event-toolbox";
+
+ let toolbox = document.getElementById(toolboxId);
+ toolbox.customizeDone = dialogToolboxCustomizeDone;
+
+ // Disable menu items during toolbar customization.
+ let menubarId = gTabmail ? "mail-menubar" : "event-menubar";
+ let menubar = document.getElementById(menubarId);
+ for (let menuitem of menubar.children) {
+ menuitem.setAttribute("disabled", true);
+ }
+
+ // Disable the toolbar context menu items
+ document.getElementById("cmd_customize").setAttribute("disabled", "true");
+
+ let wintype = document.documentElement.getAttribute("windowtype");
+ wintype = wintype.replace(/:/g, "");
+
+ window.openDialog(
+ "chrome://messenger/content/customizeToolbar.xhtml",
+ "CustomizeToolbar" + wintype,
+ "chrome,all,dependent",
+ document.getElementById(toolboxId), // toolbox dom node
+ false, // is mode toolbar yes/no?
+ null, // callback function
+ "dialog"
+ ); // name of this mode
+}
+
+/**
+ * Add menu items to the UI for attaching files using a cloud provider.
+ *
+ * @param {object[]} aItemObjects - Array of objects that each contain
+ * data to create a menuitem
+ */
+function loadCloudProviders(aItemObjects) {
+ /**
+ * Deletes any existing menu items in aParentNode that have a
+ * cloudProviderAccountKey attribute.
+ *
+ * @param {Node} aParentNode - A menupopup containing menu items
+ */
+ function deleteAlreadyExisting(aParentNode) {
+ for (let node of aParentNode.children) {
+ if (node.cloudProviderAccountKey) {
+ aParentNode.removeChild(node);
+ }
+ }
+ }
+
+ // Delete any existing menu items with a cloudProviderAccountKey,
+ // needed for the tab case to prevent duplicate menu items, and
+ // helps keep the menu items current.
+ let toolbarPopup = document.getElementById("button-attach-menupopup");
+ if (toolbarPopup) {
+ deleteAlreadyExisting(toolbarPopup);
+ }
+ let optionsPopup = document.getElementById("options-attachments-menupopup");
+ if (optionsPopup) {
+ deleteAlreadyExisting(optionsPopup);
+ }
+
+ for (let itemObject of aItemObjects) {
+ // Create a menu item.
+ let item = document.createXULElement("menuitem");
+ item.setAttribute("label", itemObject.label);
+ item.setAttribute("observes", "cmd_attach_cloud");
+ item.setAttribute(
+ "oncommand",
+ "attachFileByAccountKey(event.target.cloudProviderAccountKey); event.stopPropagation();"
+ );
+
+ if (itemObject.class) {
+ item.setAttribute("class", itemObject.class);
+ item.setAttribute("image", itemObject.image);
+ }
+
+ // Add the menu item to the UI.
+ if (toolbarPopup) {
+ toolbarPopup.appendChild(item.cloneNode(true)).cloudProviderAccountKey =
+ itemObject.cloudProviderAccountKey;
+ }
+ if (optionsPopup) {
+ // This one doesn't need to clone, just use the item itself.
+ optionsPopup.appendChild(item).cloudProviderAccountKey = itemObject.cloudProviderAccountKey;
+ }
+ }
+}
+
+/**
+ * Send a message to attach a file using a given cloud provider,
+ * to be identified by the cloud provider's accountKey.
+ *
+ * @param {string} aAccountKey - The accountKey for a cloud provider
+ */
+function attachFileByAccountKey(aAccountKey) {
+ sendMessage({ command: "attachFileByAccountKey", accountKey: aAccountKey });
+}
+
+/**
+ * Updates the save controls depending on whether the event has attendees
+ *
+ * @param {boolean} aSendNotSave
+ */
+function updateSaveControls(aSendNotSave) {
+ if (window.calItemSaveControls && window.calItemSaveControls.state == aSendNotSave) {
+ return;
+ }
+
+ let saveBtn = document.getElementById("button-save");
+ let saveandcloseBtn = document.getElementById("button-saveandclose");
+ let saveMenu =
+ document.getElementById("item-save-menuitem") ||
+ document.getElementById("calendar-save-menuitem");
+ let saveandcloseMenu =
+ document.getElementById("item-saveandclose-menuitem") ||
+ document.getElementById("calendar-save-and-close-menuitem");
+
+ // we store the initial label and tooltip values to be able to reset later
+ if (!window.calItemSaveControls) {
+ window.calItemSaveControls = {
+ state: false,
+ saveMenu: { label: saveMenu.label },
+ saveandcloseMenu: { label: saveandcloseMenu.label },
+ saveBtn: null,
+ saveandcloseBtn: null,
+ };
+ // we need to check for each button whether it exists since toolbarbuttons
+ // can be removed by customizing
+ if (saveBtn) {
+ window.window.calItemSaveControls.saveBtn = {
+ label: saveBtn.label,
+ tooltiptext: saveBtn.tooltip,
+ };
+ }
+ if (saveandcloseBtn) {
+ window.window.calItemSaveControls.saveandcloseBtn = {
+ label: saveandcloseBtn.label,
+ tooltiptext: saveandcloseBtn.tooltip,
+ };
+ }
+ }
+
+ // we update labels and tooltips but leave accesskeys as they are
+ window.calItemSaveControls.state = aSendNotSave;
+ if (aSendNotSave) {
+ if (saveBtn) {
+ saveBtn.label = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonLabel");
+ saveBtn.tooltiptext = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonTooltip");
+ saveBtn.setAttribute("mode", "send");
+ }
+ if (saveandcloseBtn) {
+ saveandcloseBtn.label = cal.l10n.getString(
+ "calendar-event-dialog",
+ "sendandcloseButtonLabel"
+ );
+ saveandcloseBtn.tooltiptext = cal.l10n.getString(
+ "calendar-event-dialog",
+ "sendandcloseButtonTooltip"
+ );
+ saveandcloseBtn.setAttribute("mode", "send");
+ }
+ saveMenu.label = cal.l10n.getString("calendar-event-dialog", "saveandsendMenuLabel");
+ saveandcloseMenu.label = cal.l10n.getString("calendar-event-dialog", "sendandcloseMenuLabel");
+ } else {
+ if (saveBtn) {
+ saveBtn.label = window.calItemSaveControls.saveBtn.label;
+ saveBtn.tooltiptext = window.calItemSaveControls.saveBtn.tooltip;
+ saveBtn.removeAttribute("mode");
+ }
+ if (saveandcloseBtn) {
+ saveandcloseBtn.label = window.calItemSaveControls.saveandcloseBtn.label;
+ saveandcloseBtn.tooltiptext = window.calItemSaveControls.saveandcloseBtn.tooltip;
+ saveandcloseBtn.removeAttribute("mode");
+ }
+ saveMenu.label = window.calItemSaveControls.saveMenu.label;
+ saveandcloseMenu.label = window.calItemSaveControls.saveandcloseMenu.label;
+ }
+}
diff --git a/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml
new file mode 100644
index 0000000000..5d0c744086
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml
@@ -0,0 +1,164 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ <toolbarbutton id="button-save"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ label="&event.toolbar.save.label2;"
+ tooltiptext="&event.toolbar.save.tooltip2;"
+ command="cmd_save"/>
+ <toolbarbutton id="button-saveandclose"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ label="&event.toolbar.saveandclose.label;"
+ tooltiptext="&event.toolbar.saveandclose.tooltip;"
+ command="cmd_accept"/>
+ <toolbarbutton id="button-attendees"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1 event-only"
+ disable-on-readonly="true"
+ label="&event.toolbar.attendees.label;"
+ tooltiptext="&event.toolbar.attendees.tooltip;"
+ command="cmd_attendees"/>
+ <toolbarbutton id="button-privacy"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ type="menu"
+ wantdropmarker="true"
+ disable-on-readonly="true"
+ label="&event.toolbar.privacy.label;"
+ tooltiptext="&event.toolbar.privacy.tooltip;">
+ <menupopup id="event-privacy-menupopup">
+ <menuitem id="event-privacy-public-menuitem"
+ name="event-privacy-group"
+ label="&event.menu.options.privacy.public.label;"
+ type="radio"
+ privacy="PUBLIC"
+ oncommand="editPrivacy(this, event)"/>
+ <menuitem id="event-privacy-confidential-menuitem"
+ name="event-privacy-group"
+ label="&event.menu.options.privacy.confidential.label;"
+ type="radio"
+ privacy="CONFIDENTIAL"
+ oncommand="editPrivacy(this, event)"/>
+ <menuitem id="event-privacy-private-menuitem"
+ name="event-privacy-group"
+ label="&event.menu.options.privacy.private.label;"
+ type="radio"
+ privacy="PRIVATE"
+ oncommand="editPrivacy(this, event)"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-url"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ type="menu"
+ wantdropmarker="true"
+ label="&event.attachments.menubutton.label;"
+ tooltiptext="&event.toolbar.attachments.tooltip;"
+ disable-on-readonly="true">
+ <menupopup id="button-attach-menupopup">
+ <menuitem id="button-attach-url"
+ label="&event.attachments.url.label;"
+ command="cmd_attach_url"/>
+ <!-- Additional items are added here in loadCloudProviders(). -->
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-delete"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ label="&event.toolbar.delete.label;"
+ tooltiptext="&event.toolbar.delete.tooltip;"
+ command="cmd_item_delete"
+ disable-on-readonly="true"/>
+ <toolbarbutton id="button-priority"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ type="menu"
+ wantdropmarker="true"
+ disable-on-readonly="true"
+ label="&event.menu.options.priority2.label;"
+ tooltiptext="&event.toolbar.priority.tooltip;">
+ <menupopup id="event-priority-menupopup">
+ <menuitem id="event-priority-none-menuitem"
+ name="event-priority-group"
+ label="&event.menu.options.priority.notspecified.label;"
+ type="radio"
+ command="cmd_priority_none"/>
+ <menuitem id="event-priority-low-menuitem"
+ name="event-priority-group"
+ label="&event.menu.options.priority.low.label;"
+ type="radio"
+ command="cmd_priority_low"/>
+ <menuitem id="event-priority-normal-menuitem"
+ name="event-priority-group"
+ label="&event.menu.options.priority.normal.label;"
+ type="radio"
+ command="cmd_priority_normal"/>
+ <menuitem id="event-priority-high-menuitem"
+ name="event-priority-group"
+ label="&event.menu.options.priority.high.label;"
+ type="radio"
+ command="cmd_priority_high"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-status"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1 event-only"
+ type="menu"
+ wantdropmarker="true"
+ disable-on-readonly="true"
+ label="&newevent.status.label;"
+ tooltiptext="&event.toolbar.status.tooltip;">
+ <menupopup id="event-status-menupopup">
+ <menuitem id="event-status-none-menuitem"
+ name="event-status-group"
+ label="&newevent.eventStatus.none.label;"
+ type="radio"
+ command="cmd_status_none"/>
+ <menuitem id="event-status-tentative-menuitem"
+ name="event-status-group"
+ label="&newevent.status.tentative.label;"
+ type="radio"
+ command="cmd_status_tentative"/>
+ <menuitem id="event-status-confirmed-menuitem"
+ name="event-status-group"
+ label="&newevent.status.confirmed.label;"
+ type="radio"
+ command="cmd_status_confirmed"/>
+ <menuitem id="event-status-cancelled-menuitem"
+ name="event-status-group"
+ label="&newevent.eventStatus.cancelled.label;"
+ type="radio"
+ command="cmd_status_cancelled"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-freebusy"
+ mode="dialog"
+ class="cal-event-toolbarbutton toolbarbutton-1 event-only"
+ type="menu"
+ wantdropmarker="true"
+ disable-on-readonly="true"
+ label="&event.menu.options.show.time.label;"
+ tooltiptext="&event.toolbar.freebusy.tooltip;">
+ <menupopup id="event-freebusy-menupopup">
+ <menuitem id="event-freebusy-busy-menuitem"
+ name="event-freebusy-group"
+ label="&event.menu.options.show.time.busy.label;"
+ type="radio"
+ command="cmd_showtimeas_busy"/>
+ <menuitem id="event-freebusy-free-menuitem"
+ name="event-freebusy-group"
+ label="&event.menu.options.show.time.free.label;"
+ type="radio"
+ command="cmd_showtimeas_free"/>
+ </menupopup>
+ </toolbarbutton>
+ <toolbarbutton id="button-timezones"
+ mode="dialog"
+ type="checkbox"
+ class="cal-event-toolbarbutton toolbarbutton-1"
+ label="&event.menu.options.timezone2.label;"
+ tooltiptext="&event.menu.options.timezone2.label;"
+ command="cmd_timezone"/>
diff --git a/comm/calendar/base/content/item-editing/calendar-task-editing.js b/comm/calendar/base/content/item-editing/calendar-task-editing.js
new file mode 100644
index 0000000000..33f67f81d7
--- /dev/null
+++ b/comm/calendar/base/content/item-editing/calendar-task-editing.js
@@ -0,0 +1,181 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../calendar-management.js */
+/* import-globals-from ../calendar-ui-utils.js */
+/* import-globals-from calendar-item-editing.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+/**
+ * Used by the "quick add" feature for tasks, for example in the task view or
+ * the uniinder-todo.
+ *
+ * NOTE: many of the following methods are called without taskEdit being the
+ * |this| object.
+ */
+
+var taskEdit = {
+ /**
+ * Helper function to set readonly and aria-disabled states and the value
+ * for a given target.
+ *
+ * @param aTarget The ID or XUL node to set the value
+ * @param aDisable A boolean if the target should be disabled.
+ * @param aValue The value that should be set on the target.
+ */
+ setupTaskField(aTarget, aDisable, aValue) {
+ aTarget.value = aValue;
+ aTarget.readOnly = aDisable;
+ aTarget.ariaDisabled = aDisable;
+ },
+
+ /**
+ * Handler function to call when the quick-add input gains focus.
+ *
+ * @param aEvent The DOM focus event
+ */
+ onFocus(aEvent) {
+ let edit = aEvent.target;
+ let calendar = getSelectedCalendar();
+ edit.showsInstructions = true;
+
+ if (calendar.getProperty("capabilities.tasks.supported") === false) {
+ taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability"));
+ } else if (cal.acl.isCalendarWritable(calendar)) {
+ edit.showsInstructions = false;
+ taskEdit.setupTaskField(edit, false, edit.savedValue || "");
+ } else {
+ taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly"));
+ }
+ },
+
+ /**
+ * Handler function to call when the quick-add input loses focus.
+ *
+ * @param aEvent The DOM blur event
+ */
+ onBlur(aEvent) {
+ let edit = aEvent.target;
+ let calendar = getSelectedCalendar();
+ if (!calendar) {
+ // this must be a first run, we don't have a calendar yet
+ return;
+ }
+
+ if (calendar.getProperty("capabilities.tasks.supported") === false) {
+ taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability"));
+ } else if (cal.acl.isCalendarWritable(calendar)) {
+ if (!edit.showsInstructions) {
+ edit.savedValue = edit.value || "";
+ }
+ taskEdit.setupTaskField(edit, false, cal.l10n.getCalString("taskEditInstructions"));
+ } else {
+ taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly"));
+ }
+
+ edit.showsInstructions = true;
+ },
+
+ /**
+ * Handler function to call on keypress for the quick-add input.
+ *
+ * @param aEvent The DOM keypress event
+ */
+ onKeyPress(aEvent) {
+ if (aEvent.key == "Enter") {
+ let edit = aEvent.target;
+ if (edit.value && edit.value.length > 0) {
+ let item = new CalTodo();
+ setDefaultItemValues(item);
+ item.title = edit.value;
+
+ edit.value = "";
+ doTransaction("add", item, item.calendar, null, null);
+ }
+ }
+ },
+
+ /**
+ * Helper function to call onBlur for all fields with class name
+ * "task-edit-field".
+ */
+ callOnBlurForAllTaskFields() {
+ let taskEditFields = document.getElementsByClassName("task-edit-field");
+ for (let i = 0; i < taskEditFields.length; i++) {
+ taskEdit.onBlur({ target: taskEditFields[i] });
+ }
+ },
+
+ /**
+ * Load function to set up all quick-add inputs. The input must
+ * have the class "task-edit-field".
+ */
+ onLoad(aEvent) {
+ cal.view.getCompositeCalendar(window).addObserver(taskEdit.compositeObserver);
+ taskEdit.callOnBlurForAllTaskFields();
+ },
+
+ /**
+ * Window load function to clean up all quick-add fields.
+ */
+ onUnload() {
+ cal.view.getCompositeCalendar(window).removeObserver(taskEdit.compositeObserver);
+ },
+
+ /**
+ * Observer to watch for changes to the selected calendar.
+ *
+ * @see calIObserver
+ * @see calICompositeObserver
+ */
+ compositeObserver: {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver", "calICompositeObserver"]),
+
+ // calIObserver:
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad(aCalendar) {},
+ onAddItem(aItem) {},
+ onModifyItem(aNewItem, aOldItem) {},
+ onDeleteItem(aDeletedItem) {},
+ onError(aCalendar, aErrNo, aMessage) {},
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ if (aCalendar.id != getSelectedCalendar().id) {
+ // Optimization: if the given calendar isn't the selected calendar,
+ // then we don't need to change any readonly/disabled states.
+ return;
+ }
+
+ switch (aName) {
+ case "readOnly":
+ case "disabled": {
+ taskEdit.callOnBlurForAllTaskFields();
+ break;
+ }
+ }
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ // Since the old value is not used directly in onPropertyChanged,
+ // but should not be the same as the value, set it to a different
+ // value.
+ this.onPropertyChanged(aCalendar, aName, null, null);
+ },
+
+ // calICompositeObserver:
+ onCalendarAdded(aCalendar) {},
+ onCalendarRemoved(aCalendar) {},
+ onDefaultCalendarChanged(aNewDefault) {
+ taskEdit.callOnBlurForAllTaskFields();
+ },
+ },
+};
diff --git a/comm/calendar/base/content/preferences/alarms.inc.xhtml b/comm/calendar/base/content/preferences/alarms.inc.xhtml
new file mode 100644
index 0000000000..a2524983d0
--- /dev/null
+++ b/comm/calendar/base/content/preferences/alarms.inc.xhtml
@@ -0,0 +1,188 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <html:legend data-l10n-id="reminder-legend"></html:legend>
+ <vbox id="alarm-sound-box">
+ <hbox align="center">
+ <checkbox id="alarmSoundCheckbox"
+ preference="calendar.alarms.playsound"
+ data-l10n-id="reminder-play-checkbox"/>
+ <spacer flex="1"/>
+ <button is="highlightable-button" id="calendar.prefs.alarm.sound.play"
+ data-l10n-id="reminder-play-alarm-button"
+ oncommand="gAlarmsPane.previewAlarm()"/>
+ </hbox>
+ <radiogroup id="alarmSoundType"
+ class="indent"
+ orient="vertical"
+ preference="calendar.alarms.soundType"
+ aria-labelledby="alarmSoundCheckbox">
+ <hbox>
+ <radio id="alarmSoundSystem"
+ value="0"
+ data-l10n-id="reminder-default-sound-label"/>
+ </hbox>
+ <hbox>
+ <radio id="alarmSoundCustom"
+ value="1"
+ data-l10n-id="reminder-custom-sound-label"/>
+ </hbox>
+ </radiogroup>
+ <hbox align="center" class="input-container">
+ <html:input id="alarmSoundFileField"
+ type="text"
+ class="input-filefield indent"
+ readonly="readonly"
+ preference="calendar.alarms.soundURL"
+ preference-editable="true"
+ aria-labelledby="alarmSoundCustom"/>
+ <button is="highlightable-button" id="calendar.prefs.alarm.sound.browse"
+ data-l10n-id="reminder-browse-sound-label"
+ oncommand="gAlarmsPane.browseAlarm()"/>
+ </hbox>
+ </vbox>
+ <hbox align="center" flex="1">
+ <checkbox id="alarmshow"
+ preference="calendar.alarms.show"
+ data-l10n-id="reminder-dialog-label"/>
+ </hbox>
+ <hbox align="center" flex="1">
+ <checkbox id="missedalarms"
+ preference="calendar.alarms.showmissed"
+ data-l10n-id="missed-reminder-label"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <html:legend data-l10n-id="reminder-default-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="default-snooze-label"
+ control="defaultsnoozelength"/>
+ <html:input id="defaultsnoozelength" type="number" class="size3"
+ min="0"
+ preference="calendar.alarms.defaultsnoozelength"
+ onselect="updateUnitLabelPlural('defaultsnoozelength', 'defaultsnoozelengthunit', 'minutes')"
+ oninput="updateUnitLabelPlural('defaultsnoozelength', 'defaultsnoozelengthunit', 'minutes')"/>
+ <label id="defaultsnoozelengthunit"/>
+ </hbox>
+ <hbox>
+ <html:table id="alarm-defaults-table">
+ <html:tr>
+ <html:td>
+ <label data-l10n-id="event-alarm-label"
+ control="eventdefalarm"/>
+ </html:td>
+ <html:td>
+ <hbox>
+ <menulist id="eventdefalarm"
+ crop="none"
+ preference="calendar.alarms.onforevents">
+ <menupopup id="eventdefalarmpopup">
+ <menuitem id="eventdefalarmon"
+ data-l10n-id="alarm-on-label"
+ value="1"/>
+ <menuitem id="eventdefalarmoff"
+ data-l10n-id="alarm-off-label"
+ value="0"
+ selected="true"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:td>
+ <label data-l10n-id="task-alarm-label"
+ control="tododefalarm"/>
+ </html:td>
+ <html:td>
+ <hbox>
+ <menulist id="tododefalarm"
+ crop="none"
+ preference="calendar.alarms.onfortodos">
+ <menupopup id="tododefalarmpopup">
+ <menuitem id="tododefalarmon"
+ data-l10n-id="alarm-on-label"
+ value="1"/>
+ <menuitem id="tododefalarmoff"
+ data-l10n-id="alarm-off-label"
+ value="0"
+ selected="true"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:td>
+ <label data-l10n-id="event-alarm-time-label"
+ control="eventdefalarmlen"/>
+ </html:td>
+ <html:td>
+ <hbox class="defaultTimeBox"
+ align="center"
+ flex="1">
+ <html:input id="eventdefalarmlen" type="number" class="size3" min="0"
+ preference="calendar.alarms.eventalarmlen"
+ onselect="updateMenuLabelsPlural('eventdefalarmlen', 'eventdefalarmunit')"
+ oninput="updateMenuLabelsPlural('eventdefalarmlen', 'eventdefalarmunit')"/>
+ <hbox>
+ <menulist id="eventdefalarmunit"
+ flex="1"
+ crop="none"
+ preference="calendar.alarms.eventalarmunit">
+ <menupopup id="eventdefalarmunitpopup">
+ <menuitem id="eventdefalarmunitmin"
+ value="minutes"
+ selected="true"/>
+ <menuitem id="eventdefalarmunithour"
+ value="hours"/>
+ <menuitem id="eventdefalarmunitday"
+ value="days"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:td>
+ <label data-l10n-id="task-alarm-time-label"
+ control="tododefalarmlen"/>
+ </html:td>
+ <html:td>
+ <hbox class="defaultTimeBox"
+ align="center"
+ flex="1">
+ <html:input id="tododefalarmlen" type="number" class="size3" min="0"
+ preference="calendar.alarms.todoalarmlen"
+ onselect="updateMenuLabelsPlural('tododefalarmlen', 'tododefalarmunit')"
+ oninput="updateMenuLabelsPlural('tododefalarmlen', 'tododefalarmunit')"/>
+ <hbox>
+ <menulist id="tododefalarmunit"
+ flex="1"
+ crop="none"
+ preference="calendar.alarms.todoalarmunit">
+ <menupopup id="tododefalarmunitpopup">
+ <menuitem id="tododefalarmunitmin"
+ value="minutes"
+ selected="true"/>
+ <menuitem id="tododefalarmunithour"
+ value="hours"/>
+ <menuitem id="tododefalarmunitday"
+ value="days"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+ </html:table>
+ <spacer flex="1"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
diff --git a/comm/calendar/base/content/preferences/alarms.js b/comm/calendar/base/content/preferences/alarms.js
new file mode 100644
index 0000000000..29b5df30ab
--- /dev/null
+++ b/comm/calendar/base/content/preferences/alarms.js
@@ -0,0 +1,165 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gAlarmsPane */
+
+/* import-globals-from ../calendar-ui-utils.js */
+/* globals Preferences */
+
+Preferences.addAll([
+ { id: "calendar.alarms.playsound", type: "bool" },
+ { id: "calendar.alarms.soundURL", type: "string" },
+ { id: "calendar.alarms.soundType", type: "int" },
+ { id: "calendar.alarms.show", type: "bool" },
+ { id: "calendar.alarms.showmissed", type: "bool" },
+ { id: "calendar.alarms.onforevents", type: "int" },
+ { id: "calendar.alarms.onfortodos", type: "int" },
+ { id: "calendar.alarms.eventalarmlen", type: "int" },
+ { id: "calendar.alarms.eventalarmunit", type: "string" },
+ { id: "calendar.alarms.todoalarmlen", type: "int" },
+ { id: "calendar.alarms.todoalarmunit", type: "string" },
+ { id: "calendar.alarms.defaultsnoozelength", type: "int" },
+]);
+
+/**
+ * Global Object to hold methods for the alarms pref pane
+ */
+var gAlarmsPane = {
+ /**
+ * Initialize the alarms pref pane. Sets up dialog controls to match the
+ * values set in prefs.
+ */
+ init() {
+ // Enable/disable the alarm sound URL box and buttons
+ this.alarmsPlaySoundPrefChanged();
+
+ // Set the correct singular/plural for the time units
+ updateMenuLabelsPlural("eventdefalarmlen", "eventdefalarmunit");
+ updateMenuLabelsPlural("tododefalarmlen", "tododefalarmunit");
+ updateUnitLabelPlural("defaultsnoozelength", "defaultsnoozelengthunit", "minutes");
+
+ Preferences.addSyncFromPrefListener(document.getElementById("alarmSoundFileField"), () =>
+ this.readSoundLocation()
+ );
+ },
+
+ /**
+ * Converts the given file url to a nsIFile
+ *
+ * @param aFileURL A string with a file:// url.
+ * @returns The corresponding nsIFile.
+ */
+ convertURLToLocalFile(aFileURL) {
+ // Convert the file url into a nsIFile
+ if (aFileURL) {
+ let fph = Services.io.getProtocolHandler("file").QueryInterface(Ci.nsIFileProtocolHandler);
+ return fph.getFileFromURLSpec(aFileURL);
+ }
+ return null;
+ },
+
+ /**
+ * Handler function to be called when the calendar.alarms.soundURL pref has
+ * changed. Updates the label in the dialog.
+ */
+ readSoundLocation() {
+ let soundUrl = document.getElementById("alarmSoundFileField");
+ soundUrl.value = Preferences.get("calendar.alarms.soundURL").value;
+ if (soundUrl.value.startsWith("file://")) {
+ soundUrl.label = gAlarmsPane.convertURLToLocalFile(soundUrl.value).leafName;
+ } else {
+ soundUrl.label = soundUrl.value;
+ }
+ soundUrl.style.backgroundImage = "url(moz-icon://" + soundUrl.label + "?size=16)";
+ return undefined;
+ },
+
+ /**
+ * Causes the default sound to be selected in the dialog controls
+ */
+ useDefaultSound() {
+ let defaultSoundUrl = "chrome://calendar/content/sound.wav";
+ Preferences.get("calendar.alarms.soundURL").value = defaultSoundUrl;
+ document.getElementById("alarmSoundCheckbox").checked = true;
+ this.readSoundLocation();
+ },
+
+ /**
+ * Opens a filepicker to open a local sound for the alarm.
+ */
+ browseAlarm() {
+ let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+ // If we already have a sound file, then use the path for that sound file
+ // as the initial path in the dialog.
+ let currentValue = Preferences.get("calendar.alarms.soundURL").value;
+ if (currentValue && currentValue.startsWith("file://")) {
+ let localFile = Services.io.newURI(currentValue).QueryInterface(Ci.nsIFileURL).file;
+ picker.displayDirectory = localFile.parent;
+ }
+
+ let title = document.getElementById("bundlePreferences").getString("soundFilePickerTitle");
+
+ picker.init(window, title, Ci.nsIFilePicker.modeOpen);
+ picker.appendFilters(Ci.nsIFilePicker.filterAudio);
+ picker.appendFilters(Ci.nsIFilePicker.filterAll);
+
+ picker.open(rv => {
+ if (rv != Ci.nsIFilePicker.returnOK || !picker.file) {
+ return;
+ }
+ Preferences.get("calendar.alarms.soundURL").value = picker.fileURL.spec;
+ document.getElementById("alarmSoundCheckbox").checked = true;
+ this.readSoundLocation();
+ });
+ },
+
+ /**
+ * Plays the alarm sound currently selected.
+ */
+ previewAlarm() {
+ let soundUrl;
+ if (Preferences.get("calendar.alarms.soundType").value == 0) {
+ soundUrl = "chrome://calendar/content/sound.wav";
+ } else {
+ soundUrl = Preferences.get("calendar.alarms.soundURL").value;
+ }
+ let soundIfc = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+ let url;
+ try {
+ soundIfc.init();
+ if (soundUrl && soundUrl.length && soundUrl.length > 0) {
+ url = Services.io.newURI(soundUrl);
+ soundIfc.play(url);
+ } else {
+ soundIfc.beep();
+ }
+ } catch (ex) {
+ dump("alarms.js previewAlarm Exception caught! " + ex + "\n");
+ }
+ },
+
+ /**
+ * Handler function to call when the calendar.alarms.playsound preference
+ * has been changed. Updates the disabled state of fields that depend on
+ * playing a sound.
+ */
+ alarmsPlaySoundPrefChanged() {
+ let alarmsPlaySoundPref = Preferences.get("calendar.alarms.playsound");
+ let alarmsSoundType = Preferences.get("calendar.alarms.soundType");
+
+ for (let item of ["alarmSoundType", "calendar.prefs.alarm.sound.play"]) {
+ document.getElementById(item).disabled = !alarmsPlaySoundPref.value;
+ }
+
+ for (let item of ["alarmSoundFileField", "calendar.prefs.alarm.sound.browse"]) {
+ document.getElementById(item).disabled =
+ alarmsSoundType.value != 1 || !alarmsPlaySoundPref.value;
+ }
+ },
+};
+
+Preferences.get("calendar.alarms.playsound").on("change", gAlarmsPane.alarmsPlaySoundPrefChanged);
+Preferences.get("calendar.alarms.soundType").on("change", gAlarmsPane.alarmsPlaySoundPrefChanged);
+Preferences.get("calendar.alarms.soundURL").on("change", gAlarmsPane.readSoundLocation);
diff --git a/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml b/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml
new file mode 100644
index 0000000000..d07f2b812b
--- /dev/null
+++ b/comm/calendar/base/content/preferences/calendar-preferences.inc.xhtml
@@ -0,0 +1,55 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This file requires the following localization files:
+# chrome://lightning/locale/lightning.dtd
+# chrome://calendar/locale/global.dtd
+# chrome://calendar/locale/calendar-event-dialog.dtd
+ <linkset>
+ <html:link rel="localization" href="calendar/preferences.ftl"/>
+ <html:link rel="localization" href="calendar/category-dialog.ftl"/>
+ </linkset>
+ <html:template id="paneCalendar"
+ flex="1"
+ insertbefore="paneChat">
+
+ <hbox id="calendarPaneCategory"
+ class="subcategory"
+ data-category="paneCalendar">
+ <html:h1 data-l10n-id="calendar-title"></html:h1>
+ </hbox>
+
+#include views.inc.xhtml
+#include general.inc.xhtml
+
+ <hbox id="calendarReminderCategory"
+ class="subcategory"
+ data-category="paneCalendar">
+ <html:h1 data-l10n-id="calendar-title-reminder"></html:h1>
+ </hbox>
+
+#include alarms.inc.xhtml
+
+ <hbox id="calendarNotificationCategory"
+ class="subcategory"
+ data-category="paneCalendar">
+ <html:h1 data-l10n-id="calendar-title-notification"></html:h1>
+ </hbox>
+
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <calendar-notifications-setting id="calendar-notifications-setting"/>
+ <label data-l10n-id="calendar-notifications-customize-label"
+ class="indent tip-caption"></label>
+ </html:fieldset>
+ </html:div>
+
+ <hbox id="calendarCategoriesCategory"
+ class="subcategory"
+ data-category="paneCalendar">
+ <html:h1 data-l10n-id="calendar-title-category"></html:h1>
+ </hbox>
+
+#include categories.inc.xhtml
+ </html:template>
diff --git a/comm/calendar/base/content/preferences/calendar-preferences.js b/comm/calendar/base/content/preferences/calendar-preferences.js
new file mode 100644
index 0000000000..4b42fa2300
--- /dev/null
+++ b/comm/calendar/base/content/preferences/calendar-preferences.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gCalendarPane */
+
+/* import-globals-from alarms.js */
+/* import-globals-from categories.js */
+/* import-globals-from general.js */
+/* import-globals-from notifications.js */
+/* import-globals-from views.js */
+/* globals Preferences */
+
+Preferences.add({ id: "calendar.preferences.lightning.selectedTabIndex", type: "int" });
+
+var gCalendarPane = {
+ init() {
+ let elements = document.querySelectorAll("#paneCalendar preference");
+ for (let element of elements) {
+ element.updateElements();
+ }
+ gCalendarGeneralPane.init();
+ gAlarmsPane.init();
+ gNotificationsPane.init();
+ gCategoriesPane.init();
+ gViewsPane.init();
+ },
+};
diff --git a/comm/calendar/base/content/preferences/categories.inc.xhtml b/comm/calendar/base/content/preferences/categories.inc.xhtml
new file mode 100644
index 0000000000..d6f37be9df
--- /dev/null
+++ b/comm/calendar/base/content/preferences/categories.inc.xhtml
@@ -0,0 +1,32 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <separator class="thin"/>
+ <hbox>
+ <richlistbox id="categorieslist"
+ flex="1"
+ seltype="multiple"
+ onselect="gCategoriesPane.updateButtons()"
+ ondblclick="gCategoriesPane.listOnDblClick(event)"/>
+ <vbox id="categoriesButtons">
+ <hbox>
+ <button is="highlightable-button" id="newCButton"
+ data-l10n-id="new-tag-button"
+ oncommand="gCategoriesPane.addCategory()"
+ search-l10n-ids="category-name-label,category-color-label.label"/>
+ </hbox>
+ <hbox>
+ <button is="highlightable-button" id="editCButton"
+ data-l10n-id="edit-tag-button"
+ oncommand="gCategoriesPane.editCategory()"
+ search-l10n-ids="category-name-label,category-color-label.label"/>
+ </hbox>
+ <button is="highlightable-button" id="deleteCButton"
+ data-l10n-id="delete-tag-button"
+ oncommand="gCategoriesPane.deleteCategory()"/>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
diff --git a/comm/calendar/base/content/preferences/categories.js b/comm/calendar/base/content/preferences/categories.js
new file mode 100644
index 0000000000..ba7453b447
--- /dev/null
+++ b/comm/calendar/base/content/preferences/categories.js
@@ -0,0 +1,297 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gCategoriesPane */
+
+/* globals gSubDialog, Preferences */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+
+Preferences.add({ id: "calendar.categories.names", type: "string" });
+
+var gCategoryList;
+var categoryPrefBranch = Services.prefs.getBranch("calendar.category.color.");
+
+/**
+ * Global Object to hold methods for the categories pref pane
+ */
+var gCategoriesPane = {
+ mCategoryDialog: null,
+ mWinProp: null,
+
+ /**
+ * Initialize the categories pref pane. Sets up dialog controls to show the
+ * categories saved in preferences.
+ */
+ init() {
+ // On non-instant-apply platforms, once this pane has been loaded,
+ // attach our "revert all changes" function to the parent prefwindow's
+ // "ondialogcancel" event.
+ let parentPrefWindow = document.documentElement;
+ if (!parentPrefWindow.instantApply) {
+ let existingOnDialogCancel = parentPrefWindow.getAttribute("ondialogcancel");
+ parentPrefWindow.setAttribute(
+ "ondialogcancel",
+ "gCategoriesPane.panelOnCancel(); " + existingOnDialogCancel
+ );
+ }
+
+ // A list of preferences to be reverted when the dialog is cancelled.
+ // It needs to be a property of the parent to be visible onCancel
+ if (!("backupPrefList" in parent)) {
+ parent.backupPrefList = [];
+ }
+
+ gCategoryList = cal.category.fromPrefs();
+
+ this.updateCategoryList();
+
+ this.mCategoryDialog = "chrome://calendar/content/preferences/editCategory.xhtml";
+
+ // Workaround for Bug 1151440 - the HTML color picker won't work
+ // in linux when opened from modal dialog
+ this.mWinProp = "centerscreen, chrome, resizable=no";
+ if (AppConstants.platform != "linux") {
+ this.mWinProp += ", modal";
+ }
+ },
+
+ /**
+ * Updates the listbox containing the categories from the categories saved
+ * in preferences.
+ */
+
+ updatePrefs() {
+ cal.l10n.sortArrayByLocaleCollator(gCategoryList);
+ Preferences.get("calendar.categories.names").value = cal.category.arrayToString(gCategoryList);
+ },
+
+ updateCategoryList() {
+ this.updatePrefs();
+ let listbox = document.getElementById("categorieslist");
+
+ listbox.clearSelection();
+ this.updateButtons();
+
+ while (listbox.lastElementChild) {
+ listbox.lastChild.remove();
+ }
+
+ for (let i = 0; i < gCategoryList.length; i++) {
+ let newListItem = document.createXULElement("richlistitem");
+ let categoryName = document.createXULElement("label");
+ categoryName.setAttribute("id", gCategoryList[i]);
+ categoryName.setAttribute("flex", "1");
+ categoryName.setAttribute("value", gCategoryList[i]);
+ let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[i]);
+
+ let categoryColor = document.createXULElement("box");
+ categoryColor.style.width = "150px";
+ let colorCode = categoryPrefBranch.getCharPref(categoryNameFix, "");
+ if (colorCode) {
+ categoryColor.style.backgroundColor = colorCode;
+ }
+
+ newListItem.appendChild(categoryName);
+ newListItem.appendChild(categoryColor);
+ listbox.appendChild(newListItem);
+ }
+ },
+
+ /**
+ * Adds a category, opening the edit category dialog to prompt the user to
+ * set up the category.
+ */
+ async addCategory() {
+ let listbox = document.getElementById("categorieslist");
+ listbox.clearSelection();
+ this.updateButtons();
+ let params = {
+ title: await document.l10n.formatValue("category-new-label"),
+ category: "",
+ color: null,
+ };
+ gSubDialog.open(this.mCategoryDialog, { features: "resizable=no" }, params);
+ },
+
+ /**
+ * Edits the currently selected category using the edit category dialog.
+ */
+ async editCategory() {
+ let list = document.getElementById("categorieslist");
+ let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[list.selectedIndex]);
+ let currentColor = categoryPrefBranch.getCharPref(categoryNameFix, "");
+
+ let params = {
+ title: await document.l10n.formatValue("category-edit-label"),
+ category: gCategoryList[list.selectedIndex],
+ color: currentColor,
+ };
+ if (list.selectedItem) {
+ gSubDialog.open(this.mCategoryDialog, { features: "resizable=no" }, params);
+ }
+ },
+
+ /**
+ * Removes the selected category.
+ */
+ deleteCategory() {
+ let list = document.getElementById("categorieslist");
+ if (list.selectedCount < 1) {
+ return;
+ }
+
+ let categoryNameFix = cal.view.formatStringForCSSRule(gCategoryList[list.selectedIndex]);
+ this.backupData(categoryNameFix);
+ try {
+ categoryPrefBranch.clearUserPref(categoryNameFix);
+ } catch (ex) {
+ // If the pref doesn't exist, don't bail out here.
+ }
+
+ // Remove category entry from listbox and gCategoryList.
+ let newSelection =
+ list.selectedItem.nextElementSibling || list.selectedItem.previousElementSibling;
+ let selectedItems = Array.from(list.selectedItems);
+ for (let i = list.selectedCount - 1; i >= 0; i--) {
+ let item = selectedItems[i];
+ if (item == newSelection) {
+ newSelection = newSelection.nextElementSibling || newSelection.previousElementSibling;
+ }
+ gCategoryList.splice(list.getIndexOfItem(item), 1);
+ item.remove();
+ }
+ list.selectedItem = newSelection;
+ this.updateButtons();
+
+ // Update the prefs from gCategoryList
+ this.updatePrefs();
+ },
+
+ /**
+ * Saves the given category to the preferences.
+ *
+ * @param categoryName The name of the category.
+ * @param categoryColor The color of the category
+ */
+ async saveCategory(categoryName, categoryColor) {
+ let list = document.getElementById("categorieslist");
+ // Check to make sure another category doesn't have the same name
+ let toBeDeleted = -1;
+ for (let i = 0; i < gCategoryList.length; i++) {
+ if (i == list.selectedIndex) {
+ continue;
+ }
+
+ if (categoryName.toLowerCase() == gCategoryList[i].toLowerCase()) {
+ let [title, description] = await document.l10n.formatValues([
+ { id: "category-overwrite-title" },
+ { id: "category-overwrite" },
+ ]);
+
+ if (Services.prompt.confirm(null, title, description)) {
+ if (list.selectedIndex != -1) {
+ // Don't delete the old category yet. It will mess up indices.
+ toBeDeleted = list.selectedIndex;
+ }
+ list.selectedIndex = i;
+ } else {
+ return;
+ }
+ }
+ }
+
+ if (categoryName.length == 0) {
+ let warning = await document.l10n.formatValue("category-blank-warning");
+ Services.prompt.alert(null, null, warning);
+ return;
+ }
+
+ let categoryNameFix = cal.view.formatStringForCSSRule(categoryName);
+ if (list.selectedIndex == -1) {
+ this.backupData(categoryNameFix);
+ gCategoryList.push(categoryName);
+ if (categoryColor) {
+ categoryPrefBranch.setCharPref(categoryNameFix, categoryColor);
+ }
+ } else {
+ this.backupData(categoryNameFix);
+ gCategoryList.splice(list.selectedIndex, 1, categoryName);
+ categoryPrefBranch.setCharPref(categoryNameFix, categoryColor || "");
+ }
+
+ // If 'Overwrite' was chosen, delete category that was being edited
+ if (toBeDeleted != -1) {
+ list.selectedIndex = toBeDeleted;
+ this.deleteCategory();
+ }
+
+ this.updateCategoryList();
+
+ let updatedCategory = gCategoryList.indexOf(categoryName);
+ list.ensureIndexIsVisible(updatedCategory);
+ list.selectedIndex = updatedCategory;
+ },
+
+ /**
+ * Enable the edit and delete category buttons.
+ */
+ updateButtons() {
+ let categoriesList = document.getElementById("categorieslist");
+ document.getElementById("deleteCButton").disabled = categoriesList.selectedCount <= 0;
+ document.getElementById("editCButton").disabled = categoriesList.selectedCount != 1;
+ },
+
+ /**
+ * Backs up the category name in case the dialog is canceled.
+ *
+ * @see formatStringForCSSRule
+ * @param categoryNameFix The formatted category name.
+ */
+ backupData(categoryNameFix) {
+ let currentColor = categoryPrefBranch.getCharPref(categoryNameFix, "##NEW");
+
+ for (let i = 0; i < parent.backupPrefList.length; i++) {
+ if (categoryNameFix == parent.backupPrefList[i].name) {
+ return;
+ }
+ }
+ parent.backupPrefList[parent.backupPrefList.length] = {
+ name: categoryNameFix,
+ color: currentColor,
+ };
+ },
+
+ /**
+ * Event Handler function to be called on doubleclick of the categories
+ * list. If the edit function is enabled and the user doubleclicked on a
+ * list item, then edit the selected category.
+ */
+ listOnDblClick(event) {
+ if (event.target.localName == "listitem" && !document.getElementById("editCButton").disabled) {
+ this.editCategory();
+ }
+ },
+
+ /**
+ * Reverts category preferences in case the cancel button is pressed.
+ */
+ panelOnCancel() {
+ for (let i = 0; i < parent.backupPrefList.length; i++) {
+ if (parent.backupPrefList[i].color == "##NEW") {
+ try {
+ categoryPrefBranch.clearUserPref(parent.backupPrefList[i].name);
+ } catch (ex) {
+ dump("Exception caught in 'panelOnCancel': " + ex + "\n");
+ }
+ } else {
+ categoryPrefBranch.setCharPref(
+ parent.backupPrefList[i].name,
+ parent.backupPrefList[i].color
+ );
+ }
+ }
+ },
+};
diff --git a/comm/calendar/base/content/preferences/editCategory.js b/comm/calendar/base/content/preferences/editCategory.js
new file mode 100644
index 0000000000..c3479b88c0
--- /dev/null
+++ b/comm/calendar/base/content/preferences/editCategory.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported editCategoryLoad, categoryNameChanged, clickColor, delay */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+window.addEventListener("DOMContentLoaded", editCategoryLoad);
+
+// Global variable, set to true if the user has picked a custom color.
+var customColorSelected = false;
+
+/**
+ * Load Handler, called when the edit category dialog is loaded
+ */
+function editCategoryLoad() {
+ let winArg = window.arguments[0];
+ let color = winArg.color || cal.view.hashColor(winArg.category);
+ let hasColor = !!winArg.color;
+ document.getElementById("categoryName").value = winArg.category;
+ document.getElementById("categoryColor").value = color;
+ document.getElementById("useColor").checked = hasColor;
+ customColorSelected = hasColor;
+ document.title = winArg.title;
+
+ toggleColor();
+}
+
+/**
+ * Handler function to be called when the category dialog is accepted and
+ * the opener should further process the selected name and color
+ */
+document.addEventListener("dialogaccept", () => {
+ let color = document.getElementById("useColor").checked
+ ? document.getElementById("categoryColor").value
+ : null;
+
+ let categoryName = document.getElementById("categoryName").value;
+ window.opener.gCategoriesPane.saveCategory(categoryName, color);
+});
+
+/**
+ * Handler function to be called when the category name changed
+ */
+function categoryNameChanged() {
+ let newValue = document.getElementById("categoryName").value;
+
+ // The user removed the category name, assign the color automatically again.
+ if (newValue == "") {
+ customColorSelected = false;
+ }
+
+ if (!customColorSelected && document.getElementById("useColor").checked) {
+ // Color is wanted, choose the color based on the category name's hash.
+ document.getElementById("categoryColor").value = cal.view.hashColor(newValue);
+ }
+}
+
+/**
+ * Handler function to be called when the color picker's color has been changed.
+ */
+function colorPickerChanged() {
+ document.getElementById("useColor").checked = true;
+ customColorSelected = true;
+}
+
+/**
+ * Handler called when the use color checkbox is toggled.
+ */
+function toggleColor() {
+ let useColor = document.getElementById("useColor").checked;
+ let categoryColor = document.getElementById("categoryColor");
+
+ if (useColor) {
+ categoryColor.removeAttribute("disabled");
+ if (toggleColor.lastColor) {
+ categoryColor.value = toggleColor.lastColor;
+ }
+ } else {
+ categoryColor.setAttribute("disabled", "disabled");
+ toggleColor.lastColor = categoryColor.value;
+ categoryColor.value = "";
+ }
+}
+
+/**
+ * Click handler for the color picker. Turns the button back into a colorpicker
+ * when clicked.
+ */
+function clickColor() {
+ let categoryColor = document.getElementById("categoryColor");
+ if (categoryColor.hasAttribute("disabled")) {
+ colorPickerChanged();
+ toggleColor();
+ categoryColor.click();
+ }
+}
+
+/**
+ * Call the function after the given timeout, resetting the timer if delay is
+ * called again with the same function.
+ *
+ * @param timeout The timeout interval.
+ * @param func The function to call after the timeout.
+ */
+function delay(timeout, func) {
+ if (func.timer) {
+ clearTimeout(func.timer);
+ }
+ func.timer = setTimeout(func, timeout);
+}
diff --git a/comm/calendar/base/content/preferences/editCategory.xhtml b/comm/calendar/base/content/preferences/editCategory.xhtml
new file mode 100644
index 0000000000..5128a03249
--- /dev/null
+++ b/comm/calendar/base/content/preferences/editCategory.xhtml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ scrolling="false"
+>
+ <head>
+ <title><!-- category-new-label / category-edit-label --></title>
+ <link rel="localization" href="calendar/category-dialog.ftl" />
+ <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script>
+ <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script>
+ <script defer="defer" src="chrome://calendar/content/preferences/editCategory.js"></script>
+ </head>
+ <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog id="editCategory" buttons="accept,cancel" style="width: 100vw; height: 100vh">
+ <label id="categoryNameLabel" data-l10n-id="category-name-label" control="categoryName" />
+ <html:input
+ id="categoryName"
+ type="text"
+ onchange="categoryNameChanged()"
+ oninput="delay(500, categoryNameChanged)"
+ aria-labelledby="categoryNameLabel"
+ />
+ <hbox id="colorSelectRow">
+ <checkbox
+ id="useColor"
+ data-l10n-id="category-color-label"
+ oncommand="toggleColor(); categoryNameChanged()"
+ />
+ <html:input
+ id="categoryColor"
+ type="color"
+ style="width: 64px; height: 23px"
+ onclick="clickColor()"
+ onchange="colorPickerChanged()"
+ aria-labelledby="useColor"
+ />
+ </hbox>
+ </dialog>
+ </html:body>
+</html>
diff --git a/comm/calendar/base/content/preferences/general.inc.xhtml b/comm/calendar/base/content/preferences/general.inc.xhtml
new file mode 100644
index 0000000000..f6b00ad29c
--- /dev/null
+++ b/comm/calendar/base/content/preferences/general.inc.xhtml
@@ -0,0 +1,185 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <html:legend data-l10n-id="todaypane-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="agenda-days"
+ control="agenda-days-menulist"/>
+ <hbox>
+ <menulist id="agenda-days-menulist"
+ preference="calendar.agenda.days">
+ <menupopup id="agenda-days-popup">
+ <menuitem value="1"/>
+ <menuitem value="2"/>
+ <menuitem value="3"/>
+ <menuitem value="4"/>
+ <menuitem value="5"/>
+ <menuitem value="6"/>
+ <menuitem value="7"/>
+ <menuitem value="14"/>
+ <menuitem value="21"/>
+ <menuitem value="28"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCalendar">
+ <html:fieldset id="defaults-itemtype-groupbox" data-category="paneCalendar">
+ <html:legend data-l10n-id="event-task-legend"></html:legend>
+ <vbox id="defaults-itemtype-box">
+ <hbox id="defaults-event-grid-box" align="center">
+ <label id="default-event-length-label"
+ data-l10n-id="default-length-label"
+ control="defaultlength"/>
+ <html:input id="defaultlength" type="number"
+ class="size3"
+ min="0"
+ preference="calendar.event.defaultlength"
+ onselect="updateUnitLabelPlural('defaultlength', 'defaultlengthunit', 'minutes')"
+ oninput="updateUnitLabelPlural('defaultlength', 'defaultlengthunit', 'minutes')"/>
+ <label id="defaultlengthunit"/>
+ </hbox>
+ <html:table id="defaults-task-table">
+ <html:tr id="defaults-task-start-row">
+ <html:td>
+ <label id="default-task-start-label"
+ data-l10n-id="task-start-label"
+ control="default_task_start"/>
+ </html:td>
+ <html:td>
+ <hbox>
+ <menulist id="default_task_start"
+ crop="none"
+ oncommand="gCalendarGeneralPane.updateDefaultTodoDates()"
+ preference="calendar.task.defaultstart">
+ <menupopup id="default_task_start_popup">
+ <menuitem id="default_task_start_none"
+ data-l10n-id="task-start-1-label"
+ value="none"
+ selected="true"/>
+ <menuitem id="default_task_start_start_of_day"
+ data-l10n-id="task-start-2-label"
+ value="startofday"/>
+ <menuitem id="default_task_start_tomorrow"
+ data-l10n-id="task-start-4-label"
+ value="tomorrow"/>
+ <menuitem id="default_task_start_next_week"
+ data-l10n-id="task-start-5-label"
+ value="nextweek"/>
+ <menuitem id="default_task_start_offset_current"
+ data-l10n-id="task-start-6-label"
+ value="offsetcurrent"/>
+ <menuitem id="default_task_start_offset_next_hour"
+ data-l10n-id="task-start-8-label"
+ value="offsetnexthour"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ <html:td>
+ <hbox id="default_task_start_offset" align="center">
+ <html:input id="default_task_start_offset_text" type="number"
+ class="size3"
+ min="0"
+ preference="calendar.task.defaultstartoffset"
+ onselect="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')"
+ oninput="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')"/>
+ <hbox>
+ <menulist id="default_task_start_offset_units"
+ crop="none"
+ preference="calendar.task.defaultstartoffsetunits">
+ <menupopup id="default_task_start_offset_units_popup">
+ <menuitem id="default_task_start_offset_units_minutes"
+ value="minutes"
+ selected="true"/>
+ <menuitem id="default_task_start_offset_units_hours"
+ value="hours"/>
+ <menuitem id="default_task_start_offset_units_days"
+ value="days"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr id="defaults-task-due-row">
+ <html:td>
+ <label id="default-task-due-label"
+ data-l10n-id="task-due-label"
+ control="default_task_due"/>
+ </html:td>
+ <html:td>
+ <hbox>
+ <menulist id="default_task_due"
+ crop="none"
+ oncommand="gCalendarGeneralPane.updateDefaultTodoDates()"
+ preference="calendar.task.defaultdue">
+ <menupopup id="default_task_due_popup">
+ <menuitem id="default_task_due_none"
+ data-l10n-id="task-start-1-label"
+ value="none"
+ selected="true"/>
+ <menuitem id="default_task_due_end_of_day"
+ data-l10n-id="task-start-3-label"
+ value="endofday"/>
+ <menuitem id="default_task_due_tomorrow"
+ data-l10n-id="task-start-4-label"
+ value="tomorrow"/>
+ <menuitem id="default_task_due_next_week"
+ data-l10n-id="task-start-5-label"
+ value="nextweek"/>
+ <menuitem id="default_task_due_offset_current"
+ data-l10n-id="task-start-7-label"
+ value="offsetcurrent"/>
+ <menuitem id="default_task_due_offset_next_hour"
+ data-l10n-id="task-start-8-label"
+ value="offsetnexthour"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ <html:td>
+ <hbox id="default_task_due_offset" align="center">
+ <html:input id="default_task_due_offset_text" type="number"
+ class="size3"
+ min="0"
+ preference="calendar.task.defaultdueoffset"
+ onselect="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')"
+ oninput="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')"/>
+ <hbox>
+ <menulist id="default_task_due_offset_units"
+ crop="none"
+ preference="calendar.task.defaultdueoffsetunits">
+ <menupopup id="default_task_due_offset_units_popup">
+ <menuitem id="default_task_due_offset_units_minutes"
+ value="minutes"
+ selected="true"/>
+ <menuitem id="default_task_due_offset_units_hours"
+ value="hours"/>
+ <menuitem id="default_task_due_offset_units_days"
+ value="days"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:td>
+ </html:tr>
+ </html:table>
+ </vbox>
+ <hbox align="center">
+ <checkbox id="tabedit" pack="end"
+ data-l10n-id="edit-intab-label"
+ preference="calendar.item.editInTab"/>
+ </hbox>
+ <hbox align="center">
+ <checkbox id="promptDelete" pack="end"
+ data-l10n-id="prompt-delete-label"
+ preference="calendar.item.promptDelete"/>
+ </hbox>
+ </html:fieldset>
+ </html:div>
diff --git a/comm/calendar/base/content/preferences/general.js b/comm/calendar/base/content/preferences/general.js
new file mode 100644
index 0000000000..8991193e05
--- /dev/null
+++ b/comm/calendar/base/content/preferences/general.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gCalendarGeneralPane */
+
+/* import-globals-from ../calendar-ui-utils.js */
+/* globals Preferences */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+Preferences.addAll([
+ { id: "calendar.date.format", type: "int" },
+ { id: "calendar.event.defaultlength", type: "int" },
+ { id: "calendar.timezone.local", type: "string" },
+ { id: "calendar.timezone.useSystemTimezone", type: "bool" },
+ { id: "calendar.task.defaultstart", type: "string" },
+ { id: "calendar.task.defaultstartoffset", type: "int" },
+ { id: "calendar.task.defaultstartoffsetunits", type: "string" },
+ { id: "calendar.task.defaultdue", type: "string" },
+ { id: "calendar.task.defaultdueoffset", type: "int" },
+ { id: "calendar.task.defaultdueoffsetunits", type: "string" },
+ { id: "calendar.agenda.days", type: "int" },
+ { id: "calendar.item.editInTab", type: "bool" },
+ { id: "calendar.item.promptDelete", type: "bool" },
+]);
+
+/**
+ * Global Object to hold methods for the general pref pane
+ */
+var gCalendarGeneralPane = {
+ /**
+ * Initialize the general pref pane. Sets up dialog controls to match the
+ * values set in prefs.
+ */
+ init() {
+ this.onChangedUseSystemTimezonePref();
+
+ let formatter = cal.dtz.formatter;
+ let dateFormattedLong = formatter.formatDateLong(cal.dtz.now());
+ let dateFormattedShort = formatter.formatDateShort(cal.dtz.now());
+
+ // menu items include examples of current date formats.
+ document.l10n.setAttributes(
+ document.getElementById("dateformat-long-menuitem"),
+ "dateformat-long",
+ { date: dateFormattedLong }
+ );
+
+ document.l10n.setAttributes(
+ document.getElementById("dateformat-short-menuitem"),
+ "dateformat-short",
+ { date: dateFormattedShort }
+ );
+
+ // deselect and reselect to update visible item title
+ updateUnitLabelPlural("defaultlength", "defaultlengthunit", "minutes");
+ this.updateDefaultTodoDates();
+
+ let tzMenuList = document.getElementById("calendar-timezone-menulist");
+ let tzMenuPopup = document.getElementById("calendar-timezone-menupopup");
+
+ let tzids = {};
+ let displayNames = [];
+ // don't rely on what order the timezone-service gives you
+ for (let timezoneId of cal.timezoneService.timezoneIds) {
+ let timezone = cal.timezoneService.getTimezone(timezoneId);
+ if (timezone && !timezone.isFloating && !timezone.isUTC) {
+ let displayName = timezone.displayName;
+ displayNames.push(displayName);
+ tzids[displayName] = timezone.tzid;
+ }
+ }
+ // the display names need to be sorted
+ displayNames.sort((a, b) => a.localeCompare(b));
+ for (let displayName of displayNames) {
+ addMenuItem(tzMenuPopup, displayName, tzids[displayName]);
+ }
+
+ let prefValue = Preferences.get("calendar.timezone.local").value;
+ if (!prefValue) {
+ prefValue = cal.dtz.defaultTimezone.tzid;
+ }
+ tzMenuList.value = prefValue;
+
+ // Set the agenda length menulist.
+ this.initializeTodaypaneMenu();
+ },
+
+ updateDefaultTodoDates() {
+ let defaultDue = document.getElementById("default_task_due").value;
+ let defaultStart = document.getElementById("default_task_start").value;
+ let offsetValues = ["offsetcurrent", "offsetnexthour"];
+
+ document.getElementById("default_task_due_offset").style.visibility = offsetValues.includes(
+ defaultDue
+ )
+ ? ""
+ : "hidden";
+ document.getElementById("default_task_start_offset").style.visibility = offsetValues.includes(
+ defaultStart
+ )
+ ? ""
+ : "hidden";
+
+ updateMenuLabelsPlural("default_task_start_offset_text", "default_task_start_offset_units");
+ updateMenuLabelsPlural("default_task_due_offset_text", "default_task_due_offset_units");
+ },
+
+ initializeTodaypaneMenu() {
+ // Assign the labels for the menuitem
+ let menulist = document.getElementById("agenda-days-menulist");
+ let items = menulist.getElementsByTagName("menuitem");
+ for (let menuItem of items) {
+ let menuitemValue = Number(menuItem.value);
+ if (menuitemValue > 7) {
+ menuItem.label = unitPluralForm(menuitemValue / 7, "weeks");
+ } else {
+ menuItem.label = unitPluralForm(menuitemValue, "days");
+ }
+ }
+
+ let pref = Preferences.get("calendar.agenda.days");
+ let value = pref.value;
+
+ // Check if the preference has been edited with a wrong value.
+ if (value > 0 && value <= 28) {
+ if (value % 7 != 0) {
+ let intValue = Math.floor(value / 7) * 7;
+ value = intValue == 0 ? value : intValue;
+ pref.value = value;
+ }
+ } else {
+ pref.value = 14;
+ }
+ },
+
+ onChangedUseSystemTimezonePref() {
+ let useSystemTimezonePref = Preferences.get("calendar.timezone.useSystemTimezone");
+
+ document.getElementById("calendar-timezone-menulist").disabled = useSystemTimezonePref.value;
+ },
+};
+
+Preferences.get("calendar.timezone.useSystemTimezone").on(
+ "change",
+ gCalendarGeneralPane.onChangedUseSystemTimezonePref
+);
diff --git a/comm/calendar/base/content/preferences/notifications.js b/comm/calendar/base/content/preferences/notifications.js
new file mode 100644
index 0000000000..11725b8309
--- /dev/null
+++ b/comm/calendar/base/content/preferences/notifications.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gNotificationsPane */
+/* globals Preferences */
+
+Preferences.add({ id: "calendar.notifications.times", type: "string" });
+
+/**
+ * Global Object to hold methods for the notifications pref pane.
+ */
+var gNotificationsPane = {
+ /**
+ * Initialize <calendar-notifications-setting> and listen to the change event.
+ */
+ init() {
+ var calendarNotificationsSetting = document.getElementById("calendar-notifications-setting");
+ calendarNotificationsSetting.value = Preferences.get("calendar.notifications.times").value;
+ calendarNotificationsSetting.addEventListener("change", () => {
+ Preferences.get("calendar.notifications.times").value = calendarNotificationsSetting.value;
+ });
+ },
+};
diff --git a/comm/calendar/base/content/preferences/views.inc.xhtml b/comm/calendar/base/content/preferences/views.inc.xhtml
new file mode 100644
index 0000000000..d37d183d8b
--- /dev/null
+++ b/comm/calendar/base/content/preferences/views.inc.xhtml
@@ -0,0 +1,311 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <separator/>
+ <box id="datePrefsBox">
+ <hbox align="center">
+ <label data-l10n-id="dateformat-label"
+ control="dateformat"/>
+ </hbox>
+ <hbox align="center">
+ <menulist id="dateformat" crop="none"
+ flex="1"
+ preference="calendar.date.format">
+ <menupopup id="dateformatpopup">
+ <menuitem id="dateformat-long-menuitem"
+ value="0"/>
+ <menuitem id="dateformat-short-menuitem"
+ value="1"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <spacer/>
+#if defined(XP_WIN) || defined(XP_MACOSX) || defined(MOZ_WIDGET_GTK) && defined(MOZ_ENABLE_DBUS)
+ <radiogroup id="timezone-source-radio-group"
+ orient="vertical"
+ preference="calendar.timezone.useSystemTimezone">
+ <hbox align="center">
+ <radio id="use-system-timezone-radio-button"
+ value="true"
+ data-l10n-id="use-system-timezone-radio-button"/>
+ </hbox>
+ <hbox>
+ <radio id="set-timezone-manually-radio-button"
+ value="false"
+ data-l10n-id="set-timezone-manually-radio-button"/>
+ </hbox>
+ </radiogroup>
+ <spacer/><spacer/>
+ <hbox align="center" class="indent">
+ <label data-l10n-id="timezone-label"
+ control="calendar-timezone-menulist"/>
+ </hbox>
+ <hbox align="center">
+ <menulist id="calendar-timezone-menulist"
+ flex="1"
+ preference="calendar.timezone.local">
+ <menupopup id="calendar-timezone-menupopup"/>
+ </menulist>
+ </hbox>
+#else
+ <hbox align="center" class="indent">
+ <label data-l10n-id="timezone-label"
+ control="calendar-timezone-menulist"/>
+ </hbox>
+ <hbox align="center">
+ <menulist id="calendar-timezone-menulist"
+ flex="1"
+ preference="calendar.timezone.local">
+ <menupopup id="calendar-timezone-menupopup"/>
+ </menulist>
+ </hbox>
+#endif
+ <spacer/>
+ <hbox align="center" flex="1">
+ <label data-l10n-id="weekstart-label"
+ control="weekstarts"/>
+ </hbox>
+ <hbox align="center">
+ <menulist id="weekstarts"
+ flex="1"
+ preference="calendar.week.start"
+ oncommand="gViewsPane.updateViewWorkDayCheckboxes(this.value)">
+ <menupopup id="weekstartspopup">
+ <menuitem data-l10n-id="day-1-name" value="0"/>
+ <menuitem data-l10n-id="day-2-name" value="1"/>
+ <menuitem data-l10n-id="day-3-name" value="2"/>
+ <menuitem data-l10n-id="day-4-name" value="3"/>
+ <menuitem data-l10n-id="day-5-name" value="4"/>
+ <menuitem data-l10n-id="day-6-name" value="5"/>
+ <menuitem data-l10n-id="day-7-name" value="6"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <spacer/>
+ </box>
+ <hbox align="center" flex="1">
+ <checkbox id="weekNumber"
+ crop="end"
+ data-l10n-id="show-weeknumber-label"
+ preference="calendar.view-minimonth.showWeekNumber"/>
+ </hbox>
+ <separator/>
+ <hbox>
+ <vbox>
+ <label data-l10n-id="workdays-label"/>
+ </vbox>
+ <vbox>
+ <hbox>
+ <checkbox id="dayoff0"
+ class="dayOffCheckbox"
+ data-l10n-id="day-1-checkbox"
+ orient="vertical"
+ preference="calendar.week.d0sundaysoff"/>
+ <checkbox id="dayoff1"
+ class="dayOffCheckbox"
+ data-l10n-id="day-2-checkbox"
+ orient="vertical"
+ preference="calendar.week.d1mondaysoff"/>
+ <checkbox id="dayoff2"
+ class="dayOffCheckbox"
+ data-l10n-id="day-3-checkbox"
+ orient="vertical"
+ preference="calendar.week.d2tuesdaysoff"/>
+ <checkbox id="dayoff3"
+ class="dayOffCheckbox"
+ data-l10n-id="day-4-checkbox"
+ orient="vertical"
+ preference="calendar.week.d3wednesdaysoff"/>
+ <checkbox id="dayoff4"
+ class="dayOffCheckbox"
+ data-l10n-id="day-5-checkbox"
+ orient="vertical"
+ preference="calendar.week.d4thursdaysoff"/>
+ <checkbox id="dayoff5"
+ class="dayOffCheckbox"
+ data-l10n-id="day-6-checkbox"
+ orient="vertical"
+ preference="calendar.week.d5fridaysoff"/>
+ <checkbox id="dayoff6"
+ class="dayOffCheckbox"
+ data-l10n-id="day-7-checkbox"
+ orient="vertical"
+ preference="calendar.week.d6saturdaysoff"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCalendar">
+ <html:fieldset data-category="paneCalendar">
+ <html:legend data-l10n-id="dayweek-legend"></html:legend>
+ <html:table id="dayAndWeekViewsTable">
+ <html:tr>
+ <html:th>
+ <label data-l10n-id="visible-hours-label"
+ control="visiblehours"/>
+ </html:th>
+ <html:td>
+ <hbox align="center">
+ <hbox>
+ <menulist id="visiblehours"
+ preference="calendar.view.visiblehours">
+ <menupopup id="visiblehourspopup">
+ <menuitem label="1" value="1"/>
+ <menuitem label="2" value="2"/>
+ <menuitem label="3" value="3"/>
+ <menuitem label="4" value="4"/>
+ <menuitem label="5" value="5"/>
+ <menuitem label="6" value="6"/>
+ <menuitem label="7" value="7"/>
+ <menuitem label="8" value="8"/>
+ <menuitem label="9" value="9"/>
+ <menuitem label="10" value="10"/>
+ <menuitem label="11" value="11"/>
+ <menuitem label="12" value="12"/>
+ <menuitem label="13" value="13"/>
+ <menuitem label="14" value="14"/>
+ <menuitem label="15" value="15"/>
+ <menuitem label="16" value="16"/>
+ <menuitem label="17" value="17"/>
+ <menuitem label="18" value="18"/>
+ <menuitem label="19" value="19"/>
+ <menuitem label="20" value="20"/>
+ <menuitem label="21" value="21"/>
+ <menuitem label="22" value="22"/>
+ <menuitem label="23" value="23"/>
+ <menuitem label="24" value="24"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <label data-l10n-id="visible-hours-end-label"/>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label data-l10n-id="day-start-label"
+ control="daystarthour"/>
+ </html:th>
+ <html:td>
+ <hbox>
+ <menulist id="daystarthour"
+ oncommand="gViewsPane.updateViewEndMenu(this.value);"
+ preference="calendar.view.daystarthour">
+ <menupopup id="daystarthourpopup">
+ <menuitem id="timeStart0" value="0" data-l10n-id="midnight-label"/>
+ <menuitem id="timeStart1" value="1"/>
+ <menuitem id="timeStart2" value="2"/>
+ <menuitem id="timeStart3" value="3"/>
+ <menuitem id="timeStart4" value="4"/>
+ <menuitem id="timeStart5" value="5"/>
+ <menuitem id="timeStart6" value="6"/>
+ <menuitem id="timeStart7" value="7"/>
+ <menuitem id="timeStart8" value="8"/>
+ <menuitem id="timeStart9" value="9"/>
+ <menuitem id="timeStart10" value="10"/>
+ <menuitem id="timeStart11" value="11"/>
+ <menuitem id="timeStart12" value="12" data-l10n-id="noon-label"/>
+ <menuitem id="timeStart13" value="13"/>
+ <menuitem id="timeStart14" value="14"/>
+ <menuitem id="timeStart15" value="15"/>
+ <menuitem id="timeStart16" value="16"/>
+ <menuitem id="timeStart17" value="17"/>
+ <menuitem id="timeStart18" value="18"/>
+ <menuitem id="timeStart19" value="19"/>
+ <menuitem id="timeStart20" value="20"/>
+ <menuitem id="timeStart21" value="21"/>
+ <menuitem id="timeStart22" value="22"/>
+ <menuitem id="timeStart23" value="23"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ </html:tr>
+ <html:tr>
+ <html:th>
+ <label data-l10n-id="day-end-label"
+ control="dayendhour"/>
+ </html:th>
+ <html:td>
+ <hbox>
+ <menulist id="dayendhour"
+ oncommand="gViewsPane.updateViewStartMenu(this.value);"
+ preference="calendar.view.dayendhour">
+ <menupopup id="dayendhourpopup">
+ <menuitem id="timeEnd1" value="1"/>
+ <menuitem id="timeEnd2" value="2"/>
+ <menuitem id="timeEnd3" value="3"/>
+ <menuitem id="timeEnd4" value="4"/>
+ <menuitem id="timeEnd5" value="5"/>
+ <menuitem id="timeEnd6" value="6"/>
+ <menuitem id="timeEnd7" value="7"/>
+ <menuitem id="timeEnd8" value="8"/>
+ <menuitem id="timeEnd9" value="9"/>
+ <menuitem id="timeEnd10" value="10"/>
+ <menuitem id="timeEnd11" value="11"/>
+ <menuitem id="timeEnd12" value="12" data-l10n-id="noon-label"/>
+ <menuitem id="timeEnd13" value="13"/>
+ <menuitem id="timeEnd14" value="14"/>
+ <menuitem id="timeEnd15" value="15"/>
+ <menuitem id="timeEnd16" value="16"/>
+ <menuitem id="timeEnd17" value="17"/>
+ <menuitem id="timeEnd18" value="18"/>
+ <menuitem id="timeEnd19" value="19"/>
+ <menuitem id="timeEnd20" value="20"/>
+ <menuitem id="timeEnd21" value="21"/>
+ <menuitem id="timeEnd22" value="22"/>
+ <menuitem id="timeEnd23" value="23"/>
+ <menuitem id="timeEnd24" value="24" data-l10n-id="midnight-label"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </html:td>
+ </html:tr>
+ </html:table>
+ <checkbox id="showLocation" pack="end"
+ data-l10n-id="location-checkbox"
+ preference="calendar.view.showLocation"/>
+ <spacer/>
+ </html:fieldset>
+ </html:div>
+
+ <html:div data-category="paneCalendar">
+ <html:fieldset id="viewsMultiweekGroupbox" data-category="paneCalendar">
+ <html:legend data-l10n-id="multiweek-legend"></html:legend>
+ <hbox align="center">
+ <label data-l10n-id="number-of-weeks-label"
+ control="viewsMultiweekTotalWeeks"/>
+ <hbox>
+ <menulist id="viewsMultiweekTotalWeeks"
+ preference="calendar.weeks.inview">
+ <menupopup>
+ <menuitem data-l10n-id="week-1-label" value="1"/>
+ <menuitem data-l10n-id="week-2-label" value="2"/>
+ <menuitem data-l10n-id="week-3-label" value="3"/>
+ <menuitem data-l10n-id="week-4-label" value="4"/>
+ <menuitem data-l10n-id="week-5-label" value="5"/>
+ <menuitem data-l10n-id="week-6-label" value="6"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ <hbox align="center" id="previousWeeksBox">
+ <label data-l10n-id="previous-weeks-label"
+ control="viewsMultiweekPreviousWeeks"/>
+ <hbox>
+ <menulist id="viewsMultiweekPreviousWeeks"
+ preference="calendar.previousweeks.inview">
+ <menupopup>
+ <menuitem data-l10n-id="week-0-label" value="0"/>
+ <menuitem data-l10n-id="week-1-label" value="1"/>
+ <menuitem data-l10n-id="week-2-label" value="2"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ </hbox>
+ </html:fieldset>
+ </html:div>
diff --git a/comm/calendar/base/content/preferences/views.js b/comm/calendar/base/content/preferences/views.js
new file mode 100644
index 0000000000..7c1a7384a1
--- /dev/null
+++ b/comm/calendar/base/content/preferences/views.js
@@ -0,0 +1,115 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported gViewsPane */
+
+/* globals Preferences */
+
+Preferences.addAll([
+ { id: "calendar.week.start", type: "int" },
+ { id: "calendar.view-minimonth.showWeekNumber", type: "bool" },
+ { id: "calendar.week.d0sundaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d1mondaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d2tuesdaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d3wednesdaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d4thursdaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d5fridaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.week.d6saturdaysoff", type: "bool", inverted: "true" },
+ { id: "calendar.view.daystarthour", type: "int" },
+ { id: "calendar.view.dayendhour", type: "int" },
+ { id: "calendar.view.visiblehours", type: "int" },
+ { id: "calendar.weeks.inview", type: "int" },
+ { id: "calendar.previousweeks.inview", type: "int" },
+ { id: "calendar.view.showLocation", type: "bool" },
+]);
+
+/**
+ * Global Object to hold methods for the views pref pane
+ */
+var gViewsPane = {
+ /**
+ * Initialize the views pref pane. Sets up dialog controls to match the
+ * values set in prefs.
+ */
+ init() {
+ this.updateViewEndMenu(Preferences.get("calendar.view.daystarthour").value);
+ this.updateViewStartMenu(Preferences.get("calendar.view.dayendhour").value);
+ this.updateViewWorkDayCheckboxes(Preferences.get("calendar.week.start").value);
+ this.initializeViewStartEndMenus();
+ },
+
+ /**
+ * Initialize the strings for the "day starts at" and "day ends at"
+ * menulists. This is needed to respect locales that use AM/PM.
+ */
+ initializeViewStartEndMenus() {
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ let formatter = cal.dtz.formatter;
+ let calTime = cal.createDateTime();
+ calTime.minute = 0;
+
+ // 1 to 23 instead of 0 to 24 to keep midnight & noon as the localized strings
+ for (let theHour = 1; theHour <= 23; theHour++) {
+ calTime.hour = theHour;
+ let time = formatter.formatTime(calTime);
+
+ let labelIdStart = "timeStart" + theHour;
+ let labelIdEnd = "timeEnd" + theHour;
+ // This if block to keep Noon as the localized string, instead of as a number.
+ if (theHour != 12) {
+ document.getElementById(labelIdStart).setAttribute("label", time);
+ document.getElementById(labelIdEnd).setAttribute("label", time);
+ }
+ }
+ },
+
+ /**
+ * Updates the view end menu to only display hours after the selected view
+ * start.
+ *
+ * @param aStartValue The value selected for view start.
+ */
+ updateViewEndMenu(aStartValue) {
+ let endMenuKids = document.getElementById("dayendhourpopup").children;
+ for (let i = 0; i < endMenuKids.length; i++) {
+ if (Number(endMenuKids[i].value) <= Number(aStartValue)) {
+ endMenuKids[i].setAttribute("hidden", true);
+ } else {
+ endMenuKids[i].removeAttribute("hidden");
+ }
+ }
+ },
+
+ /**
+ * Updates the view start menu to only display hours before the selected view
+ * end.
+ *
+ * @param aEndValue The value selected for view end.
+ */
+ updateViewStartMenu(aEndValue) {
+ let startMenuKids = document.getElementById("daystarthourpopup").children;
+ for (let i = 0; i < startMenuKids.length; i++) {
+ if (Number(startMenuKids[i].value) >= Number(aEndValue)) {
+ startMenuKids[i].setAttribute("hidden", true);
+ } else {
+ startMenuKids[i].removeAttribute("hidden");
+ }
+ }
+ },
+
+ /**
+ * Update the workday checkboxes based on the start of the week.
+ *
+ * @Param weekStart The (0-based) index of the weekday the week
+ * should start at.
+ */
+ updateViewWorkDayCheckboxes(weekStart) {
+ weekStart = Number(weekStart);
+ for (let i = weekStart; i < weekStart + 7; i++) {
+ let checkbox = document.getElementById("dayoff" + (i % 7));
+ checkbox.parentNode.appendChild(checkbox);
+ }
+ },
+};
diff --git a/comm/calendar/base/content/printing-template.html b/comm/calendar/base/content/printing-template.html
new file mode 100644
index 0000000000..f7ed5fdbca
--- /dev/null
+++ b/comm/calendar/base/content/printing-template.html
@@ -0,0 +1,285 @@
+<!DOCTYPE html>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, you can obtain one at http://mozilla.org/MPL/2.0/. -->
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8" />
+ <title id="title"></title>
+ <style>
+ table {
+ width: 100%;
+ border: 1px black outset;
+ border-spacing: 0;
+ page-break-inside: avoid;
+ display: grid;
+ }
+
+ tbody,
+ tr,
+ th,
+ td {
+ display: contents;
+ }
+
+ th > div,
+ td > div {
+ border: 1px black inset;
+ padding: 2px;
+ overflow: hidden;
+ }
+
+ td > div {
+ min-height: 100px;
+ }
+
+ .day-title {
+ text-align: end;
+ font-size: 13px;
+ }
+
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ li {
+ margin-block-start: 2px;
+ padding: 2px;
+ font-size: 11px;
+ }
+
+ #list-container .vevent {
+ border: 1px solid black;
+ padding: 0;
+ margin-bottom: 10px;
+ }
+
+ #list-container .key {
+ font-style: italic;
+ margin-inline-start: 3px;
+ }
+
+ #list-container .value {
+ margin-inline-start: 20px;
+ }
+
+ #list-container .summarykey {
+ display: none;
+ }
+
+ #list-container .summary {
+ font-weight: bold;
+ margin: 0;
+ padding: 3px;
+ }
+
+ #list-container .description {
+ white-space: pre-wrap;
+ }
+
+ #month-container table {
+ grid-template-columns: repeat(7, 1fr);
+ }
+
+ #month-container .month-title {
+ grid-column: 1 / 8;
+ }
+
+ #week-container table {
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: min-content 2fr 2fr 1fr 1fr;
+ }
+
+ #week-container .week-title {
+ grid-column: 1 / 3;
+ }
+
+ #week-container .monday-box > div {
+ grid-column: 1;
+ grid-row: 2;
+ }
+
+ #week-container .tuesday-box > div {
+ grid-column: 1;
+ grid-row: 3;
+ }
+
+ #week-container .wednesday-box > div {
+ grid-column: 1;
+ grid-row: 4 / 6;
+ }
+ </style>
+ </head>
+ <body>
+ <!-- This is what is printed when printing the calendar.
+ It is filled dynamically by calPrintUtils.jsm. -->
+ <div id="list-container"></div>
+ <div id="month-container"></div>
+ <div id="week-container"></div>
+ <div id="tasks-list-box" hidden="true">
+ <h3 id="tasks-title"></h3>
+ <ul id="task-container" class="taskList"></ul>
+ </div>
+
+ <!-- List item template for the "list" layout. -->
+ <template id="list-item-template">
+ <div class="vevent">
+ <div class="row summaryrow">
+ <div class="key summarykey"></div>
+ <div class="value summary"></div>
+ </div>
+ <div class="row intervalrow">
+ <div class="key intervalkey"></div>
+ <div class="value dtstart"></div>
+ </div>
+ <div class="row locationrow">
+ <div class="key locationkey"></div>
+ <div class="value location"></div>
+ </div>
+ <div class="row descriptionrow">
+ <div class="key descriptionkey"></div>
+ <div class="value description"></div>
+ </div>
+ </div>
+ </template>
+
+ <!-- Month template for the "monthly grid" layout. -->
+ <template id="month-template">
+ <table>
+ <tr>
+ <th><div class="month-title"></div></th>
+ </tr>
+ <tr>
+ <th><div></div></th>
+ <th><div></div></th>
+ <th><div></div></th>
+ <th><div></div></th>
+ <th><div></div></th>
+ <th><div></div></th>
+ <th><div></div></th>
+ </tr>
+ </table>
+ </template>
+
+ <!-- Week template for the "monthly grid" layout. -->
+ <template id="month-week-template">
+ <tr>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td>
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ </tr>
+ </template>
+
+ <!-- Week template for the "weekly planner" layout. -->
+ <template id="week-template">
+ <table>
+ <tr>
+ <th>
+ <div class="week-title"></div>
+ </th>
+ </tr>
+ <tr>
+ <td class="monday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="tuesday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="wednesday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="thursday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="friday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="saturday-box">
+ <div>
+ <div class="day-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ <td class="sunday-box">
+ <div>
+ <div class="day-title sunday-title"></div>
+ <ul class="items"></ul>
+ </div>
+ </td>
+ </tr>
+ </table>
+ </template>
+
+ <!-- List item template for the "monthly grid" and "weekly planner" layouts. -->
+ <template id="item-template">
+ <li class="category-color-box calendar-color-box">
+ <span class="item-interval"></span>
+ <span class="item-title"></span>
+ </li>
+ </template>
+
+ <!-- Template for tasks with no due date. -->
+ <template id="task-template">
+ <li>
+ <input type="checkbox" class="task-checkbox" disabled="disabled" />
+ <span class="task-title"></span>
+ </li>
+ </template>
+ </body>
+</html>
diff --git a/comm/calendar/base/content/publish.js b/comm/calendar/base/content/publish.js
new file mode 100644
index 0000000000..cad9123843
--- /dev/null
+++ b/comm/calendar/base/content/publish.js
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported publishCalendarData, publishCalendarDataDialogResponse,
+ * publishEntireCalendar, publishEntireCalendarDialogResponse
+ */
+
+/* import-globals-from ../../base/content/calendar-views-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Show publish dialog, ask for URL and publish all selected items.
+ */
+function publishCalendarData() {
+ let args = {};
+
+ args.onOk = self.publishCalendarDataDialogResponse;
+
+ openDialog(
+ "chrome://calendar/content/publishDialog.xhtml",
+ "caPublishEvents",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+}
+
+/**
+ * Callback method for publishCalendarData() that is called when the user
+ * presses the OK button in the publish dialog.
+ */
+function publishCalendarDataDialogResponse(CalendarPublishObject, aProgressDialog) {
+ publishItemArray(
+ currentView().getSelectedItems(),
+ CalendarPublishObject.remotePath,
+ aProgressDialog
+ );
+}
+
+/**
+ * Show publish dialog, ask for URL and publish all items from the calendar.
+ *
+ * @param {?calICalendar} aCalendar - The calendar that will be published.
+ * If not specified, the user will be prompted to select a calendar.
+ */
+function publishEntireCalendar(aCalendar) {
+ if (!aCalendar) {
+ let calendars = cal.manager.getCalendars();
+
+ if (calendars.length == 1) {
+ // Do not ask user for calendar if only one calendar exists
+ aCalendar = calendars[0];
+ } else {
+ // Ask user to select the calendar that should be published.
+ // publishEntireCalendar() will be called again if OK is pressed
+ // in the dialog and the selected calendar will be passed in.
+ // Therefore return after openDialog().
+ let args = {};
+ args.onOk = publishEntireCalendar;
+ args.promptText = cal.l10n.getCalString("publishPrompt");
+ openDialog(
+ "chrome://calendar/content/chooseCalendarDialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ return;
+ }
+ }
+
+ let args = {};
+ let publishObject = {};
+
+ args.onOk = self.publishEntireCalendarDialogResponse;
+
+ publishObject.calendar = aCalendar;
+
+ // restore the remote ics path preference from the calendar passed in
+ let remotePath = aCalendar.getProperty("remote-ics-path");
+ if (remotePath) {
+ publishObject.remotePath = remotePath;
+ }
+
+ args.publishObject = publishObject;
+ openDialog(
+ "chrome://calendar/content/publishDialog.xhtml",
+ "caPublishEvents",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+}
+
+/**
+ * Callback method for publishEntireCalendar() that is called when the user
+ * presses the OK button in the publish dialog.
+ */
+async function publishEntireCalendarDialogResponse(CalendarPublishObject, aProgressDialog) {
+ // store the selected remote ics path as a calendar preference
+ CalendarPublishObject.calendar.setProperty("remote-ics-path", CalendarPublishObject.remotePath);
+
+ aProgressDialog.onStartUpload();
+ let oldCalendar = CalendarPublishObject.calendar;
+ let items = await oldCalendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ null,
+ null
+ );
+ publishItemArray(items, CalendarPublishObject.remotePath, aProgressDialog);
+}
+
+function publishItemArray(aItemArray, aPath, aProgressDialog) {
+ let outputStream;
+ let inputStream;
+ let storageStream;
+
+ let icsURL = Services.io.newURI(aPath);
+
+ let channel = Services.io.newChannelFromURI(
+ icsURL,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ if (icsURL.schemeIs("webcal")) {
+ icsURL.scheme = "http";
+ }
+ if (icsURL.schemeIs("webcals")) {
+ icsURL.scheme = "https";
+ }
+
+ switch (icsURL.scheme) {
+ case "http":
+ case "https":
+ channel = channel.QueryInterface(Ci.nsIHttpChannel);
+ break;
+ case "file":
+ channel = channel.QueryInterface(Ci.nsIFileChannel);
+ break;
+ default:
+ dump("No such scheme\n");
+ return;
+ }
+
+ let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel);
+ uploadChannel.notificationCallbacks = notificationCallbacks;
+
+ storageStream = Cc["@mozilla.org/storagestream;1"].createInstance(Ci.nsIStorageStream);
+ storageStream.init(32768, 0xffffffff, null);
+ outputStream = storageStream.getOutputStream(0);
+
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems(aItemArray);
+ // Outlook requires METHOD:PUBLISH property:
+ let methodProp = cal.icsService.createIcalProperty("METHOD");
+ methodProp.value = "PUBLISH";
+ serializer.addProperty(methodProp);
+ serializer.serializeToStream(outputStream);
+ outputStream.close();
+
+ inputStream = storageStream.newInputStream(0);
+
+ uploadChannel.setUploadStream(inputStream, "text/calendar", -1);
+ try {
+ channel.asyncOpen(new PublishingListener(aProgressDialog));
+ } catch (e) {
+ Services.prompt.alert(
+ null,
+ cal.l10n.getCalString("genericErrorTitle"),
+ cal.l10n.getCalString("otherPutError", [e.message])
+ );
+ }
+}
+
+/** @implements {nsIInterfaceRequestor} */
+var notificationCallbacks = {
+ getInterface(iid, instance) {
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ if (!this.calAuthPrompt) {
+ return new cal.auth.Prompt();
+ }
+ }
+ if (iid.equals(Ci.nsIAuthPrompt)) {
+ // use the window watcher service to get a nsIAuthPrompt impl
+ return Services.ww.getNewAuthPrompter(null);
+ }
+
+ throw Components.Exception(`${iid} not implemented`, Cr.NS_ERROR_NO_INTERFACE);
+ },
+};
+
+/**
+ * Listener object to pass to `channel.asyncOpen()`. A reference to the current dialog window
+ * passed to the constructor provides access to the dialog once the request is done.
+ *
+ * @implements {nsIStreamListener}
+ */
+class PublishingListener {
+ QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]);
+
+ constructor(progressDialog) {
+ this.progressDialog = progressDialog;
+ }
+
+ onStartRequest(request) {}
+ onStopRequest(request, status) {
+ let channel;
+ let requestSucceeded;
+ try {
+ channel = request.QueryInterface(Ci.nsIHttpChannel);
+ requestSucceeded = channel.requestSucceeded;
+ } catch (e) {
+ // Don't fail if it is not an http channel, will be handled below.
+ }
+
+ if (channel && !requestSucceeded) {
+ this.progressDialog.wrappedJSObject.onStopUpload(0);
+ let body = cal.l10n.getCalString("httpPutError", [
+ channel.responseStatus,
+ channel.responseStatusText,
+ ]);
+ Services.prompt.alert(null, cal.l10n.getCalString("genericErrorTitle"), body);
+ } else if (!channel && !Components.isSuccessCode(request.status)) {
+ this.progressDialog.wrappedJSObject.onStopUpload(0);
+ // XXX this should be made human-readable.
+ let body = cal.l10n.getCalString("otherPutError", [request.status.toString(16)]);
+ Services.prompt.alert(null, cal.l10n.getCalString("genericErrorTitle"), body);
+ } else {
+ this.progressDialog.wrappedJSObject.onStopUpload(100);
+ }
+ }
+
+ onDataAvailable(request, inStream, sourceOffset, count) {}
+}
diff --git a/comm/calendar/base/content/sound.wav b/comm/calendar/base/content/sound.wav
new file mode 100644
index 0000000000..1bd5683f8c
--- /dev/null
+++ b/comm/calendar/base/content/sound.wav
Binary files differ
diff --git a/comm/calendar/base/content/today-pane-agenda.js b/comm/calendar/base/content/today-pane-agenda.js
new file mode 100644
index 0000000000..7eb74a574e
--- /dev/null
+++ b/comm/calendar/base/content/today-pane-agenda.js
@@ -0,0 +1,668 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals CalendarFilteredViewMixin, calendarCalendarButtonDNDObserver, setupAttendanceMenu,
+ openEventDialogForViewing, modifyEventWithDialog, calendarViewController, showToolTip,
+ TodayPane */
+
+{
+ const { CalMetronome } = ChromeUtils.import("resource:///modules/CalMetronome.jsm");
+ const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ class Agenda extends CalendarFilteredViewMixin(customElements.get("tree-listbox")) {
+ _showsToday = false;
+
+ constructor() {
+ super();
+
+ this.addEventListener("contextmenu", event => this._showContextMenu(event));
+ this.addEventListener("keypress", event => {
+ if (this.selectedIndex < 0) {
+ return;
+ }
+
+ switch (event.key) {
+ case "Enter":
+ this.editSelectedItem();
+ break;
+ case "Delete":
+ case "Backspace":
+ // Fall through to "Backspace" to avoid deleting messages if the
+ // preferred deletion button is not "Delete".
+ this.deleteSelectedItem();
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ });
+ this.addEventListener("dragover", event =>
+ calendarCalendarButtonDNDObserver.onDragOver(event)
+ );
+ this.addEventListener("drop", event => calendarCalendarButtonDNDObserver.onDrop(event));
+ document
+ .getElementById("itemTooltip")
+ .addEventListener("popupshowing", event => this._fillTooltip(event));
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "numberOfDays",
+ "calendar.agenda.days",
+ 14,
+ () => this.update(this.startDate),
+ value => {
+ // Invalid values, return the default.
+ if (value < 1 || value > 28) {
+ return 14;
+ }
+ return value;
+ }
+ );
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ super.connectedCallback();
+
+ let metronomeCallback = () => {
+ if (!this.showsToday) {
+ return;
+ }
+
+ for (let item of this.children) {
+ item.setRelativeTime();
+ }
+ };
+ CalMetronome.on("minute", metronomeCallback);
+ window.addEventListener("unload", () => CalMetronome.off("minute", metronomeCallback));
+ }
+
+ /**
+ * Implementation as required by CalendarFilteredViewMixin.
+ */
+ clearItems() {
+ while (this.lastChild) {
+ this.lastChild.remove();
+ }
+ }
+
+ /**
+ * Implementation as required by CalendarFilteredViewMixin.
+ *
+ * @param {calIItemBase[]} items
+ */
+ addItems(items) {
+ for (let item of items) {
+ if (document.getElementById(`agenda-listitem-${item.hashId}`)) {
+ // Item already added.
+ continue;
+ }
+
+ let startItem = document.createElement("li", { is: "agenda-listitem" });
+ startItem.item = item;
+ this.insertListItem(startItem);
+
+ // Try to maintain selection across item edits.
+ if (this._lastRemovedID == startItem.id) {
+ setTimeout(() => (this.selectedIndex = this.rows.indexOf(startItem)));
+ }
+ }
+ }
+
+ /**
+ * Implementation as required by CalendarFilteredViewMixin.
+ *
+ * @param {calIItemBase[]} items
+ */
+ removeItems(items) {
+ for (let item of items) {
+ let startItem = document.getElementById(`agenda-listitem-${item.hashId}`);
+ if (!startItem) {
+ // Item not found.
+ continue;
+ }
+
+ this.removeListItem(startItem);
+ this._lastRemovedID = startItem.id;
+ }
+ }
+
+ /**
+ * Implementation as required by CalendarFilteredViewMixin.
+ *
+ * @param {string} calendarId
+ */
+ removeItemsFromCalendar(calendarId) {
+ for (let li of [...this.children]) {
+ if (li.item.calendar.id == calendarId) {
+ if (li.displayDateHeader && li.nextElementSibling?.dateString == li.dateString) {
+ li.nextElementSibling.displayDateHeader = true;
+ }
+ li.remove();
+ }
+ }
+ }
+
+ /**
+ * Set the date displayed in the agenda. If the date is today, display the
+ * full agenda, otherwise display just the given date.
+ *
+ * @param {calIDateTime} date
+ */
+ async update(date) {
+ let today = cal.dtz.now();
+
+ this.startDate = date.clone();
+ this.startDate.isDate = true;
+
+ this.endDate = this.startDate.clone();
+ this._showsToday =
+ date.year == today.year && date.month == today.month && date.day == today.day;
+ if (this._showsToday) {
+ this.endDate.day += this.numberOfDays;
+ } else {
+ this.endDate.day++;
+ }
+
+ this.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ if (this.isActive) {
+ await this.refreshItems();
+ } else {
+ await this.activate();
+ }
+ this.selectedIndex = 0;
+ }
+
+ /**
+ * If the agenda is showing today (true), or any other day (false).
+ *
+ * @type {boolean}
+ */
+ get showsToday() {
+ return this._showsToday;
+ }
+
+ /**
+ * Insert the given list item at the appropriate point in the list, and
+ * shows or hides date headers as appropriate. Use this method rather than
+ * DOM methods.
+ *
+ * @param {AgendaListItem} listItem
+ */
+ insertListItem(listItem) {
+ cal.data.binaryInsertNode(this, listItem, listItem, this._compareListItems, false, n => n);
+
+ if (listItem.previousElementSibling?.dateString == listItem.dateString) {
+ listItem.displayDateHeader = false;
+ } else if (listItem.nextElementSibling?.dateString == listItem.dateString) {
+ listItem.nextElementSibling.displayDateHeader = false;
+ }
+ }
+
+ /**
+ * Remove the given list item from the list, and shows date headers as
+ * appropriate. Use this method rather than DOM methods.
+ *
+ * @param {AgendaListItem} listItem
+ */
+ removeListItem(listItem) {
+ if (
+ listItem.displayDateHeader &&
+ listItem.nextElementSibling?.dateString == listItem.dateString
+ ) {
+ listItem.nextElementSibling.displayDateHeader = true;
+ }
+ listItem.remove();
+ }
+
+ /**
+ * Compare two list items for insertion order, using the `sortValue`
+ * property on each item, deferring to `compareItems` if the same.
+ *
+ * @param {AgendaListItem} a
+ * @param {AgendaListItem} b
+ * @returns {number}
+ */
+ _compareListItems(a, b) {
+ let cmp = a.sortValue - b.sortValue;
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ return cal.view.compareItems(a.item, b.item);
+ }
+
+ /**
+ * Returns the calendar item of the selected row.
+ *
+ * @returns {calIEvent}
+ */
+ get selectedItem() {
+ return this.getRowAtIndex(this.selectedIndex)?.item;
+ }
+
+ /**
+ * Shows the context menu.
+ *
+ * @param {MouseEvent} event
+ */
+ _showContextMenu(event) {
+ let row = event.target.closest("li");
+ if (!row) {
+ return;
+ }
+ this.selectedIndex = this.rows.indexOf(row);
+
+ let popup = document.getElementById("agenda-menupopup");
+ let menu = document.getElementById("calendar-today-pane-menu-attendance-menu");
+ setupAttendanceMenu(menu, [this.selectedItem]);
+ popup.openPopupAtScreen(event.screenX, event.screenY, true);
+ }
+
+ /**
+ * Opens the UI for editing the selected event.
+ */
+ editSelectedItem() {
+ if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) {
+ modifyEventWithDialog(this.selectedItem, true);
+ return;
+ }
+ openEventDialogForViewing(this.selectedItem);
+ }
+
+ /**
+ * Deletes the selected event.
+ */
+ deleteSelectedItem() {
+ calendarViewController.deleteOccurrences([this.selectedItem], false, false);
+ }
+
+ /**
+ * Called in the 'popupshowing' event of #itemTooltip.
+ *
+ * @param {Event} event
+ */
+ _fillTooltip(event) {
+ let element = document.elementFromPoint(event.clientX, event.clientY);
+ if (!this.contains(element)) {
+ // Not on the agenda, ignore.
+ return;
+ }
+
+ if (!element.closest(".agenda-listitem-details")) {
+ // Not on an agenda item, cancel.
+ event.preventDefault();
+ return;
+ }
+
+ showToolTip(event.target, element.closest(".agenda-listitem").item);
+ }
+ }
+ customElements.define("agenda-list", Agenda, { extends: "ul" });
+
+ class AgendaListItem extends HTMLLIElement {
+ /**
+ * If this element represents an event that starts before the displayed day(s).
+ *
+ * @type {boolean}
+ */
+ overlapsDisplayStart = false;
+
+ /**
+ * If this element represents an event on a day that is not the event's first day.
+ *
+ * @type {boolean}
+ */
+ overlapsDayStart = false;
+
+ /**
+ * If this element represents an event on a day that is not the event's last day.
+ *
+ * @type {boolean}
+ */
+ overlapsDayEnd = false;
+
+ /**
+ * If this element represents an event that ends after the displayed day(s).
+ *
+ * @type {boolean}
+ */
+ overlapsDisplayEnd = false;
+
+ constructor() {
+ super();
+ this.setAttribute("is", "agenda-listitem");
+ this.classList.add("agenda-listitem");
+
+ let template = document.getElementById("agenda-listitem");
+ for (let element of template.content.children) {
+ this.appendChild(element.cloneNode(true));
+ }
+
+ this.dateHeaderElement = this.querySelector(".agenda-date-header");
+ this.detailsElement = this.querySelector(".agenda-listitem-details");
+ this.calendarElement = this.querySelector(".agenda-listitem-calendar");
+ this.timeElement = this.querySelector(".agenda-listitem-time");
+ this.titleElement = this.querySelector(".agenda-listitem-title");
+ this.relativeElement = this.querySelector(".agenda-listitem-relative");
+ this.overlapElement = this.querySelector(".agenda-listitem-overlap");
+
+ this.detailsElement.addEventListener("dblclick", () => {
+ if (Services.prefs.getBoolPref("calendar.events.defaultActionEdit", true)) {
+ modifyEventWithDialog(this.item, true);
+ return;
+ }
+ openEventDialogForViewing(this.item);
+ });
+ }
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ if (!this.overlapsDayEnd || this.overlapsDisplayEnd) {
+ return;
+ }
+
+ // Where the start and end of an event are on different days, both within
+ // the date range of the agenda, a second item is added representing the
+ // end of the event. It's owned by this item (representing the start of
+ // the event), and if this item is removed, it is too.
+ this._endItem = document.createElement("li", { is: "agenda-listitem" });
+ this._endItem.classList.add("agenda-listitem-end");
+ this._endItem.item = this.item;
+ TodayPane.agenda.insertListItem(this._endItem);
+ }
+
+ disconnectedCallback() {
+ // When this item is removed, remove the item representing the end of
+ // the event, if there is one.
+ if (this._endItem) {
+ TodayPane.agenda.removeListItem(this._endItem);
+ delete this._endItem;
+ }
+ }
+
+ /**
+ * The date for this event, in ISO format (YYYYMMDD). This corresponds
+ * to the date header shown for this event, so only the first event on
+ * each day needs to show a header.
+ *
+ * @type string
+ */
+ get dateString() {
+ return this._dateString;
+ }
+
+ set dateString(value) {
+ this._dateString = value.substring(0, 8);
+
+ let date = cal.createDateTime(value);
+ let today = cal.dtz.now();
+ let tomorrow = cal.dtz.now();
+ tomorrow.day++;
+
+ if (date.year == today.year && date.month == today.month && date.day == today.day) {
+ this.dateHeaderElement.textContent = cal.l10n.getCalString("today");
+ } else if (
+ date.year == tomorrow.year &&
+ date.month == tomorrow.month &&
+ date.day == tomorrow.day
+ ) {
+ this.dateHeaderElement.textContent = cal.l10n.getCalString("tomorrow");
+ } else {
+ this.dateHeaderElement.textContent = cal.dtz.formatter.formatDateLongWithoutYear(date);
+ }
+ }
+
+ /**
+ * Whether or not to show the date header on this list item. If the item
+ * is preceded by an item with the same `dateString` value, no header
+ * should be shown.
+ *
+ * @type {boolean}
+ */
+ get displayDateHeader() {
+ return !this.dateHeaderElement.hidden;
+ }
+
+ set displayDateHeader(value) {
+ this.dateHeaderElement.hidden = !value;
+ }
+
+ /**
+ * The calendar item for this list item.
+ *
+ * @type {calIEvent}
+ */
+ get item() {
+ return this._item;
+ }
+
+ set item(item) {
+ this._item = item;
+
+ let isAllDay = item.startDate.isDate;
+ this.classList.toggle("agenda-listitem-all-day", isAllDay);
+
+ let defaultTimezone = cal.dtz.defaultTimezone;
+ this._localStartDate = item.startDate;
+ if (this._localStartDate.timezone.tzid != defaultTimezone.tzid) {
+ this._localStartDate = this._localStartDate.getInTimezone(defaultTimezone);
+ }
+ this._localEndDate = item.endDate;
+ if (this._localEndDate.timezone.tzid != defaultTimezone.tzid) {
+ this._localEndDate = this._localEndDate.getInTimezone(defaultTimezone);
+ }
+ this.overlapsDisplayStart = this._localStartDate.compare(TodayPane.agenda.startDate) < 0;
+
+ // Work out the date and time to use when sorting events, and the date header.
+
+ if (this.classList.contains("agenda-listitem-end")) {
+ this.id = `agenda-listitem-end-${item.hashId}`;
+ this.overlapsDayStart = true;
+
+ let sortDate = this._localEndDate.clone();
+ if (isAllDay) {
+ // Sort all-day events at midnight on the previous day.
+ sortDate.day--;
+ this.sortValue = sortDate.getInTimezone(defaultTimezone).nativeTime;
+ } else {
+ // Sort at the end time of the event.
+ this.sortValue = this._localEndDate.nativeTime;
+
+ // If the event ends at midnight, remove a microsecond so that
+ // it is placed at the end of the previous day's events.
+ if (sortDate.hour == 0 && sortDate.minute == 0 && sortDate.second == 0) {
+ sortDate.day--;
+ this.sortValue--;
+ }
+ }
+ this.dateString = sortDate.icalString;
+ } else {
+ this.id = `agenda-listitem-${item.hashId}`;
+ this.overlapsDayStart = this.overlapsDisplayStart;
+
+ let sortDate;
+ if (this.overlapsDayStart) {
+ // Use midnight for sorting.
+ sortDate = cal.createDateTime();
+ sortDate.resetTo(
+ TodayPane.agenda.startDate.year,
+ TodayPane.agenda.startDate.month,
+ TodayPane.agenda.startDate.day,
+ 0,
+ 0,
+ 0,
+ defaultTimezone
+ );
+ } else {
+ // Use the real start time for sorting.
+ sortDate = this._localStartDate.clone();
+ }
+ this.dateString = sortDate.icalString;
+
+ let nextDay = cal.createDateTime();
+ nextDay.resetTo(sortDate.year, sortDate.month, sortDate.day + 1, 0, 0, 0, defaultTimezone);
+ this.overlapsDayEnd = this._localEndDate.compare(nextDay) > 0;
+ this.overlapsDisplayEnd =
+ this.overlapsDayEnd && this._localEndDate.compare(TodayPane.agenda.endDate) >= 0;
+
+ if (isAllDay || !this.overlapsDayStart || this.overlapsDayEnd) {
+ // Sort using the start of the event.
+ this.sortValue = sortDate.nativeTime;
+ } else {
+ // Sort using the end of the event.
+ this.sortValue = this._localEndDate.nativeTime;
+
+ // If the event ends at midnight, remove a microsecond so that
+ // it is placed at the end of the previous day's events.
+ if (
+ this._localEndDate.hour == 0 &&
+ this._localEndDate.minute == 0 &&
+ this._localEndDate.second == 0
+ ) {
+ this.sortValue--;
+ }
+ }
+ }
+
+ // Set the element's colours.
+
+ let cssSafeCalendar = cal.view.formatStringForCSSRule(this.item.calendar.id);
+ this.style.setProperty("--item-backcolor", `var(--calendar-${cssSafeCalendar}-backcolor)`);
+ this.style.setProperty("--item-forecolor", `var(--calendar-${cssSafeCalendar}-forecolor)`);
+
+ // Set the time label if necessary.
+
+ this.timeElement.removeAttribute("datetime");
+ this.timeElement.textContent = "";
+ if (!isAllDay) {
+ if (!this.overlapsDayStart) {
+ this.timeElement.setAttribute("datetime", cal.dtz.toRFC3339(this.item.startDate));
+ this.timeElement.textContent = cal.dtz.formatter.formatTime(this._localStartDate);
+ } else if (!this.overlapsDayEnd) {
+ this.timeElement.setAttribute("datetime", cal.dtz.toRFC3339(this.item.endDate));
+ this.timeElement.textContent = cal.dtz.formatter.formatTime(
+ this._localEndDate,
+ // We prefer to show midnight as 24:00 if possible to indicate
+ // that the event ends at the end of this day, rather than the
+ // start of the next day.
+ true
+ );
+ }
+ this.setRelativeTime();
+ }
+
+ // Set the title.
+
+ this.titleElement.textContent = this.item.title;
+
+ // Display icons indicating if this event starts or ends on another day.
+
+ if (this.overlapsDayStart) {
+ if (this.overlapsDayEnd) {
+ this.overlapElement.src = "chrome://messenger/skin/icons/new/event-continue.svg";
+ document.l10n.setAttributes(
+ this.overlapElement,
+ "calendar-editable-item-multiday-event-icon-continue"
+ );
+ } else {
+ this.overlapElement.src = "chrome://messenger/skin/icons/new/event-end.svg";
+ document.l10n.setAttributes(
+ this.overlapElement,
+ "calendar-editable-item-multiday-event-icon-end"
+ );
+ }
+ } else if (this.overlapsDayEnd) {
+ this.overlapElement.src = "chrome://messenger/skin/icons/new/event-start.svg";
+ document.l10n.setAttributes(
+ this.overlapElement,
+ "calendar-editable-item-multiday-event-icon-start"
+ );
+ } else {
+ this.overlapElement.removeAttribute("src");
+ this.overlapElement.removeAttribute("data-l10n-id");
+ this.overlapElement.removeAttribute("alt");
+ }
+
+ // Set the invitation status.
+
+ if (cal.itip.isInvitation(item)) {
+ this.setAttribute("status", cal.itip.getInvitedAttendee(item).participationStatus);
+ }
+ }
+
+ /**
+ * Sets class names and a label depending on when the event occurs
+ * relative to the current time.
+ *
+ * If the event happened today but has finished, sets the class
+ * `agenda-listitem-past`, or if it is happening now, sets
+ * `agenda-listitem-now`.
+ *
+ * For events that are today or within the next 12 hours (i.e. early
+ * tomorrow) a label is displayed stating the when the start time is, e.g.
+ * "1 hr ago", "now", "in 23 min".
+ */
+ setRelativeTime() {
+ // These conditions won't change in the lifetime of an AgendaListItem,
+ // so let's avoid any further work and return immediately.
+ if (
+ !TodayPane.agenda.showsToday ||
+ this.item.startDate.isDate ||
+ this.classList.contains("agenda-listitem-end")
+ ) {
+ return;
+ }
+
+ this.classList.remove("agenda-listitem-past");
+ this.classList.remove("agenda-listitem-now");
+ this.relativeElement.textContent = "";
+
+ let now = cal.dtz.now();
+
+ // The event has started.
+ if (this._localStartDate.compare(now) <= 0) {
+ // The event is happening now.
+ if (this._localEndDate.compare(now) <= 0) {
+ this.classList.add("agenda-listitem-past");
+ } else {
+ this.classList.add("agenda-listitem-now");
+ this.relativeElement.textContent = AgendaListItem.relativeFormatter.format(0, "second");
+ }
+ return;
+ }
+
+ let relative = this._localStartDate.subtractDate(now);
+
+ // Should we display a label? Is the event today or less than 12 hours away?
+ if (this._localStartDate.day == now.day || relative.inSeconds < 12 * 60 * 60) {
+ let unit = "hour";
+ let value = relative.hours;
+ if (relative.inSeconds <= 5400) {
+ // 90 minutes.
+ unit = "minute";
+ value = value * 60 + relative.minutes;
+ if (relative.seconds >= 30) {
+ value++;
+ }
+ } else if (relative.minutes >= 30) {
+ value++;
+ }
+ this.relativeElement.textContent = AgendaListItem.relativeFormatter.format(value, unit);
+ }
+ }
+ }
+ XPCOMUtils.defineLazyGetter(
+ AgendaListItem,
+ "relativeFormatter",
+ () => new Intl.RelativeTimeFormat(undefined, { numeric: "auto", style: "short" })
+ );
+ customElements.define("agenda-listitem", AgendaListItem, { extends: "li" });
+}
diff --git a/comm/calendar/base/content/today-pane.js b/comm/calendar/base/content/today-pane.js
new file mode 100644
index 0000000000..ac615b8ebe
--- /dev/null
+++ b/comm/calendar/base/content/today-pane.js
@@ -0,0 +1,535 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from calendar-modes.js */
+/* import-globals-from calendar-tabs.js */
+/* import-globals-from calendar-views-utils.js */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Namespace object to hold functions related to the today pane.
+ */
+var TodayPane = {
+ isLoaded: false,
+ paneViews: null,
+ start: null,
+ cwlabel: null,
+ previousMode: null,
+ switchCounter: 0,
+ minidayTimer: null,
+ minidayDrag: {
+ startX: 0,
+ startY: 0,
+ distance: 0,
+ session: false,
+ },
+
+ /**
+ * Load Handler, sets up the today pane controls.
+ */
+ async onLoad() {
+ this.isLoaded = true;
+
+ TodayPane.paneViews = [
+ cal.l10n.getCalString("eventsandtasks"),
+ cal.l10n.getCalString("tasksonly"),
+ cal.l10n.getCalString("eventsonly"),
+ ];
+
+ this.agenda = document.getElementById("agenda");
+
+ TodayPane.updateDisplay();
+ TodayPane.updateSplitterState();
+ TodayPane.previousMode = gCurrentMode;
+ TodayPane.showTodayPaneStatusLabel();
+
+ document.getElementById("today-splitter").addEventListener("command", () => {
+ window.dispatchEvent(new CustomEvent("viewresize"));
+ });
+
+ Services.obs.addObserver(TodayPane, "defaultTimezoneChanged");
+ },
+
+ /**
+ * Unload handler, cleans up the today pane on window unload.
+ */
+ onUnload() {
+ Services.obs.removeObserver(TodayPane, "defaultTimezoneChanged");
+ },
+
+ /**
+ * React if the default timezone changes.
+ */
+ observe() {
+ if (this.start !== null) {
+ this.setDay(this.start.getInTimezone(cal.dtz.defaultTimezone));
+ }
+ },
+
+ /**
+ * Sets up the label for the switcher that allows switching between today pane
+ * views. (event+task, task only, event only)
+ */
+ updateDisplay() {
+ if (!this.isLoaded) {
+ return;
+ }
+ let agendaIsVisible = document.getElementById("agenda-panel").isVisible(gCurrentMode);
+ let todoIsVisible = document.getElementById("todo-tab-panel").isVisible(gCurrentMode);
+ let index = 2;
+ if (agendaIsVisible && todoIsVisible) {
+ index = 0;
+ } else if (!agendaIsVisible && todoIsVisible) {
+ index = 1;
+ } else if (agendaIsVisible && !todoIsVisible) {
+ index = 2;
+ } else {
+ // agendaIsVisible == false && todoIsVisible == false:
+ // In this case something must have gone wrong
+ // - probably in the previous session - and no pane is displayed.
+ // We set a default by only displaying agenda-pane.
+ agendaIsVisible = true;
+ document.getElementById("agenda-panel").setVisible(agendaIsVisible);
+ index = 2;
+ }
+ let todayHeader = document.getElementById("today-pane-header");
+ todayHeader.setAttribute("index", index);
+ todayHeader.setAttribute("value", this.paneViews[index]);
+ let todayPaneSplitter = document.getElementById("today-pane-splitter");
+ todayPaneSplitter.hidden = index != 0;
+ let todayIsVisible = document.getElementById("today-pane-panel").isVisible();
+
+ // Disable or enable the today pane menuitems that have an attribute
+ // name="minidisplay" depending on the visibility of elements.
+ let menupopup = document.getElementById("calTodayPaneMenuPopup");
+ if (menupopup) {
+ for (let child of menupopup.children) {
+ if (child.getAttribute("name") == "minidisplay") {
+ child.disabled = !todayIsVisible || !agendaIsVisible;
+ }
+ }
+ }
+
+ if (todayIsVisible) {
+ if (agendaIsVisible) {
+ if (this.start === null) {
+ this.setDay(cal.dtz.now());
+ }
+ if (document.getElementById("today-minimonth-box").isVisible()) {
+ document.getElementById("today-minimonth").setAttribute("freebusy", "true");
+ }
+ }
+ if (todoIsVisible) {
+ // Add listener to update the date filters.
+ getViewBox().addEventListener("dayselect", event => {
+ this.updateCalendarToDoUnifinder();
+ });
+ this.updateCalendarToDoUnifinder();
+ }
+ }
+
+ window.dispatchEvent(new CustomEvent("viewresize"));
+ },
+
+ /**
+ * Updates the applied filter and show completed view of the unifinder todo.
+ *
+ * @param {string} [filter] - The filter name to set.
+ */
+ updateCalendarToDoUnifinder(filter) {
+ let tree = document.getElementById("unifinder-todo-tree");
+ if (!tree.hasBeenVisible) {
+ tree.hasBeenVisible = true;
+ tree.refresh();
+ }
+
+ // Set up hiding completed tasks for the unifinder-todo tree
+ filter = filter || tree.getAttribute("filterValue") || "throughcurrent";
+ tree.setAttribute("filterValue", filter);
+
+ document
+ .querySelectorAll("#task-context-menu-filter-todaypane-popup > menuitem")
+ .forEach(item => {
+ if (item.getAttribute("value") == filter) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ });
+
+ let showCompleted = document.getElementById("show-completed-checkbox").checked;
+ if (!showCompleted) {
+ let filterProps = tree.mFilter.getDefinedFilterProperties(filter);
+ if (filterProps) {
+ filterProps.status =
+ (filterProps.status || filterProps.FILTER_STATUS_ALL) &
+ (filterProps.FILTER_STATUS_INCOMPLETE | filterProps.FILTER_STATUS_IN_PROGRESS);
+ filter = filterProps;
+ }
+ }
+
+ // update the filter
+ tree.showCompleted = showCompleted;
+ tree.updateFilter(filter);
+ },
+
+ /**
+ * Go to month/week/day views when double-clicking a label inside miniday
+ */
+ onDoubleClick(aEvent) {
+ if (aEvent.button == 0) {
+ if (aEvent.target.id == "datevalue-label") {
+ switchCalendarView("day", true);
+ } else if (aEvent.target.id == "weekdayNameLabel") {
+ switchCalendarView("day", true);
+ } else if (aEvent.target.id == "currentWeek-label") {
+ switchCalendarView("week", true);
+ } else if (aEvent.target.parentNode.id == "monthNameContainer") {
+ switchCalendarView("month", true);
+ } else {
+ return;
+ }
+ document.getElementById("tabmail").openTab("calendar");
+ }
+ },
+
+ /**
+ * Set conditions about start dragging on day-label or start switching
+ * with time on navigation buttons.
+ */
+ onMousedown(aEvent, aDir) {
+ if (aEvent.button != 0) {
+ return;
+ }
+ let element = aEvent.target;
+ if (element.id == "previous-day-button" || element.id == "next-day-button") {
+ // Start switching days by pressing, without release, the navigation buttons
+ element.addEventListener("mouseout", TodayPane.stopSwitching);
+ element.addEventListener("mouseup", TodayPane.stopSwitching);
+ TodayPane.minidayTimer = setTimeout(
+ TodayPane.updateAdvanceTimer.bind(TodayPane, Event, aDir),
+ 500
+ );
+ } else if (element.id == "datevalue-label") {
+ // Start switching days by dragging the mouse with a starting point on the day label
+ window.addEventListener("mousemove", TodayPane.onMousemove);
+ window.addEventListener("mouseup", TodayPane.stopSwitching);
+ TodayPane.minidayDrag.startX = aEvent.clientX;
+ TodayPane.minidayDrag.startY = aEvent.clientY;
+ }
+ },
+
+ /**
+ * Figure out the mouse distance from the center of the day's label
+ * to the current position.
+ *
+ * NOTE: This function is usually called without the correct this pointer.
+ */
+ onMousemove(aEvent) {
+ const MIN_DRAG_DISTANCE_SQ = 49;
+ let x = aEvent.clientX - TodayPane.minidayDrag.startX;
+ let y = aEvent.clientY - TodayPane.minidayDrag.startY;
+ if (TodayPane.minidayDrag.session) {
+ if (x * x + y * y >= MIN_DRAG_DISTANCE_SQ) {
+ let distance = Math.floor(Math.sqrt(x * x + y * y) - Math.sqrt(MIN_DRAG_DISTANCE_SQ));
+ // Dragging on the left/right side, the day date decrease/increase
+ TodayPane.minidayDrag.distance = x > 0 ? distance : -distance;
+ } else {
+ TodayPane.minidayDrag.distance = 0;
+ }
+ } else if (x * x + y * y > 9) {
+ // move the mouse a bit before starting the drag session
+ window.addEventListener("mouseout", TodayPane.stopSwitching);
+ TodayPane.minidayDrag.session = true;
+ let dragCenterImage = document.getElementById("dragCenter-image");
+ dragCenterImage.removeAttribute("hidden");
+ // Move the starting point in the center so we have a fixed
+ // point where stopping the day switching while still dragging
+ let centerObj = dragCenterImage.getBoundingClientRect();
+ TodayPane.minidayDrag.startX = Math.floor(centerObj.x + centerObj.width / 2);
+ TodayPane.minidayDrag.startY = Math.floor(centerObj.y + centerObj.height / 2);
+
+ TodayPane.updateAdvanceTimer();
+ }
+ },
+
+ /**
+ * Figure out the days switching speed according to the position (when
+ * dragging) or time elapsed (when pressing buttons).
+ */
+ updateAdvanceTimer(aEvent, aDir) {
+ const INITIAL_TIME = 400;
+ const REL_DISTANCE = 8;
+ const MINIMUM_TIME = 100;
+ const ACCELERATE_COUNT_LIMIT = 7;
+ const SECOND_STEP_TIME = 200;
+ if (TodayPane.minidayDrag.session) {
+ // Dragging the day label: days switch with cursor distance and time.
+ let dir = (TodayPane.minidayDrag.distance > 0) - (TodayPane.minidayDrag.distance < 0);
+ TodayPane.advance(dir);
+ let distance = Math.abs(TodayPane.minidayDrag.distance);
+ // Linear relation between distance and switching speed
+ let timeInterval = Math.max(Math.ceil(INITIAL_TIME - distance * REL_DISTANCE), MINIMUM_TIME);
+ TodayPane.minidayTimer = setTimeout(
+ TodayPane.updateAdvanceTimer.bind(TodayPane, null, null),
+ timeInterval
+ );
+ } else {
+ // Keeping pressed next/previous day buttons causes days switching (with
+ // three levels higher speed after some commutations).
+ TodayPane.advance(parseInt(aDir, 10));
+ TodayPane.switchCounter++;
+ let timeInterval = INITIAL_TIME;
+ if (TodayPane.switchCounter > 2 * ACCELERATE_COUNT_LIMIT) {
+ timeInterval = MINIMUM_TIME;
+ } else if (TodayPane.switchCounter > ACCELERATE_COUNT_LIMIT) {
+ timeInterval = SECOND_STEP_TIME;
+ }
+ TodayPane.minidayTimer = setTimeout(
+ TodayPane.updateAdvanceTimer.bind(TodayPane, aEvent, aDir),
+ timeInterval
+ );
+ }
+ },
+
+ /**
+ * Stop automatic days switching when releasing the mouse button or the
+ * position is outside the window.
+ *
+ * NOTE: This function is usually called without the correct this pointer.
+ */
+ stopSwitching(aEvent) {
+ let element = aEvent.target;
+ if (
+ TodayPane.minidayDrag.session &&
+ aEvent.type == "mouseout" &&
+ element.id != "messengerWindow"
+ ) {
+ return;
+ }
+ if (TodayPane.minidayTimer) {
+ clearTimeout(TodayPane.minidayTimer);
+ delete TodayPane.minidayTimer;
+ if (TodayPane.switchCounter == 0 && !TodayPane.minidayDrag.session) {
+ let dir = element.getAttribute("dir");
+ TodayPane.advance(parseInt(dir, 10));
+ }
+ }
+ if (element.id == "previous-day-button" || element.id == "next-day-button") {
+ TodayPane.switchCounter = 0;
+ let button = document.getElementById(element.id);
+ button.removeEventListener("mouseout", TodayPane.stopSwitching);
+ }
+ if (TodayPane.minidayDrag.session) {
+ window.removeEventListener("mouseout", TodayPane.stopSwitching);
+ TodayPane.minidayDrag.distance = 0;
+ document.getElementById("dragCenter-image").setAttribute("hidden", "true");
+ TodayPane.minidayDrag.session = false;
+ }
+ window.removeEventListener("mousemove", TodayPane.onMousemove);
+ window.removeEventListener("mouseup", TodayPane.stopSwitching);
+ },
+
+ /**
+ * Cycle the view shown in the today pane (event+task, event, task).
+ *
+ * @param aCycleForward If true, the views are cycled in the forward
+ * direction, otherwise in the opposite direction
+ */
+ cyclePaneView(aCycleForward) {
+ if (this.paneViews == null) {
+ return;
+ }
+ let index = parseInt(document.getElementById("today-pane-header").getAttribute("index"), 10);
+ index = index + aCycleForward;
+ let nViewLen = this.paneViews.length;
+ if (index >= nViewLen) {
+ index = 0;
+ } else if (index == -1) {
+ index = nViewLen - 1;
+ }
+ let agendaPanel = document.getElementById("agenda-panel");
+ let todoPanel = document.getElementById("todo-tab-panel");
+ let isTodoPanelVisible = index != 2 && todoPanel.isVisibleInMode(gCurrentMode);
+ let isAgendaPanelVisible = index != 1 && agendaPanel.isVisibleInMode(gCurrentMode);
+ todoPanel.setVisible(isTodoPanelVisible);
+ agendaPanel.setVisible(isAgendaPanelVisible);
+ this.updateDisplay();
+ },
+
+ /**
+ * Sets the shown date from a JSDate.
+ *
+ * @param aNewDate The date to show.
+ */
+ setDaywithjsDate(aNewDate) {
+ let newdatetime = cal.dtz.jsDateToDateTime(aNewDate, cal.dtz.floating);
+ newdatetime = newdatetime.getInTimezone(cal.dtz.defaultTimezone);
+ newdatetime.hour = newdatetime.minute = newdatetime.second = 0;
+ this.setDay(newdatetime, true);
+ },
+
+ /**
+ * Sets the first day shown in the today pane.
+ *
+ * @param aNewDate The calIDateTime to set.
+ * @param aDontUpdateMinimonth If true, the minimonth will not be
+ * updated to show the same date.
+ */
+ setDay(aNewDate, aDontUpdateMinimonth) {
+ if (this.setDay.alreadySettingDay) {
+ // If we update the mini-month, this function gets called again.
+ return;
+ }
+ if (!document.getElementById("agenda-panel").isVisible()) {
+ // If the agenda panel isn't visible, there's no need to set the day.
+ return;
+ }
+ this.setDay.alreadySettingDay = true;
+ this.start = aNewDate.clone();
+
+ let daylabel = document.getElementById("datevalue-label");
+ daylabel.value = this.start.day;
+
+ document
+ .getElementById("weekdayNameLabel")
+ .setAttribute("value", cal.l10n.getDateFmtString(`day.${this.start.weekday + 1}.Mmm`));
+
+ let monthnamelabel = document.getElementById("monthNameContainer");
+ monthnamelabel.value =
+ cal.dtz.formatter.shortMonthName(this.start.month) + " " + this.start.year;
+
+ let currentweeklabel = document.getElementById("currentWeek-label");
+ currentweeklabel.value =
+ cal.l10n.getCalString("shortcalendarweek") +
+ " " +
+ cal.weekInfoService.getWeekTitle(this.start);
+
+ if (!aDontUpdateMinimonth) {
+ try {
+ // The minimonth code sometimes throws an exception as a result of this call. Bug 1560547.
+ // As there's no known plausible explanation, just catch the exception and carry on.
+ document.getElementById("today-minimonth").value = cal.dtz.dateTimeToJsDate(this.start);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ this.updatePeriod();
+ this.setDay.alreadySettingDay = false;
+ },
+
+ /**
+ * Advance by a given number of days in the today pane.
+ *
+ * @param aDir The number of days to advance. Negative numbers advance
+ * backwards in time.
+ */
+ advance(aDir) {
+ if (aDir != 0) {
+ this.start.day += aDir;
+ this.setDay(this.start);
+ }
+ },
+
+ /**
+ * Checks if the today pane is showing today's date.
+ */
+ showsToday() {
+ return cal.dtz.sameDay(cal.dtz.now(), this.start);
+ },
+
+ /**
+ * Update the period headers in the agenda listbox using the today pane's
+ * start date.
+ */
+ updatePeriod() {
+ this.agenda.update(this.start);
+ if (document.getElementById("todo-tab-panel").isVisible()) {
+ this.updateCalendarToDoUnifinder();
+ }
+ },
+
+ /**
+ * Display a certain section in the minday/minimonth part of the todaypane.
+ *
+ * @param aSection The section to display
+ */
+ displayMiniSection(aSection) {
+ document.getElementById("today-minimonth-box").setVisible(aSection == "minimonth");
+ document.getElementById("mini-day-box").setVisible(aSection == "miniday");
+ document.getElementById("today-none-box").setVisible(aSection == "none");
+ document.getElementById("today-minimonth").setAttribute("freebusy", aSection == "minimonth");
+ },
+
+ /**
+ * Handler function to update the today-pane when the current mode changes.
+ */
+ onModeModified() {
+ TodayPane.updateDisplay();
+ TodayPane.updateSplitterState();
+ let todayPanePanel = document.getElementById("today-pane-panel");
+ const currentWidth = todayPanePanel.getModeAttribute("modewidths");
+ if (currentWidth != 0) {
+ todayPanePanel.style.width = `${currentWidth}px`;
+ }
+ TodayPane.previousMode = gCurrentMode;
+ },
+
+ get isVisible() {
+ return document.getElementById("today-pane-panel").isVisible();
+ },
+
+ /**
+ * Toggle the today-pane and update its visual appearance.
+ *
+ * @param aEvent The DOM event occurring on activated command.
+ */
+ toggleVisibility(aEvent) {
+ document.getElementById("today-pane-panel").togglePane(aEvent);
+ TodayPane.updateDisplay();
+ TodayPane.updateSplitterState();
+ },
+
+ /**
+ * Update the today-splitter state.
+ */
+ updateSplitterState() {
+ let splitter = document.getElementById("today-splitter");
+ if (this.isVisible) {
+ splitter.removeAttribute("hidden");
+ splitter.setAttribute("state", "open");
+ } else {
+ splitter.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Generates the todaypane toggle command when the today-splitter
+ * is being collapsed or uncollapsed.
+ */
+ onCommandTodaySplitter() {
+ let todaypane = document.getElementById("today-pane-panel");
+ let splitter = document.getElementById("today-splitter");
+ let splitterCollapsed = splitter.getAttribute("state") == "collapsed";
+
+ todaypane.setModeAttribute("modewidths", todaypane.getAttribute("width"));
+
+ if (splitterCollapsed == todaypane.isVisible()) {
+ document.getElementById("calendar_toggle_todaypane_command").doCommand();
+ }
+ },
+
+ /**
+ * Checks if the todayPaneStatusLabel should be hidden.
+ */
+ showTodayPaneStatusLabel() {
+ let hideLabel = !Services.prefs.getBoolPref("calendar.view.showTodayPaneStatusLabel", true);
+ document
+ .getElementById("calendar-status-todaypane-button")
+ .toggleAttribute("hideLabel", hideLabel);
+ },
+};
+
+window.addEventListener("unload", TodayPane.onUnload, { capture: false, once: true });
diff --git a/comm/calendar/base/content/widgets/calendar-alarm-widget.js b/comm/calendar/base/content/widgets/calendar-alarm-widget.js
new file mode 100644
index 0000000000..58300255bd
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-alarm-widget.js
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global Cr MozElements MozXULElement PluralForm Services */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ /**
+ * Represents an alarm in the alarms dialog. It appears there when an alarm is fired, and
+ * allows the alarm to be snoozed, dismissed, etc.
+ *
+ * @augments MozElements.MozRichlistitem
+ */
+ class MozCalendarAlarmWidgetRichlistitem extends MozElements.MozRichlistitem {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <vbox pack="start">
+ <html:img class="alarm-calendar-image"
+ src="chrome://calendar/skin/shared/icons/icon32.svg"
+ alt="" />
+ </vbox>
+ <vbox class="alarm-calendar-event">
+ <label class="alarm-title-label" crop="end"/>
+ <vbox class="additional-information-box">
+ <label class="alarm-date-label"/>
+ <description class="alarm-location-description"
+ crop="end"
+ flex="1"/>
+ <hbox pack="start">
+ <label class="text-link alarm-details-label"
+ value="&calendar.alarm.details.label;"
+ onclick="showDetails(event)"
+ onkeypress="showDetails(event)"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <spacer flex="1"/>
+ <label class="alarm-relative-date-label"/>
+ <vbox class="alarm-action-buttons" pack="center">
+ <button class="alarm-snooze-button"
+ type="menu"
+ label="&calendar.alarm.snoozefor.label;">
+ <menupopup is="calendar-snooze-popup" ignorekeys="true"/>
+ </button>
+ <button class="alarm-dismiss-button"
+ label="&calendar.alarm.dismiss.label;"
+ oncommand="dismissAlarm()"/>
+ </vbox>
+ `,
+ ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+ this.mItem = null;
+ this.mAlarm = null;
+ this.setAttribute("is", "calendar-alarm-widget-richlistitem");
+ }
+
+ set item(val) {
+ this.mItem = val;
+ this.updateLabels();
+ }
+
+ get item() {
+ return this.mItem;
+ }
+
+ set alarm(val) {
+ this.mAlarm = val;
+ this.updateLabels();
+ }
+
+ get alarm() {
+ return this.mAlarm;
+ }
+
+ /**
+ * Refresh UI text (dates, titles, locations) when the data has changed.
+ */
+ updateLabels() {
+ if (!this.mItem || !this.mAlarm) {
+ // Setup not complete, do nothing for now.
+ return;
+ }
+ const formatter = cal.dtz.formatter;
+ let titleLabel = this.querySelector(".alarm-title-label");
+ let locationDescription = this.querySelector(".alarm-location-description");
+ let dateLabel = this.querySelector(".alarm-date-label");
+
+ // Dates
+ if (this.mItem.isEvent()) {
+ dateLabel.value = formatter.formatItemInterval(this.mItem);
+ } else if (this.mItem.isTodo()) {
+ let startDate = this.mItem.entryDate || this.mItem.dueDate;
+ if (startDate) {
+ // A task with a start or due date, show with label.
+ startDate = startDate.getInTimezone(cal.dtz.defaultTimezone);
+ dateLabel.value = cal.l10n.getCalString("alarmStarts", [
+ formatter.formatDateTime(startDate),
+ ]);
+ } else {
+ // If the task has no start date, then format the alarm date.
+ dateLabel.value = formatter.formatDateTime(this.mAlarm.alarmDate);
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // Relative Date
+ this.updateRelativeDateLabel();
+
+ // Title, Location
+ titleLabel.value = this.mItem.title || "";
+ locationDescription.value = this.mItem.getProperty("LOCATION") || "";
+ if (locationDescription.value.length) {
+ let urlMatch = locationDescription.value.match(/(https?:\/\/[^ ]*)/);
+ let url = urlMatch && urlMatch[1];
+ if (url) {
+ locationDescription.setAttribute("link", url);
+ locationDescription.setAttribute(
+ "onclick",
+ "launchBrowser(this.getAttribute('link'), event)"
+ );
+ locationDescription.setAttribute(
+ "oncommand",
+ "launchBrowser(this.getAttribute('link'), event)"
+ );
+ locationDescription.classList.add("text-link", "alarm-details-label");
+ }
+ } else {
+ locationDescription.hidden = true;
+ }
+ // Hide snooze button if read-only.
+ let snoozeButton = this.querySelector(".alarm-snooze-button");
+ if (
+ !cal.acl.isCalendarWritable(this.mItem.calendar) ||
+ !cal.acl.userCanModifyItem(this.mItem)
+ ) {
+ let tooltip = "reminderDisabledSnoozeButtonTooltip";
+ snoozeButton.disabled = true;
+ snoozeButton.setAttribute("tooltiptext", cal.l10n.getString("calendar-alarms", tooltip));
+ } else {
+ snoozeButton.disabled = false;
+ snoozeButton.removeAttribute("tooltiptext");
+ }
+ }
+
+ /**
+ * Refresh UI text for relative date when the data has changed.
+ */
+ updateRelativeDateLabel() {
+ const formatter = cal.dtz.formatter;
+ const item = this.mItem;
+ let relativeDateLabel = this.querySelector(".alarm-relative-date-label");
+ let relativeDateString;
+ let startDate = item[cal.dtz.startDateProp(item)] || item[cal.dtz.endDateProp(item)];
+
+ if (startDate) {
+ startDate = startDate.getInTimezone(cal.dtz.defaultTimezone);
+ let currentDate = cal.dtz.now();
+
+ const sinceDayStart = currentDate.hour * 3600 + currentDate.minute * 60;
+
+ currentDate.second = 0;
+ startDate.second = 0;
+
+ const sinceAlarm = currentDate.subtractDate(startDate).inSeconds;
+
+ this.mAlarmToday = sinceAlarm < sinceDayStart && sinceAlarm > sinceDayStart - 86400;
+
+ if (this.mAlarmToday) {
+ // The alarm is today.
+ relativeDateString = cal.l10n.getCalString("alarmTodayAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else if (sinceAlarm <= sinceDayStart - 86400 && sinceAlarm > sinceDayStart - 172800) {
+ // The alarm is tomorrow.
+ relativeDateString = cal.l10n.getCalString("alarmTomorrowAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else if (sinceAlarm < sinceDayStart + 86400 && sinceAlarm > sinceDayStart) {
+ // The alarm is yesterday.
+ relativeDateString = cal.l10n.getCalString("alarmYesterdayAt", [
+ formatter.formatTime(startDate),
+ ]);
+ } else {
+ // The alarm is way back.
+ relativeDateString = [formatter.formatDateTime(startDate)];
+ }
+ } else {
+ // No start or end date, therefore the alarm must be absolute
+ // and have an alarm date.
+ relativeDateString = [formatter.formatDateTime(this.mAlarm.alarmDate)];
+ }
+
+ relativeDateLabel.value = relativeDateString;
+ }
+
+ /**
+ * Click/keypress handler for "Details" link. Dispatches an event to open an item dialog.
+ *
+ * @param event {Event} The click or keypress event.
+ */
+ showDetails(event) {
+ if (event.type == "click" || (event.type == "keypress" && event.key == "Enter")) {
+ const detailsEvent = new Event("itemdetails", { bubbles: true, cancelable: false });
+ this.dispatchEvent(detailsEvent);
+ }
+ }
+
+ /**
+ * Click handler for "Dismiss" button. Dispatches an event to dismiss the alarm.
+ */
+ dismissAlarm() {
+ const dismissEvent = new Event("dismiss", { bubbles: true, cancelable: false });
+ this.dispatchEvent(dismissEvent);
+ }
+ }
+
+ customElements.define("calendar-alarm-widget-richlistitem", MozCalendarAlarmWidgetRichlistitem, {
+ extends: "richlistitem",
+ });
+
+ /**
+ * A popup panel for selecting how long to snooze alarms/reminders.
+ * It appears when a snooze button is clicked.
+ *
+ * @augments MozElements.MozMenuPopup
+ */
+ class MozCalendarSnoozePopup extends MozElements.MozMenuPopup {
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ <menuitem label="&calendar.alarm.snooze.5minutes.label;"
+ value="5"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.10minutes.label;"
+ value="10"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.15minutes.label;"
+ value="15"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.30minutes.label;"
+ value="30"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.45minutes.label;"
+ value="45"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.1hour.label;"
+ value="60"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.2hours.label;"
+ value="120"
+ oncommand="snoozeItem(event)"/>
+ <menuitem label="&calendar.alarm.snooze.1day.label;"
+ value="1440"
+ oncommand="snoozeItem(event)"/>
+ <menuseparator/>
+ <hbox class="snooze-options-box">
+ <html:input type="number"
+ class="size3 snooze-value-textbox"
+ oninput="updateUIText()"
+ onselect="updateUIText()"/>
+ <menulist class="snooze-unit-menulist" allowevents="true">
+ <menupopup class="snooze-unit-menupopup menulist-menupopup"
+ position="after_start"
+ ignorekeys="true">
+ <menuitem closemenu="single" class="unit-menuitem" value="1"></menuitem>
+ <menuitem closemenu="single" class="unit-menuitem" value="60"></menuitem>
+ <menuitem closemenu="single" class="unit-menuitem" value="1440"></menuitem>
+ </menupopup>
+ </menulist>
+ <toolbarbutton class="snooze-popup-button snooze-popup-ok-button"
+ oncommand="snoozeOk()"/>
+ <toolbarbutton class="snooze-popup-button snooze-popup-cancel-button"
+ aria-label="&calendar.alarm.snooze.cancel;"
+ oncommand="snoozeCancel()"/>
+ </hbox>
+ `,
+ ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"]
+ )
+ );
+ const defaultSnoozeLength = Services.prefs.getIntPref(
+ "calendar.alarms.defaultsnoozelength",
+ 0
+ );
+ const snoozeLength = defaultSnoozeLength <= 0 ? 5 : defaultSnoozeLength;
+
+ let unitList = this.querySelector(".snooze-unit-menulist");
+ let unitValue = this.querySelector(".snooze-value-textbox");
+
+ if ((snoozeLength / 60) % 24 == 0) {
+ // Days
+ unitValue.value = snoozeLength / 60 / 24;
+ unitList.selectedIndex = 2;
+ } else if (snoozeLength % 60 == 0) {
+ // Hours
+ unitValue.value = snoozeLength / 60;
+ unitList.selectedIndex = 1;
+ } else {
+ // Minutes
+ unitValue.value = snoozeLength;
+ unitList.selectedIndex = 0;
+ }
+
+ this.updateUIText();
+ }
+
+ /**
+ * Dispatch a snooze event when an alarm is snoozed.
+ *
+ * @param minutes {number|string} The number of minutes to snooze for.
+ */
+ snoozeAlarm(minutes) {
+ let snoozeEvent = new Event("snooze", { bubbles: true, cancelable: false });
+ snoozeEvent.detail = minutes;
+
+ // For single alarms the event.target has to be the calendar-alarm-widget element,
+ // (so call dispatchEvent on that). For snoozing all alarms the event.target is not
+ // relevant but the snooze all popup is not inside a calendar-alarm-widget (so call
+ // dispatchEvent on 'this').
+ const eventTarget = this.id == "alarm-snooze-all-popup" ? this : this.closest("richlistitem");
+ eventTarget.dispatchEvent(snoozeEvent);
+ }
+
+ /**
+ * Click handler for snooze popup menu items (like "5 Minutes", "1 Hour", etc.).
+ *
+ * @param event {Event} The click event.
+ */
+ snoozeItem(event) {
+ this.snoozeAlarm(event.target.value);
+ }
+
+ /**
+ * Click handler for the "OK" (checkmark) button when snoozing for a custom amount of time.
+ */
+ snoozeOk() {
+ const unitList = this.querySelector(".snooze-unit-menulist");
+ const unitValue = this.querySelector(".snooze-value-textbox");
+ const minutes = (unitList.value || 1) * unitValue.value;
+ this.snoozeAlarm(minutes);
+ }
+
+ /**
+ * Click handler for the "cancel" ("X") button for not snoozing a custom amount of time.
+ */
+ snoozeCancel() {
+ this.hidePopup();
+ }
+
+ /**
+ * Initializes and updates the dynamic UI text. This text can change depending on
+ * input, like for plurals, when you change from "[1] [minute]" to "[2] [minutes]".
+ */
+ updateUIText() {
+ const unitList = this.querySelector(".snooze-unit-menulist");
+ const unitPopup = this.querySelector(".snooze-unit-menupopup");
+ const unitValue = this.querySelector(".snooze-value-textbox");
+ let okButton = this.querySelector(".snooze-popup-ok-button");
+
+ function unitName(list) {
+ return { 1: "unitMinutes", 60: "unitHours", 1440: "unitDays" }[list.value] || "unitMinutes";
+ }
+
+ let pluralString = cal.l10n.getCalString(unitName(unitList));
+
+ const unitPlural = PluralForm.get(unitValue.value, pluralString).replace(
+ "#1",
+ unitValue.value
+ );
+
+ let okButtonAriaLabel = cal.l10n.getString("calendar-alarms", "reminderSnoozeOkA11y", [
+ unitPlural,
+ ]);
+ okButton.setAttribute("aria-label", okButtonAriaLabel);
+
+ const items = unitPopup.getElementsByTagName("menuitem");
+ for (let menuItem of items) {
+ pluralString = cal.l10n.getCalString(unitName(menuItem));
+
+ menuItem.label = PluralForm.get(unitValue.value, pluralString).replace("#1", "").trim();
+ }
+ }
+ }
+
+ customElements.define("calendar-snooze-popup", MozCalendarSnoozePopup, { extends: "menupopup" });
+}
diff --git a/comm/calendar/base/content/widgets/calendar-dnd-widgets.js b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js
new file mode 100644
index 0000000000..f0d75745b6
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-dnd-widgets.js
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals currentView MozElements MozXULElement */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ /**
+ * An abstract class to handle drag on drop for calendar.
+ *
+ * @abstract
+ */
+ class CalendarDnDContainer extends MozXULElement {
+ constructor() {
+ super();
+ this.addEventListener("dragstart", this.onDragStart);
+ this.addEventListener("dragover", this.onDragOver);
+ this.addEventListener("dragenter", this.onDragEnter);
+ this.addEventListener("drop", this.onDrop);
+ this.addEventListener("dragend", this.onDragEnd);
+ this.mCalendarView = null;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.hasConnected = true;
+ }
+
+ /**
+ * The ViewController that supports the interface 'calICalendarView'.
+ *
+ * @returns {calICalendarView}
+ */
+ get calendarView() {
+ return this.mCalendarView;
+ }
+
+ set calendarView(val) {
+ this.mCalendarView = val;
+ }
+
+ /**
+ * Method to add individual code e.g to set up the new item during 'ondrop'.
+ */
+ onDropItem(aItem) {
+ // method that may be overridden by derived bindings...
+ }
+
+ /**
+ * Adds the dropshadows to the children of the binding.
+ * The dropshadows are added at the first position of the children.
+ */
+ addDropShadows() {
+ let offset = this.calendarView.mShadowOffset;
+ let shadowStartDate = this.date.clone();
+ shadowStartDate.addDuration(offset);
+ this.calendarView.mDropShadows = [];
+ for (let i = 0; i < this.calendarView.mDropShadowsLength; i++) {
+ let box = this.calendarView.findDayBoxForDate(shadowStartDate);
+ if (box) {
+ box.setDropShadow(true);
+ this.calendarView.mDropShadows.push(box);
+ }
+ shadowStartDate.day += 1;
+ }
+ }
+
+ /**
+ * Removes all dropShadows from the binding.
+ * Dropshadows are recognized as such by carrying an attribute "dropshadow".
+ */
+ removeDropShadows() {
+ // method that may be overwritten by derived bindings...
+ if (this.calendarView.mDropShadows) {
+ for (let box of this.calendarView.mDropShadows) {
+ box.setDropShadow(false);
+ }
+ }
+ this.calendarView.mDropShadows = null;
+ }
+
+ /**
+ * By setting the attribute "dropbox" to "true" or "false" the
+ * dropshadows are added or removed.
+ */
+ setAttribute(aAttr, aVal) {
+ if (aAttr == "dropbox") {
+ let session = cal.dragService.getCurrentSession();
+ if (session) {
+ session.canDrop = true;
+ // no shadows when dragging in the initial position
+ if (aVal == "true" && !this.contains(session.sourceNode)) {
+ this.addDropShadows();
+ } else {
+ this.removeDropShadows();
+ }
+ }
+ }
+ return XULElement.prototype.setAttribute.call(this, aAttr, aVal);
+ }
+
+ onDragStart(event) {
+ let draggedDOMNode = document.monthDragEvent || event.target;
+ if (!draggedDOMNode?.occurrence || !this.contains(draggedDOMNode)) {
+ return;
+ }
+ let item = draggedDOMNode.occurrence.clone();
+ let beginMoveDate = draggedDOMNode.mParentBox.date;
+ let itemStartDate = (item.startDate || item.entryDate || item.dueDate).getInTimezone(
+ this.calendarView.mTimezone
+ );
+ let itemEndDate = (item.endDate || item.dueDate || item.entryDate).getInTimezone(
+ this.calendarView.mTimezone
+ );
+ let oneMoreDay = itemEndDate.hour > 0 || itemEndDate.minute > 0;
+ itemStartDate.isDate = true;
+ itemEndDate.isDate = true;
+ let offsetDuration = itemStartDate.subtractDate(beginMoveDate);
+ let lenDuration = itemEndDate.subtractDate(itemStartDate);
+ let len = lenDuration.weeks * 7 + lenDuration.days;
+
+ this.calendarView.mShadowOffset = offsetDuration;
+ this.calendarView.mDropShadowsLength = oneMoreDay ? len + 1 : len;
+ }
+
+ onDragOver(event) {
+ let session = cal.dragService.getCurrentSession();
+ if (!session?.sourceNode?.sourceObject) {
+ // No source item? Then this is not for us.
+ return;
+ }
+
+ // We handled the event.
+ event.preventDefault();
+ }
+
+ onDragEnter(event) {
+ let session = cal.dragService.getCurrentSession();
+ if (!session?.sourceNode?.sourceObject) {
+ // No source item? Then this is not for us.
+ return;
+ }
+
+ // We can drop now, tell the drag service.
+ event.preventDefault();
+
+ if (!this.hasAttribute("dropbox") || this.getAttribute("dropbox") == "false") {
+ // As it turned out it was not possible to remove the remaining dropshadows
+ // at the "dragleave" event, majorly because it was not reliably
+ // fired.
+ // So we have to remove them at the currentView(). The restriction of course is
+ // that these containers so far may not be used for drag and drop from/to e.g.
+ // the today-pane.
+ currentView().removeDropShadows();
+ }
+ this.setAttribute("dropbox", "true");
+ }
+
+ onDrop(event) {
+ let session = cal.dragService.getCurrentSession();
+ let item = session?.sourceNode?.sourceObject;
+ if (!item) {
+ // No source node? Not our drag.
+ return;
+ }
+ this.setAttribute("dropbox", "false");
+ let newItem = this.onDropItem(item).clone();
+ let newStart = newItem.startDate || newItem.entryDate || newItem.dueDate;
+ let newEnd = newItem.endDate || newItem.dueDate || newItem.entryDate;
+ let offset = this.calendarView.mShadowOffset;
+ newStart.addDuration(offset);
+ newEnd.addDuration(offset);
+ this.calendarView.controller.modifyOccurrence(item, newStart, newEnd);
+
+ // We handled the event.
+ event.stopPropagation();
+ }
+
+ onDragEnd(event) {
+ currentView().removeDropShadows();
+ }
+ }
+
+ MozElements.CalendarDnDContainer = CalendarDnDContainer;
+}
diff --git a/comm/calendar/base/content/widgets/calendar-filter-tree-view.js b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js
new file mode 100644
index 0000000000..8c2804baf0
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-filter-tree-view.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal, getEventStatusString, CalendarFilteredViewMixin, PROTO_TREE_VIEW */
+
+class CalendarFilteredTreeView extends CalendarFilteredViewMixin(PROTO_TREE_VIEW) {
+ /**
+ * A function to, given a calendar item, determine whether it matches some
+ * condition, and should therefore be displayed.
+ *
+ * @callback filterFunction
+ * @param {calIItemBase} item The item to compute filter for
+ * @returns {boolean} Whether the item matches the filter
+ */
+
+ #collator = new Intl.Collator(undefined, { numeric: true });
+ #sortColumn = "startDate";
+ #sortDirection = "ascending";
+
+ /** @type {filterFunction?} */
+ #filterFunction = null;
+
+ /** @type {CalendarFilteredTreeViewRow[]} */
+ #allRows = [];
+
+ /**
+ * Set the function used to filter displayed rows and update the current view.
+ *
+ * @param {filterFunction} filterFunction The function to use as a filter
+ */
+ setFilterFunction(filterFunction) {
+ this.#filterFunction = filterFunction;
+
+ this._tree?.beginUpdateBatch();
+
+ if (this.#filterFunction) {
+ this._rowMap = this.#allRows.filter(row => this.#filterFunction(row.item));
+ } else {
+ // With no filter function, all rows should be displayed.
+ this._rowMap = Array.from(this.#allRows);
+ }
+
+ this._tree?.endUpdateBatch();
+
+ // Ensure that no items remain selected after filter change.
+ this.selection.clearSelection();
+ }
+
+ /**
+ * Clear the filter on the current view.
+ */
+ clearFilter() {
+ this.setFilterFunction(null);
+ }
+
+ /**
+ * Given a calendar item, determine whether it matches the current filter.
+ *
+ * @param {calIItemBase} item The item to compute filter for
+ * @returns {boolean} Whether the item matches the filter, or true if filter
+ * is unset
+ */
+ #itemMatchesFilterIfAny(item) {
+ return !this.#filterFunction || this.#filterFunction(item);
+ }
+
+ /**
+ * Save currently selected rows so that they can be restored after
+ * modifications to the tree.
+ */
+ #saveSelection() {
+ const selection = this.selection;
+ if (selection) {
+ // Mark rows which are selected.
+ for (let i = 0; i < this._rowMap.length; i++) {
+ this._rowMap[i].wasSelected = selection.isSelected(i);
+ this._rowMap[i].wasCurrent = selection.currentIndex == i;
+ }
+ }
+ }
+
+ /**
+ * Reselect rows which were selected before modifications were made to the
+ * tree.
+ */
+ #restoreSelection() {
+ const selection = this.selection;
+ if (selection) {
+ selection.selectEventsSuppressed = true;
+
+ let newCurrent;
+ for (let i = 0; i < this._rowMap.length; i++) {
+ if (this._rowMap[i].wasSelected != selection.isSelected(i)) {
+ selection.toggleSelect(i);
+ }
+
+ if (this._rowMap[i].wasCurrent) {
+ newCurrent = i;
+ }
+ }
+
+ selection.currentIndex = newCurrent;
+
+ this.selectionChanged();
+ selection.selectEventsSuppressed = false;
+ }
+ }
+
+ // CalendarFilteredViewMixin implementation
+
+ clearItems() {
+ this.#allRows.length = 0;
+
+ this._tree?.beginUpdateBatch();
+ this._rowMap.length = 0;
+ this._tree?.endUpdateBatch();
+ }
+
+ addItems(items) {
+ let anyItemsMatchedFilter = false;
+
+ for (const item of items) {
+ const row = new CalendarFilteredTreeViewRow(item);
+
+ const sortValue = row.getValue(this.#sortColumn);
+
+ let addIndex = null;
+ for (let i = 0; addIndex === null && i < this.#allRows.length; i++) {
+ const comparison = this.#collator.compare(
+ sortValue,
+ this.#allRows[i].getValue(this.#sortColumn)
+ );
+ if (
+ (comparison < 0 && this.#sortDirection == "ascending") ||
+ (comparison >= 0 && this.#sortDirection == "descending")
+ ) {
+ addIndex = i;
+ }
+ }
+
+ if (addIndex === null) {
+ addIndex = this.#allRows.length;
+ }
+ this.#allRows.splice(addIndex, 0, row);
+
+ if (this.#itemMatchesFilterIfAny(item)) {
+ anyItemsMatchedFilter = true;
+ }
+ }
+
+ if (anyItemsMatchedFilter) {
+ this.#saveSelection();
+
+ this._tree?.beginUpdateBatch();
+ this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item));
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+ }
+ }
+
+ removeItems(items) {
+ const hashIDsToRemove = items.map(i => i.hashId);
+ for (let i = this.#allRows.length - 1; i >= 0; i--) {
+ if (hashIDsToRemove.includes(this.#allRows[i].item.hashId)) {
+ this.#allRows.splice(i, 1);
+ }
+ }
+
+ this.#saveSelection();
+
+ this._tree?.beginUpdateBatch();
+ for (let i = this._rowMap.length - 1; i >= 0; i--) {
+ if (hashIDsToRemove.includes(this._rowMap[i].item.hashId)) {
+ this._rowMap.splice(i, 1);
+ }
+ }
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+ }
+
+ removeItemsFromCalendar(calendarId) {
+ const itemsToRemove = this.#allRows
+ .filter(row => row.calendar.id == calendarId)
+ .map(row => row.item);
+ this.removeItems(itemsToRemove);
+ }
+
+ // nsITreeView implementation
+
+ isSorted() {
+ return true;
+ }
+
+ cycleHeader(column) {
+ let direction = "ascending";
+ if (column.id == this.#sortColumn && this.#sortDirection == "ascending") {
+ direction = "descending";
+ }
+
+ this.#sortBy(column.id, direction);
+ }
+
+ #sortBy(sortColumn, sortDirection) {
+ // Sort underlying array of rows first.
+ if (sortColumn == this.#sortColumn) {
+ if (sortDirection == this.#sortDirection) {
+ // Sort order hasn't changed; do nothing.
+ return;
+ }
+
+ this.#allRows.reverse();
+ } else {
+ this.#allRows.sort((a, b) => {
+ const aValue = a.getValue(sortColumn);
+ const bValue = b.getValue(sortColumn);
+
+ if (sortDirection == "descending") {
+ return this.#collator.compare(bValue, aValue);
+ }
+
+ return this.#collator.compare(aValue, bValue);
+ });
+ }
+
+ this.#saveSelection();
+
+ // Refilter displayed rows from newly-sorted underlying array.
+ this._tree?.beginUpdateBatch();
+ this._rowMap = this.#allRows.filter(row => this.#itemMatchesFilterIfAny(row.item));
+ this._tree?.endUpdateBatch();
+
+ this.#restoreSelection();
+
+ this.#sortColumn = sortColumn;
+ this.#sortDirection = sortDirection;
+ }
+}
+
+class CalendarFilteredTreeViewRow {
+ static listFormatter = new Services.intl.ListFormat(
+ Services.appinfo.name == "xpcshell" ? "en-US" : Services.locale.appLocalesAsBCP47,
+ { type: "unit" }
+ );
+
+ #columnTextCache = {};
+ #columnValueCache = {};
+ #item = null;
+ #calendar = null;
+ wasSelected = false;
+ wasCurrent = false;
+
+ constructor(item) {
+ this.#item = item;
+ this.#calendar = item.calendar;
+ }
+
+ #getTextByColumnID(columnID) {
+ switch (columnID) {
+ case "calendarName":
+ case "unifinder-search-results-tree-col-calendarname":
+ return this.#calendar.name;
+ case "categories":
+ case "unifinder-search-results-tree-col-categories":
+ return CalendarFilteredTreeViewRow.listFormatter.format(this.#item.getCategories());
+ case "color":
+ case "unifinder-search-results-tree-col-color":
+ return cal.view.formatStringForCSSRule(this.#calendar.id);
+ case "endDate":
+ case "unifinder-search-results-tree-col-enddate": {
+ const endDate = this.#item.endDate.getInTimezone(cal.dtz.defaultTimezone);
+ if (endDate.isDate) {
+ endDate.day--;
+ }
+
+ return cal.dtz.formatter.formatDateTime(endDate);
+ }
+ case "location":
+ case "unifinder-search-results-tree-col-location":
+ return this.#item.getProperty("LOCATION");
+ case "startDate":
+ case "unifinder-search-results-tree-col-startdate":
+ return cal.dtz.formatter.formatDateTime(
+ this.#item.startDate.getInTimezone(cal.dtz.defaultTimezone)
+ );
+ case "status":
+ case "unifinder-search-results-tree-col-status":
+ return getEventStatusString(this.#item);
+ case "title":
+ case "unifinder-search-results-tree-col-title":
+ return this.#item.title?.replace(/\n/g, " ") || "";
+ }
+
+ return "";
+ }
+
+ getText(columnID) {
+ if (!(columnID in this.#columnTextCache)) {
+ this.#columnTextCache[columnID] = this.#getTextByColumnID(columnID);
+ }
+
+ return this.#columnTextCache[columnID];
+ }
+
+ #getValueByColumnID(columnID) {
+ switch (columnID) {
+ case "startDate":
+ case "unifinder-search-results-tree-col-startdate":
+ return this.#item.startDate.icalString;
+ case "endDate":
+ case "unifinder-search-results-tree-col-enddate":
+ return this.#item.endDate.icalString;
+ }
+
+ return this.getText(columnID);
+ }
+
+ getValue(columnID) {
+ if (!(columnID in this.#columnValueCache)) {
+ this.#columnValueCache[columnID] = this.#getValueByColumnID(columnID);
+ }
+
+ return this.#columnValueCache[columnID];
+ }
+
+ getProperties() {
+ let properties = [];
+ if (this.#item.priority > 0 && this.#item.priority < 5) {
+ properties.push("highpriority");
+ } else if (this.#item.priority > 5 && this.#item.priority < 10) {
+ properties.push("lowpriority");
+ }
+
+ properties.push("calendar-" + cal.view.formatStringForCSSRule(this.#calendar.name));
+
+ if (this.#item.status) {
+ properties.push("status-" + this.#item.status.toLowerCase());
+ }
+
+ if (this.#item.getAlarms().length) {
+ properties.push("alarm");
+ }
+
+ properties = properties.concat(this.#item.getCategories().map(cal.view.formatStringForCSSRule));
+ return properties.join(" ");
+ }
+
+ /** @type {calIItemBase} */
+ get item() {
+ return this.#item;
+ }
+
+ /** @type {calICalendar} */
+ get calendar() {
+ return this.#calendar;
+ }
+
+ get open() {
+ return false;
+ }
+
+ get level() {
+ return 0;
+ }
+
+ get children() {
+ return [];
+ }
+}
diff --git a/comm/calendar/base/content/widgets/calendar-filter.js b/comm/calendar/base/content/widgets/calendar-filter.js
new file mode 100644
index 0000000000..d49ea0fe76
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-filter.js
@@ -0,0 +1,1365 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../calendar-views-utils.js */
+
+/* exported CalendarFilteredViewMixin */
+
+var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Object that contains a set of filter properties that may be used by a calFilter object
+ * to filter a set of items.
+ * Supported filter properties:
+ * start, end: Specifies the relative date range to use when calculating the filter date
+ * range. The relative date range may relative to the current date and time, the
+ * currently selected date, or the dates range of the current view. The actual
+ * date range used to filter items will be calculated by the calFilter object
+ * by using the updateFilterDates function, which may be called multiple times
+ * to reflect changes in the current date and time, and changes to the view.
+ *
+ *
+ * The properties may be set to one of the following values:
+ * - FILTER_DATE_ALL: An unbound date range.
+ * - FILTER_DATE_XXX: One of the defined relative date ranges.
+ * - A string that may be converted to a calIDuration object that will be used
+ * as an offset to the current date and time.
+ *
+ * The start and end properties may have values representing different relative
+ * date ranges, in which case the filter start date will be calculated as the start
+ * of the relative range specified by the start property, while the filter end date
+ * will be calculated as the end of the relative range specified by the end
+ * property.
+ *
+ * due: Specifies the filter property for the due date of tasks. This filter has no
+ * effect when filtering events.
+ *
+ * The property has a bit field value, with the FILTER_DUE_XXX bit flags set
+ * to indicate that tasks with the corresponding due property value should match
+ * the filter.
+ *
+ * If the value is set to null the due date will not be considered when filtering.
+ *
+ * status: Specifies the filter property for the status of tasks. This filter has no
+ * effect when filtering events.
+ *
+ * The property has a bit field value, with the FILTER_STATUS_XXX bit flags set
+ * to indicate that tasks with the corresponding status property value should match
+ * the filter.
+ *
+ * If the value is set to null the status will not be considered when filtering.
+ *
+ * category: Specifies the filter property for the item category.
+ *
+ * The property may be set to one of the following values:
+ * - null: The item category will not be considered when filtering.
+ * - A string: The item will match the filter if any of it's categories match the
+ * category specified by the property.
+ * - An array: The item will match the filter if any of it's categories match any
+ * of the categories contained in the Array specified by the property.
+ *
+ * occurrences: Specifies the filter property for returning occurrences of repeating items.
+ *
+ * The property may be set to one of the following values:
+ * - null, FILTER_OCCURRENCES_BOUND: The default occurrence handling. Occurrences
+ * will be returned only for date ranges with a bound end date.
+ * - FILTER_OCCURRENCES_NONE: Only the parent items will be returned.
+ * - FILTER_OCCURRENCES_PAST_AND_NEXT: Returns past occurrences and the next future
+ * matching occurrence if one is found.
+ *
+ * onfilter: A callback function that may be used to apply additional custom filter
+ * constraints. If specified, the callback function will be called after any other
+ * specified filter properties are tested.
+ *
+ * The callback function will be called with the following parameters:
+ * - function(aItem, aResults, aFilterProperties, aFilter)
+ *
+ * @param aItem The item being tested.
+ * @param aResults The results of the test of the other specified
+ * filter properties.
+ * @param aFilterProperties The current filter properties being tested.
+ * @param aFilter The calFilter object performing the filter test.
+ *
+ * If specified, the callback function is responsible for returning a value that
+ * can be converted to true if the item should match the filter, or a value that
+ * can be converted to false otherwise. The return value will override the results
+ * of the testing of any other specified filter properties.
+ */
+function calFilterProperties() {
+ this.wrappedJSObject = this;
+}
+
+calFilterProperties.prototype = {
+ FILTER_DATE_ALL: 0,
+ FILTER_DATE_VIEW: 1,
+ FILTER_DATE_SELECTED: 2,
+ FILTER_DATE_SELECTED_OR_NOW: 3,
+ FILTER_DATE_NOW: 4,
+ FILTER_DATE_TODAY: 5,
+ FILTER_DATE_CURRENT_WEEK: 6,
+ FILTER_DATE_CURRENT_MONTH: 7,
+ FILTER_DATE_CURRENT_YEAR: 8,
+
+ FILTER_STATUS_INCOMPLETE: 1,
+ FILTER_STATUS_IN_PROGRESS: 2,
+ FILTER_STATUS_COMPLETED_TODAY: 4,
+ FILTER_STATUS_COMPLETED_BEFORE: 8,
+ FILTER_STATUS_ALL: 15,
+
+ FILTER_DUE_PAST: 1,
+ FILTER_DUE_TODAY: 2,
+ FILTER_DUE_FUTURE: 4,
+ FILTER_DUE_NONE: 8,
+ FILTER_DUE_ALL: 15,
+
+ FILTER_OCCURRENCES_BOUND: 0,
+ FILTER_OCCURRENCES_NONE: 1,
+ FILTER_OCCURRENCES_PAST_AND_NEXT: 2,
+
+ start: null,
+ end: null,
+ due: null,
+ status: null,
+ category: null,
+ occurrences: null,
+
+ onfilter: null,
+
+ equals(aFilterProps) {
+ if (!(aFilterProps instanceof calFilterProperties)) {
+ return false;
+ }
+ let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"];
+ return props.every(function (prop) {
+ return this[prop] == aFilterProps[prop];
+ }, this);
+ },
+
+ clone() {
+ let cloned = new calFilterProperties();
+ let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"];
+ props.forEach(function (prop) {
+ cloned[prop] = this[prop];
+ }, this);
+
+ return cloned;
+ },
+
+ LOG(aString) {
+ cal.LOG(
+ "[calFilterProperties] " +
+ (aString || "") +
+ " start=" +
+ this.start +
+ " end=" +
+ this.end +
+ " status=" +
+ this.status +
+ " due=" +
+ this.due +
+ " category=" +
+ this.category
+ );
+ },
+};
+
+/**
+ * Object that allows filtering of a set of items using a set of filter properties. A set
+ * of property filters may be defined by a filter name, which may then be used to apply
+ * the defined filter properties. A set of commonly used property filters are predefined.
+ */
+function calFilter() {
+ this.wrappedJSObject = this;
+ this.mFilterProperties = new calFilterProperties();
+ this.initDefinedFilters();
+ this.mMaxIterations = Services.prefs.getIntPref("calendar.filter.maxiterations", 50);
+}
+
+calFilter.prototype = {
+ mStartDate: null,
+ mEndDate: null,
+ mItemType: Ci.calICalendar.ITEM_FILTER_TYPE_ALL,
+ mSelectedDate: null,
+ mFilterText: "",
+ mDefinedFilters: {},
+ mFilterProperties: null,
+ mToday: null,
+ mTomorrow: null,
+ mMaxIterations: 50,
+
+ /**
+ * Initializes the predefined filters.
+ */
+ initDefinedFilters() {
+ let filters = [
+ "all",
+ "notstarted",
+ "overdue",
+ "open",
+ "completed",
+ "throughcurrent",
+ "throughtoday",
+ "throughsevendays",
+ "today",
+ "thisCalendarMonth",
+ "future",
+ "current",
+ "currentview",
+ ];
+ filters.forEach(function (filter) {
+ if (!(filter in this.mDefinedFilters)) {
+ this.defineFilter(filter, this.getPreDefinedFilterProperties(filter));
+ }
+ }, this);
+ },
+
+ /**
+ * Gets the filter properties for a predefined filter.
+ *
+ * @param aFilter The name of the filter to retrieve the filter properties for.
+ * @result The filter properties for the specified filter, or null if the filter
+ * not predefined.
+ */
+ getPreDefinedFilterProperties(aFilter) {
+ let props = new calFilterProperties();
+
+ if (!aFilter) {
+ return props;
+ }
+
+ switch (aFilter) {
+ // Predefined Task filters
+ case "notstarted":
+ props.status = props.FILTER_STATUS_INCOMPLETE;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "overdue":
+ props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS;
+ props.due = props.FILTER_DUE_PAST;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "open":
+ props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_ALL;
+ props.occurrences = props.FILTER_OCCURRENCES_PAST_AND_NEXT;
+ break;
+ case "completed":
+ props.status = props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "throughcurrent":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_SELECTED_OR_NOW;
+ break;
+ case "throughtoday":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_TODAY;
+ break;
+ case "throughsevendays":
+ props.status =
+ props.FILTER_STATUS_INCOMPLETE |
+ props.FILTER_STATUS_IN_PROGRESS |
+ props.FILTER_STATUS_COMPLETED_TODAY;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = "P7D";
+ break;
+
+ // Predefined Event filters
+ case "today":
+ props.start = props.FILTER_DATE_TODAY;
+ props.end = props.FILTER_DATE_TODAY;
+ break;
+ case "thisCalendarMonth":
+ props.start = props.FILTER_DATE_CURRENT_MONTH;
+ props.end = props.FILTER_DATE_CURRENT_MONTH;
+ break;
+ case "future":
+ props.start = props.FILTER_DATE_NOW;
+ props.end = props.FILTER_DATE_ALL;
+ break;
+ case "current":
+ props.start = props.FILTER_DATE_SELECTED;
+ props.end = props.FILTER_DATE_SELECTED;
+ break;
+ case "currentview":
+ props.start = props.FILTER_DATE_VIEW;
+ props.end = props.FILTER_DATE_VIEW;
+ break;
+
+ case "all":
+ default:
+ props.status = props.FILTER_STATUS_ALL;
+ props.due = props.FILTER_DUE_ALL;
+ props.start = props.FILTER_DATE_ALL;
+ props.end = props.FILTER_DATE_ALL;
+ }
+
+ return props;
+ },
+
+ /**
+ * Defines a set of filter properties so that they may be applied by the filter name. If
+ * the specified filter name is already defined, it's associated filter properties will be
+ * replaced.
+ *
+ * @param aFilterName The name to define the filter properties as.
+ * @param aFilterProperties The filter properties to define.
+ */
+ defineFilter(aFilterName, aFilterProperties) {
+ if (!(aFilterProperties instanceof calFilterProperties)) {
+ return;
+ }
+
+ this.mDefinedFilters[aFilterName] = aFilterProperties;
+ },
+
+ /**
+ * Returns the set of filter properties that were previously defined by a filter name.
+ *
+ * @param aFilter The filter name of the defined filter properties.
+ * @returns The properties defined by the filter name, or null if
+ * the filter name was not previously defined.
+ */
+ getDefinedFilterProperties(aFilter) {
+ if (aFilter in this.mDefinedFilters) {
+ return this.mDefinedFilters[aFilter].clone();
+ }
+ return null;
+ },
+
+ /**
+ * Returns the filter name that a set of filter properties were previously defined as.
+ *
+ * @param aFilterProperties The filter properties previously defined.
+ * @returns The name of the first filter name that the properties
+ * were defined as, or null if the filter properties were
+ * not previously defined.
+ */
+ getDefinedFilterName(aFilterProperties) {
+ for (let filter in this.mDefinedFilters) {
+ if (this.mDefinedFilters[filter].equals(aFilterProperties)) {
+ return filter;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Checks if the item matches the current filter text
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filter text or no
+ * filter text has been set, false otherwise.
+ */
+ textFilter(aItem) {
+ if (!this.mFilterText) {
+ return true;
+ }
+
+ let searchText = this.mFilterText.toLowerCase();
+
+ if (!searchText.length || searchText.match(/^\s*$/)) {
+ return true;
+ }
+
+ // TODO: Support specifying which fields to search on
+ for (let field of ["SUMMARY", "DESCRIPTION", "LOCATION", "URL"]) {
+ let val = aItem.getProperty(field);
+ if (val && val.toLowerCase().includes(searchText)) {
+ return true;
+ }
+ }
+
+ return aItem.getCategories().some(cat => cat.toLowerCase().includes(searchText));
+ },
+
+ /**
+ * Checks if the item matches the current filter date range.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item falls within the date range
+ * specified by mStartDate and mEndDate, false otherwise.
+ */
+ dateRangeFilter(aItem) {
+ return !!cal.item.checkIfInRange(aItem, this.mStartDate, this.mEndDate);
+ },
+
+ /**
+ * Checks if the item matches the currently applied filter properties. Filter properties
+ * with a value of null or that are not applicable to the item's type are not tested.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filter properties
+ * currently applied, false otherwise.
+ */
+ propertyFilter(aItem) {
+ let result;
+ let props = this.mFilterProperties;
+ if (!props) {
+ return false;
+ }
+
+ // the today and tomorrow properties are precalculated in the updateFilterDates function
+ // for better performance when filtering batches of items.
+ let today = this.mToday;
+ if (!today) {
+ today = cal.dtz.now();
+ today.isDate = true;
+ }
+
+ let tomorrow = this.mTomorrow;
+ if (!tomorrow) {
+ tomorrow = today.clone();
+ tomorrow.day++;
+ }
+
+ // test the date range of the applied filter.
+ result = this.dateRangeFilter(aItem);
+
+ // test the category property. If the property value is an array, only one category must
+ // match.
+ if (result && props.category) {
+ let cats = [];
+
+ if (typeof props.category == "string") {
+ cats.push(props.category);
+ } else if (Array.isArray(props.category)) {
+ cats = props.category;
+ }
+ result = cats.some(cat => aItem.getCategories().includes(cat));
+ }
+
+ // test the status property. Only applies to tasks.
+ if (result && props.status != null && aItem.isTodo()) {
+ let completed = aItem.isCompleted;
+ let current = !aItem.completedDate || today.compare(aItem.completedDate) <= 0;
+ let percent = aItem.percentComplete || 0;
+
+ result =
+ (props.status & props.FILTER_STATUS_INCOMPLETE || !(!completed && percent == 0)) &&
+ (props.status & props.FILTER_STATUS_IN_PROGRESS || !(!completed && percent > 0)) &&
+ (props.status & props.FILTER_STATUS_COMPLETED_TODAY || !(completed && current)) &&
+ (props.status & props.FILTER_STATUS_COMPLETED_BEFORE || !(completed && !current));
+ }
+
+ // test the due property. Only applies to tasks.
+ if (result && props.due != null && aItem.isTodo()) {
+ let due = aItem.dueDate;
+ let now = cal.dtz.now();
+
+ result =
+ (props.due & props.FILTER_DUE_PAST || !(due && due.compare(now) < 0)) &&
+ (props.due & props.FILTER_DUE_TODAY ||
+ !(due && due.compare(now) >= 0 && due.compare(tomorrow) < 0)) &&
+ (props.due & props.FILTER_DUE_FUTURE || !(due && due.compare(tomorrow) >= 0)) &&
+ (props.due & props.FILTER_DUE_NONE || !(due == null));
+ }
+
+ // Call the filter properties onfilter callback if set. The return value of the
+ // callback function will override the result of this function.
+ if (props.onfilter && typeof props.onfilter == "function") {
+ return props.onfilter(aItem, result, props, this);
+ }
+
+ return result;
+ },
+
+ /**
+ * Checks if the item matches the expected item type.
+ *
+ * @param {calIItemBase} aItem - The item to check.
+ * @returns {boolean} - True if the item matches the item type, false otherwise.
+ */
+ itemTypeFilter(aItem) {
+ if (aItem.isTodo() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_TODO) {
+ // If `mItemType` doesn't specify a completion status, the item passes.
+ if ((this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0) {
+ return true;
+ }
+
+ // Otherwise, check it matches the completion status(es).
+ if (aItem.isCompleted) {
+ return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_YES) != 0;
+ }
+ return (this.mItemType & Ci.calICalendar.ITEM_FILTER_COMPLETED_NO) != 0;
+ }
+ if (aItem.isEvent() && this.mItemType & Ci.calICalendar.ITEM_FILTER_TYPE_EVENT) {
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Calculates the date from a date filter property.
+ *
+ * @param prop The value of the date filter property to calculate for. May
+ * be a constant specifying a relative date range, or a string
+ * representing a duration offset from the current date time.
+ * @param start If true, the function will return the date value for the
+ * start of the relative date range, otherwise it will return the
+ * date value for the end of the date range.
+ * @returns The calculated date for the property.
+ */
+ getDateForProperty(prop, start) {
+ let props = this.mFilterProperties || new calFilterProperties();
+ let result = null;
+ let selectedDate = this.mSelectedDate || currentView().selectedDay || cal.dtz.now();
+ let nowDate = cal.dtz.now();
+
+ if (typeof prop == "string") {
+ let duration = cal.createDuration(prop);
+ if (duration) {
+ result = nowDate;
+ result.addDuration(duration);
+ }
+ } else {
+ switch (prop) {
+ case props.FILTER_DATE_ALL:
+ result = null;
+ break;
+ case props.FILTER_DATE_VIEW:
+ result = start ? currentView().startDay.clone() : currentView().endDay.clone();
+ break;
+ case props.FILTER_DATE_SELECTED:
+ result = selectedDate.clone();
+ result.isDate = true;
+ break;
+ case props.FILTER_DATE_SELECTED_OR_NOW: {
+ result = selectedDate.clone();
+ let resultJSDate = cal.dtz.dateTimeToJsDate(result);
+ let nowJSDate = cal.dtz.dateTimeToJsDate(nowDate);
+ if ((start && resultJSDate > nowJSDate) || (!start && resultJSDate < nowJSDate)) {
+ result = nowDate;
+ }
+ result.isDate = true;
+ break;
+ }
+ case props.FILTER_DATE_NOW:
+ result = nowDate;
+ break;
+ case props.FILTER_DATE_TODAY:
+ result = nowDate;
+ result.isDate = true;
+ break;
+ case props.FILTER_DATE_CURRENT_WEEK:
+ result = start ? nowDate.startOfWeek : nowDate.endOfWeek;
+ break;
+ case props.FILTER_DATE_CURRENT_MONTH:
+ result = start ? nowDate.startOfMonth : nowDate.endOfMonth;
+ break;
+ case props.FILTER_DATE_CURRENT_YEAR:
+ result = start ? nowDate.startOfYear : nowDate.endOfYear;
+ break;
+ }
+
+ // date ranges are inclusive, so we need to include the day for the end date
+ if (!start && result && prop != props.FILTER_DATE_NOW) {
+ result.day++;
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Calculates the current start and end dates for the currently applied filter.
+ *
+ * @returns The current [startDate, endDate] for the applied filter.
+ */
+ getDatesForFilter() {
+ let startDate = null;
+ let endDate = null;
+
+ if (this.mFilterProperties) {
+ startDate = this.getDateForProperty(this.mFilterProperties.start, true);
+ endDate = this.getDateForProperty(this.mFilterProperties.end, false);
+
+ // swap the start and end dates if necessary
+ if (startDate && endDate && startDate.compare(endDate) > 0) {
+ let swap = startDate;
+ endDate = startDate;
+ startDate = swap;
+ }
+ }
+
+ return [startDate, endDate];
+ },
+
+ /**
+ * Gets the start date for the current filter date range.
+ *
+ * @return: The start date of the current filter date range, or null if
+ * the date range has an unbound start date.
+ */
+ get startDate() {
+ return this.mStartDate;
+ },
+
+ /**
+ * Sets the start date for the current filter date range. This will override the date range
+ * calculated from the filter properties by the getDatesForFilter function.
+ */
+ set startDate(aStartDate) {
+ this.mStartDate = aStartDate;
+ },
+
+ /**
+ * Gets the end date for the current filter date range.
+ *
+ * @return: The end date of the current filter date range, or null if
+ * the date range has an unbound end date.
+ */
+ get endDate() {
+ return this.mEndDate;
+ },
+
+ /**
+ * Sets the end date for the current filter date range. This will override the date range
+ * calculated from the filter properties by the getDatesForFilter function.
+ */
+ set endDate(aEndDate) {
+ this.mEndDate = aEndDate;
+ },
+
+ /**
+ * Gets the current item type filter.
+ */
+ get itemType() {
+ return this.mItemType;
+ },
+
+ /**
+ * One of the calICalendar.ITEM_FILTER_TYPE constants, optionally bitwise-OR-ed with a
+ * calICalendar.ITEM_FILTER_COMPLETED value. Only items of this type will pass the filter.
+ *
+ * If an ITEM_FILTER_COMPLETED bit is set it will will take priority over applyFilter.
+ */
+ set itemType(aItemType) {
+ this.mItemType = aItemType;
+ },
+
+ /**
+ * Gets the value used to perform the text filter.
+ */
+ get filterText() {
+ return this.mFilterText;
+ },
+
+ /**
+ * Sets the value used to perform the text filter.
+ *
+ * @param aValue The string value to use for the text filter.
+ */
+ set filterText(aValue) {
+ this.mFilterText = aValue;
+ },
+
+ /**
+ * Gets the selected date used by the getDatesForFilter function to calculate date ranges
+ * that are relative to the selected date.
+ */
+ get selectedDate() {
+ return this.mSelectedDate;
+ },
+
+ /**
+ * Sets the selected date used by the getDatesForFilter function to calculate date ranges
+ * that are relative to the selected date.
+ */
+ set selectedDate(aSelectedDate) {
+ this.mSelectedDate = aSelectedDate;
+ },
+
+ /**
+ * Gets the currently applied filter properties.
+ *
+ * @returns The currently applied filter properties.
+ */
+ get filterProperties() {
+ return this.mFilterProperties ? this.mFilterProperties.clone() : null;
+ },
+
+ /**
+ * Gets the name of the currently applied filter.
+ *
+ * @returns The current defined name of the currently applied filter
+ * properties, or null if the current properties were not
+ * previously defined.
+ */
+ get filterName() {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+
+ return this.getDefinedFilterName(this.mFilterProperties);
+ },
+
+ /**
+ * Applies the specified filter.
+ *
+ * @param aFilter The filter to apply. May be one of the following types:
+ * - a calFilterProperties object specifying the filter properties
+ * - a String representing a previously defined filter name
+ * - a String representing a duration offset from now
+ * - a Function to use for the onfilter callback for a custom filter
+ */
+ applyFilter(aFilter) {
+ this.mFilterProperties = null;
+
+ if (typeof aFilter == "string") {
+ if (aFilter in this.mDefinedFilters) {
+ this.mFilterProperties = this.getDefinedFilterProperties(aFilter);
+ } else {
+ let dur = cal.createDuration(aFilter);
+ if (dur.inSeconds > 0) {
+ this.mFilterProperties = new calFilterProperties();
+ this.mFilterProperties.start = this.mFilterProperties.FILTER_DATE_NOW;
+ this.mFilterProperties.end = aFilter;
+ }
+ }
+ } else if (typeof aFilter == "object" && aFilter instanceof calFilterProperties) {
+ this.mFilterProperties = aFilter;
+ } else if (typeof aFilter == "function") {
+ this.mFilterProperties = new calFilterProperties();
+ this.mFilterProperties.onfilter = aFilter;
+ } else {
+ this.mFilterProperties = new calFilterProperties();
+ }
+
+ if (this.mFilterProperties) {
+ this.updateFilterDates();
+ // this.mFilterProperties.LOG("Applying filter:");
+ } else {
+ cal.WARN("[calFilter] Unable to apply filter " + aFilter);
+ }
+ },
+
+ /**
+ * Calculates the current start and end dates for the currently applied filter, and updates
+ * the current filter start and end dates. This function can be used to update the date range
+ * for date range filters that are relative to the selected date or current date and time.
+ *
+ * @returns The current [startDate, endDate] for the applied filter.
+ */
+ updateFilterDates() {
+ let [startDate, endDate] = this.getDatesForFilter();
+ this.mStartDate = startDate;
+ this.mEndDate = endDate;
+
+ // the today and tomorrow properties are precalculated here
+ // for better performance when filtering batches of items.
+ this.mToday = cal.dtz.now();
+ this.mToday.isDate = true;
+
+ this.mTomorrow = this.mToday.clone();
+ this.mTomorrow.day++;
+
+ return [startDate, endDate];
+ },
+
+ /**
+ * Filters an array of items, returning a new array containing the items that match
+ * the currently applied filter properties and text filter.
+ *
+ * @param aItems The array of items to check.
+ * @param aCallback An optional callback function to be called with each item and
+ * the result of it's filter test.
+ * @returns A new array containing the items that match the filters, or
+ * null if no filter has been applied.
+ */
+ filterItems(aItems, aCallback) {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+
+ return aItems.filter(function (aItem) {
+ let result = this.isItemInFilters(aItem);
+
+ if (aCallback && typeof aCallback == "function") {
+ aCallback(aItem, result, this.mFilterProperties, this);
+ }
+
+ return result;
+ }, this);
+ },
+
+ /**
+ * Checks if the item matches the currently applied filter properties and text filter.
+ *
+ * @param aItem The item to check.
+ * @returns Returns true if the item matches the filters,
+ * false otherwise.
+ */
+ isItemInFilters(aItem) {
+ return this.itemTypeFilter(aItem) && this.propertyFilter(aItem) && this.textFilter(aItem);
+ },
+
+ /**
+ * Finds the next occurrence of a repeating item that matches the currently applied
+ * filter properties.
+ *
+ * @param aItem The parent item to find the next occurrence of.
+ * @returns Returns the next occurrence that matches the filters,
+ * or null if no match is found.
+ */
+ getNextOccurrence(aItem) {
+ if (!aItem.recurrenceInfo) {
+ return this.isItemInFilters(aItem) ? aItem : null;
+ }
+
+ let count = 0;
+ let start = cal.dtz.now();
+
+ // If the base item matches the filter, we need to check each future occurrence.
+ // Otherwise, we only need to check the exceptions.
+ if (this.isItemInFilters(aItem)) {
+ while (count++ < this.mMaxIterations) {
+ let next = aItem.recurrenceInfo.getNextOccurrence(start);
+ if (!next) {
+ // there are no more occurrences
+ return null;
+ }
+
+ if (this.isItemInFilters(next)) {
+ return next;
+ }
+ start = next.startDate || next.entryDate;
+ }
+
+ // we've hit the maximum number of iterations without finding a match
+ cal.WARN("[calFilter] getNextOccurrence: reached maximum iterations for " + aItem.title);
+ return null;
+ }
+ // the parent item doesn't match the filter, we can return the first future exception
+ // that matches the filter
+ let exMatch = null;
+ aItem.recurrenceInfo.getExceptionIds().forEach(function (rID) {
+ let ex = aItem.recurrenceInfo.getExceptionFor(rID);
+ if (
+ ex &&
+ cal.dtz.now().compare(ex.startDate || ex.entryDate) < 0 &&
+ this.isItemInFilters(ex)
+ ) {
+ exMatch = ex;
+ }
+ }, this);
+ return exMatch;
+ },
+
+ /**
+ * Gets the occurrences of a repeating item that match the currently applied
+ * filter properties and date range.
+ *
+ * @param aItem The parent item to find occurrence of.
+ * @returns Returns an array containing the occurrences that
+ * match the filters, an empty array if there are no
+ * matches, or null if the filter is not initialized.
+ */
+ getOccurrences(aItem) {
+ if (!this.mFilterProperties) {
+ return null;
+ }
+ let props = this.mFilterProperties;
+ let occs;
+
+ if (
+ !aItem.recurrenceInfo ||
+ (!props.occurrences && !this.mEndDate) ||
+ props.occurrences == props.FILTER_OCCURRENCES_NONE
+ ) {
+ // either this isn't a repeating item, the occurrence filter specifies that
+ // we don't want occurrences, or we have a default occurrence filter with an
+ // unbound date range, so we return just the unexpanded item.
+ occs = [aItem];
+ } else {
+ occs = aItem.getOccurrencesBetween(
+ this.mStartDate || cal.createDateTime(),
+ this.mEndDate || cal.dtz.now()
+ );
+ if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT && !this.mEndDate) {
+ // we have an unbound date range and the occurrence filter specifies
+ // that we also want the next matching occurrence if available.
+ let next = this.getNextOccurrence(aItem);
+ if (next) {
+ occs.push(next);
+ }
+ }
+ }
+
+ return this.filterItems(occs);
+ },
+
+ /**
+ * Gets the items matching the currently applied filter properties from a calendar.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get items from.
+ * @returns {ReadableStream<calIItemBase>} A stream of returned values.
+ */
+ getItems(aCalendar) {
+ if (!this.mFilterProperties) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ let props = this.mFilterProperties;
+
+ // Build the filter argument for calICalendar.getItems() from the filter properties.
+ let filter = this.mItemType;
+
+ // For tasks, if `mItemType` doesn't specify a completion status, add one.
+ if (
+ filter & Ci.calICalendar.ITEM_FILTER_TYPE_TODO &&
+ (filter & Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL) == 0
+ ) {
+ if (
+ !props.status ||
+ props.status & (props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE)
+ ) {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ }
+ if (
+ !props.status ||
+ props.status & (props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS)
+ ) {
+ filter |= Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ }
+ }
+
+ if (!filter) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ let startDate = this.startDate;
+ let endDate = this.endDate;
+
+ // We only want occurrences returned from calICalendar.getItems() with a default
+ // occurrence filter property and a bound date range, otherwise the local listener
+ // will handle occurrence expansion.
+ if (!props.occurrences && this.endDate) {
+ filter |= Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ startDate = startDate || cal.createDateTime();
+ endDate = endDate || cal.dtz.now();
+ }
+
+ // We use a local ReadableStream for the calICalendar.getItems() call, and use it
+ // to handle occurrence expansion and filter the results before forwarding them
+ // upstream.
+ return CalReadableStreamFactory.createMappedReadableStream(
+ aCalendar.getItems(filter, 0, startDate, endDate),
+ chunk => {
+ let items;
+ if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT) {
+ // with the FILTER_OCCURRENCES_PAST_AND_NEXT occurrence filter we will
+ // get parent items returned here, so we need to let the getOccurrences
+ // function handle occurrence expansion.
+ items = [];
+ for (let item of chunk) {
+ items = items.concat(this.getOccurrences(item));
+ }
+ } else {
+ // with other occurrence filters the calICalendar.getItems() function will
+ // return expanded occurrences appropriately, we only need to filter them.
+ items = this.filterItems(chunk);
+ }
+ return items;
+ }
+ );
+ },
+};
+
+/**
+ * A mixin to use as a base class for calendar widgets.
+ *
+ * With startDate, endDate, and itemType set this mixin will inform the widget
+ * of any calendar item within the range that needs to be added to, or removed
+ * from, the UI. Widgets should implement clearItems, addItems, removeItems,
+ * and removeItemsFromCalendar to receive this information.
+ *
+ * To update the display (e.g. if the user wants to display a different month),
+ * just set the new date values and call refreshItems().
+ *
+ * This mixin handles disabled and/or hidden calendars, so you don't have to.
+ *
+ * @note Instances must have an `id` for logging purposes.
+ */
+let CalendarFilteredViewMixin = Base =>
+ class extends Base {
+ /**
+ * The filter responsible for collecting items when this view is refreshed,
+ * and checking new items as they appear.
+ *
+ * @type {calFilter}
+ */
+ #filter = null;
+
+ /**
+ * An object representing the most recent refresh job.
+ * This is used to check if a job that completes is still the most recent.
+ *
+ * @type {?object}
+ */
+ #currentRefresh = null;
+
+ /**
+ * The current PromiseUtils.jsm `Deferred` object (containing a Promise
+ * and methods to resolve/reject it).
+ *
+ * @type {object}
+ */
+ #deferred = PromiseUtils.defer();
+
+ /**
+ * Any async iterator currently reading from a calendar.
+ *
+ * @type {Set<CalReadableStreamIterator>}
+ */
+ #iterators = new Set();
+
+ constructor(...args) {
+ super(...args);
+
+ this.#filter = new calFilter();
+ this.#filter.itemType = 0;
+ }
+
+ /**
+ * A Promise that resolves when the next refreshing of items is complete,
+ * or instantly if refreshing is already complete and still valid.
+ *
+ * Changes to the startDate, endDate, or itemType properties, or a call to
+ * refreshItems with the force argument, will delay this Promise until the
+ * refresh settles for the new values.
+ *
+ * @type {Promise}
+ */
+ get ready() {
+ return this.#deferred.promise;
+ }
+
+ /**
+ * The start of the filter range. Can be either a date or a datetime.
+ *
+ * @type {calIDateTime}
+ */
+ get startDate() {
+ return this.#filter.startDate;
+ }
+
+ set startDate(value) {
+ if (
+ this.startDate?.compare(value) == 0 &&
+ this.startDate.timezone.tzid == value.timezone.tzid
+ ) {
+ return;
+ }
+
+ this.#filter.startDate = value.clone();
+ this.#filter.startDate.makeImmutable();
+ this.#invalidate();
+ }
+
+ /**
+ * The end of the filter range. Can be either a date or a datetime.
+ * If it is a date, the filter won't include items on that date, so use the
+ * day after the last day to be displayed.
+ *
+ * @type {calIDateTime}
+ */
+ get endDate() {
+ return this.#filter.endDate;
+ }
+
+ set endDate(value) {
+ if (this.endDate?.compare(value) == 0 && this.endDate.timezone.tzid == value.timezone.tzid) {
+ return;
+ }
+
+ this.#filter.endDate = value.clone();
+ this.#filter.endDate.makeImmutable();
+ this.#invalidate();
+ }
+
+ /**
+ * One of the calICalendar.ITEM_FILTER_TYPE constants.
+ * This must be set to a non-zero value in order to display any items.
+ *
+ * @type {number}
+ */
+ get itemType() {
+ return this.#filter.itemType;
+ }
+
+ set itemType(value) {
+ if (this.itemType == value) {
+ return;
+ }
+
+ this.#filter.itemType = value;
+ this.#invalidate();
+ }
+
+ #isActive = false;
+
+ /**
+ * Whether the view is active.
+ *
+ * Whilst the view is active, it will listen for item changes. Otherwise,
+ * if the view is set to be inactive, it will stop listening for changes.
+ *
+ * @type {boolean}
+ */
+ get isActive() {
+ return this.#isActive;
+ }
+
+ /**
+ * Activate the view, refreshing items and listening for changes.
+ *
+ * @returns {Promise} a promise which resolves when refresh is complete
+ */
+ activate() {
+ if (this.#isActive) {
+ return Promise.resolve();
+ }
+
+ this.#isActive = true;
+ this.#calendarObserver.self = this;
+
+ cal.manager.addCalendarObserver(this.#calendarObserver);
+ return this.refreshItems();
+ }
+
+ /**
+ * Deactivate the view, cancelling any in-progress refresh and causing it to
+ * no longer listen for changes.
+ */
+ deactivate() {
+ if (!this.#isActive) {
+ return;
+ }
+
+ this.#isActive = false;
+ this.#calendarObserver.self = this;
+
+ cal.manager.removeCalendarObserver(this.#calendarObserver);
+ this.#invalidate();
+ }
+
+ /**
+ * Clears the display and adds items that match the filter from all enabled
+ * and visible calendars.
+ *
+ * @param {boolean} force - Start refreshing again, even if a refresh is already in progress.
+ * @returns {Promise} A Promise resolved when all calendars have refreshed. This is the same
+ * Promise as returned from the `ready` getter.
+ */
+ refreshItems(force = false) {
+ if (!this.#isActive) {
+ // If we're inactive, calling #refreshCalendar() will do nothing, but we
+ // will have created a refresh job with no effect and subsequent refresh
+ // attempts will fail.
+ return Promise.resolve();
+ } else if (force) {
+ // Refresh, even if already refreshing or refreshed.
+ this.#invalidate();
+ } else if (this.#currentRefresh) {
+ // We already have an ongoing refresh job, or one that has already completed.
+ return this.#deferred.promise;
+ }
+
+ // Create a new refresh job.
+ let refresh = (this.#currentRefresh = { completed: false });
+
+ // Collect items from all of the calendars.
+ this.clearItems();
+ let promises = [];
+ for (let calendar of cal.manager.getCalendars()) {
+ promises.push(this.#refreshCalendar(calendar));
+ }
+
+ Promise.all(promises).then(() => {
+ refresh.completed = true;
+ // Resolve the Promise if the current job is still the most recent one.
+ // In other words, if nothing has called `#invalidate` since `currentRefresh` was created.
+ if (this.#currentRefresh == refresh) {
+ this.#deferred.resolve();
+ }
+ });
+
+ return this.#deferred.promise;
+ }
+
+ /**
+ * Cancels any refresh in progress.
+ */
+ #invalidate() {
+ for (let iterator of this.#iterators) {
+ iterator.cancel();
+ }
+ this.#iterators.clear();
+ if (this.#currentRefresh?.completed) {
+ // If a previous refresh completed, start a new Promise that resolves when the next refresh
+ // completes. Otherwise, continue with the current Promise.
+ // If #currentRefresh is completed, #deferred is already resolved, so we can safely discard it.
+ this.#deferred = PromiseUtils.defer();
+ }
+ this.#currentRefresh = null;
+ }
+
+ /**
+ * Checks if the given calendar is both enabled and visible.
+ *
+ * @param {calICalendar} calendar
+ * @returns {boolean} True if both enabled and visible.
+ */
+ #isCalendarVisible(calendar) {
+ if (!calendar) {
+ // If this happens then something's wrong, but it's not our problem so just ignore it.
+ return false;
+ }
+
+ return (
+ !calendar.getProperty("disabled") && calendar.getProperty("calendar-main-in-composite")
+ );
+ }
+
+ /**
+ * Adds items that match the filter from a specific calendar. Does NOT
+ * remove existing items first, use removeItemsFromCalendar for that.
+ *
+ * @param {calICalendar} calendar
+ * @returns {Promise} A promise resolved when this calendar has refreshed.
+ */
+ async #refreshCalendar(calendar) {
+ if (!this.#isActive || !this.itemType || !this.#isCalendarVisible(calendar)) {
+ return;
+ }
+ let iterator = cal.iterate.streamValues(this.#filter.getItems(calendar));
+ this.#iterators.add(iterator);
+ for await (let chunk of iterator) {
+ this.addItems(chunk);
+ }
+ this.#iterators.delete(iterator);
+ }
+
+ /**
+ * Implement this method to remove all items from the UI.
+ */
+ clearItems() {}
+
+ /**
+ * Implement this method to add items to the UI.
+ *
+ * @param {calIItemBase[]} items
+ */
+ addItems(items) {}
+
+ /**
+ * Implement this method to remove items from the UI.
+ *
+ * @param {calIItemBase[]} items
+ */
+ removeItems(items) {}
+
+ /**
+ * Implement this method to remove all items from a specific calendar from
+ * the UI.
+ *
+ * @param {string} calendarId
+ */
+ removeItemsFromCalendar(calendarId) {}
+
+ /**
+ * @implements {calIObserver}
+ */
+ #calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ onStartBatch(calendar) {},
+ onEndBatch(calendar) {},
+ onLoad(calendar) {
+ if (calendar.type == "ics") {
+ // ICS doesn't bother telling us about events that disappeared when
+ // sync'ing, so just throw them all out and reload. This should get
+ // fixed somehow, and this hack removed.
+ this.self.removeItemsFromCalendar(calendar.id);
+ this.self.#refreshCalendar(calendar);
+ }
+ },
+ onAddItem(item) {
+ if (!this.self.#isCalendarVisible(item.calendar)) {
+ return;
+ }
+
+ let occurrences = this.self.#filter.getOccurrences(item);
+ if (occurrences.length) {
+ this.self.addItems(occurrences);
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ if (!this.self.#isCalendarVisible(newItem.calendar)) {
+ return;
+ }
+
+ // Ideally we'd calculate the intersection between oldOccurrences and
+ // newOccurrences, then call a modifyItems function, but it proved
+ // unreliable in some situations, so instead we remove and replace
+ // the occurrences.
+
+ let oldOccurrences = this.self.#filter.getOccurrences(oldItem);
+ if (oldOccurrences.length) {
+ this.self.removeItems(oldOccurrences);
+ }
+
+ let newOccurrences = this.self.#filter.getOccurrences(newItem);
+ if (newOccurrences.length) {
+ this.self.addItems(newOccurrences);
+ }
+ },
+ onDeleteItem(deletedItem) {
+ if (!this.self.#isCalendarVisible(deletedItem.calendar)) {
+ return;
+ }
+
+ this.self.removeItems(this.self.#filter.getOccurrences(deletedItem));
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, newValue, oldValue) {
+ if (!["calendar-main-in-composite", "disabled"].includes(name)) {
+ return;
+ }
+
+ if (
+ (name == "disabled" && newValue) ||
+ (name == "calendar-main-in-composite" && !newValue)
+ ) {
+ this.self.removeItemsFromCalendar(calendar.id);
+ return;
+ }
+
+ this.self.#refreshCalendar(calendar);
+ },
+ onPropertyDeleting(calendar, name) {},
+ };
+ };
diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.js b/comm/calendar/base/content/widgets/calendar-invitation-panel.js
new file mode 100644
index 0000000000..aa2be5e29f
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.js
@@ -0,0 +1,799 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal, openLinkExternally, MozXULElement, MozElements */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { recurrenceRule2String } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+ );
+
+ // calendar-invitation-panel.ftl is not globally loaded until now.
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-invitation-panel.ftl");
+
+ const PROPERTY_REMOVED = -1;
+ const PROPERTY_UNCHANGED = 0;
+ const PROPERTY_ADDED = 1;
+ const PROPERTY_MODIFIED = 2;
+
+ /**
+ * InvitationPanel displays the details of an iTIP event invitation in an
+ * interactive panel.
+ */
+ class InvitationPanel extends HTMLElement {
+ static MODE_NEW = "New";
+ static MODE_ALREADY_PROCESSED = "Processed";
+ static MODE_UPDATE_MAJOR = "UpdateMajor";
+ static MODE_UPDATE_MINOR = "UpdateMinor";
+ static MODE_CANCELLED = "Cancelled";
+ static MODE_CANCELLED_NOT_FOUND = "CancelledNotFound";
+
+ /**
+ * Used to retrieve a property value from an event.
+ *
+ * @callback GetValue
+ * @param {calIEvent} event
+ * @returns {string}
+ */
+
+ /**
+ * A function used to make a property value visible in to the user.
+ *
+ * @callback PropertyShow
+ * @param {HTMLElement} node - The element responsible for displaying the
+ * value.
+ * @param {string} value - The value of property to display.
+ * @param {string} oldValue - The previous value of the property if the
+ * there is a prior copy of the event.
+ * @param {calIEvent} item - The event item the property belongs to.
+ * @param {string} oldItem - The prior version of the event if there is one.
+ */
+
+ /**
+ * @typedef {Object} InvitationPropertyDescriptor
+ * @property {string} id - The id of the HTMLElement that displays
+ * the property.
+ * @property {GetValue} getValue - Function used to retrieve the displayed
+ * value of the property from the item.
+ * @property {boolean?} isList - Indicates the value of the property is a
+ * list.
+ * @property {PropertyShow?} show - Function to use to display the property
+ * value if it is not a list.
+ */
+
+ /**
+ * A static list of objects used in determining how to display each of the
+ * properties.
+ *
+ * @type {PropertyDescriptor[]}
+ */
+ static propertyDescriptors = [
+ {
+ id: "when",
+ getValue(item) {
+ let tz = cal.dtz.defaultTimezone;
+ let startDate = item.startDate?.getInTimezone(tz) ?? null;
+ let endDate = item.endDate?.getInTimezone(tz) ?? null;
+ return `${startDate.icalString}-${endDate?.icalString}`;
+ },
+ show(intervalNode, newValue, oldValue, item) {
+ intervalNode.item = item;
+ },
+ },
+ {
+ id: "recurrence",
+ getValue(item) {
+ let parent = item.parentItem;
+ if (!parent.recurrenceInfo) {
+ return null;
+ }
+ return recurrenceRule2String(parent.recurrenceInfo, parent.recurrenceStartDate);
+ },
+ show(recurrence, value) {
+ recurrence.appendChild(document.createTextNode(value));
+ },
+ },
+ {
+ id: "location",
+ getValue(item) {
+ return item.getProperty("LOCATION");
+ },
+ show(location, value) {
+ location.appendChild(cal.view.textToHtmlDocumentFragment(value, document));
+ },
+ },
+ {
+ id: "summary",
+ getValue(item) {
+ return item.getAttendees();
+ },
+ show(summary, value) {
+ summary.attendees = value;
+ },
+ },
+ {
+ id: "attendees",
+ isList: true,
+ getValue(item) {
+ return item.getAttendees();
+ },
+ },
+ {
+ id: "attachments",
+ isList: true,
+ getValue(item) {
+ return item.getAttachments();
+ },
+ },
+ {
+ id: "description",
+ getValue(item) {
+ return item.descriptionText;
+ },
+ show(description, value) {
+ description.appendChild(cal.view.textToHtmlDocumentFragment(value, document));
+ },
+ },
+ ];
+
+ /**
+ * mode determines how the UI should display the received invitation. It
+ * must be set to one of the MODE_* constants, defaults to MODE_NEW.
+ *
+ * @type {string}
+ */
+ mode = InvitationPanel.MODE_NEW;
+
+ /**
+ * A previous copy of the event item if found on an existing calendar.
+ *
+ * @type {calIEvent?}
+ */
+ foundItem;
+
+ /**
+ * The event item to be displayed.
+ *
+ * @type {calIEvent?}
+ */
+ item;
+
+ constructor(id) {
+ super();
+ this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(this.shadowRoot);
+
+ let link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://calendar/skin/shared/widgets/calendar-invitation-panel.css";
+ this.shadowRoot.appendChild(link);
+ }
+
+ /**
+ * Compares two like property values, an old and a new one, to determine
+ * what type of change has been made (if any).
+ *
+ * @param {any} oldValue
+ * @param {any} newValue
+ * @returns {number} - One of the PROPERTY_* constants.
+ */
+ compare(oldValue, newValue) {
+ if (!oldValue && newValue) {
+ return PROPERTY_ADDED;
+ }
+ if (oldValue && !newValue) {
+ return PROPERTY_REMOVED;
+ }
+ return oldValue != newValue ? PROPERTY_MODIFIED : PROPERTY_UNCHANGED;
+ }
+
+ connectedCallback() {
+ if (this.item && this.mode) {
+ let template = document.getElementById(`calendarInvitationPanel`);
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+ if (this.foundItem && this.foundItem.title != this.item.title) {
+ let indicator = this.shadowRoot.getElementById("titleChangeIndicator");
+ indicator.status = PROPERTY_MODIFIED;
+ indicator.hidden = false;
+ }
+ this.shadowRoot.getElementById("title").textContent = this.item.title;
+
+ let statusBar = this.shadowRoot.querySelector("calendar-invitation-panel-status-bar");
+ statusBar.status = this.mode;
+
+ this.shadowRoot.querySelector("calendar-minidate").date = this.item.startDate;
+
+ for (let prop of InvitationPanel.propertyDescriptors) {
+ let el = this.shadowRoot.getElementById(prop.id);
+ let value = prop.getValue(this.item);
+ let result = PROPERTY_UNCHANGED;
+
+ if (prop.isList) {
+ let oldValue = this.foundItem ? prop.getValue(this.foundItem) : [];
+ if (value.length || oldValue.length) {
+ el.oldValue = oldValue;
+ el.value = value;
+ el.closest(".calendar-invitation-row").hidden = false;
+ }
+ continue;
+ }
+
+ let oldValue = this.foundItem ? prop.getValue(this.foundItem) : null;
+ if (this.foundItem) {
+ result = this.compare(oldValue, value);
+ if (result) {
+ let indicator = this.shadowRoot.getElementById(`${prop.id}ChangeIndicator`);
+ if (indicator) {
+ indicator.type = result;
+ indicator.hidden = false;
+ }
+ }
+ }
+ if (value || oldValue) {
+ prop.show(el, value, oldValue, this.item, this.foundItem, result);
+ el.closest(".calendar-invitation-row").hidden = false;
+ }
+ }
+
+ if (
+ this.mode == InvitationPanel.MODE_NEW ||
+ this.mode == InvitationPanel.MODE_UPDATE_MAJOR
+ ) {
+ for (let button of this.shadowRoot.querySelectorAll("#actionButtons > button")) {
+ button.addEventListener("click", e =>
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: button.dataset.action },
+ })
+ )
+ );
+ }
+ this.shadowRoot.getElementById("footer").hidden = false;
+ }
+ }
+ }
+ }
+ customElements.define("calendar-invitation-panel", InvitationPanel);
+
+ /**
+ * Object used to describe relevant arguments to MozElements.NotificationBox.
+ * appendNotification().
+ * @type {Object} InvitationStatusBarDescriptor
+ * @property {string} label - An l10n id used used to generate the notification
+ * bar text.
+ * @property {number} priority - One of the notification box constants that
+ * indicate the priority of a notification.
+ * @property {object[]} buttons - An array of objects corresponding to the
+ * "buttons" argument of MozElements.NotificationBox.appendNotification().
+ * See that method for details.
+ */
+
+ /**
+ * InvitationStatusBar generates a notification bar that informs the user about
+ * the status of the received invitation and possible actions they may take.
+ */
+ class InvitationPanelStatusBar extends HTMLElement {
+ /**
+ * @type {NotificationBox}
+ */
+ get notificationBox() {
+ if (!this._notificationBox) {
+ this._notificationBox = new MozElements.NotificationBox(element => {
+ this.append(element);
+ });
+ }
+ return this._notificationBox;
+ }
+
+ /**
+ * Map-like object where each key is an InvitationPanel mode and the values
+ * are descriptors used to generate the notification bar for that mode.
+ *
+ * @type {Object.<string, InvitationStatusBarDescriptor>
+ */
+ notices = {
+ [InvitationPanel.MODE_NEW]: {
+ label: "calendar-invitation-panel-status-new",
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-more-button",
+ callback: (notification, opts, button, event) =>
+ this._showMoreMenu(event, [
+ {
+ l10nId: "calendar-invitation-panel-menu-item-save-copy",
+ name: "save",
+ command: e =>
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ details: { type: "x-savecopy" },
+ bubbles: true,
+ composed: true,
+ })
+ ),
+ },
+ ]),
+ },
+ ],
+ },
+ [InvitationPanel.MODE_ALREADY_PROCESSED]: {
+ label: "calendar-invitation-panel-status-processed",
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-view-button",
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: "x-showdetails" },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return true;
+ },
+ },
+ ],
+ },
+ [InvitationPanel.MODE_UPDATE_MINOR]: {
+ label: "calendar-invitation-panel-status-updateminor",
+ priority: this.notificationBox.PRIORITY_WARNING_LOW,
+ buttons: [
+ {
+ "l10n-id": "calendar-invitation-panel-update-button",
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent("calendar-invitation-panel-action", {
+ detail: { type: "update" },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return true;
+ },
+ },
+ ],
+ },
+ [InvitationPanel.MODE_UPDATE_MAJOR]: {
+ label: "calendar-invitation-panel-status-updatemajor",
+ priority: this.notificationBox.PRIORITY_WARNING_LOW,
+ },
+ [InvitationPanel.MODE_CANCELLED]: {
+ label: "calendar-invitation-panel-status-cancelled",
+ buttons: [{ "l10n-id": "calendar-invitation-panel-delete-button" }],
+ priority: this.notificationBox.PRIORITY_CRITICAL_LOW,
+ },
+ [InvitationPanel.MODE_CANCELLED_NOT_FOUND]: {
+ label: "calendar-invitation-panel-status-cancelled-notfound",
+ priority: this.notificationBox.PRIORITY_CRITICAL_LOW,
+ },
+ };
+
+ /**
+ * status corresponds to one of the MODE_* constants and will trigger
+ * rendering of the notification box.
+ *
+ * @type {string} status
+ */
+ set status(value) {
+ let opts = this.notices[value];
+ let priority = opts.priority || this.notificationBox.PRIORITY_INFO_LOW;
+ let buttons = opts.buttons || [];
+ let notification = this.notificationBox.appendNotification(
+ "invitationStatus",
+ {
+ label: { "l10n-id": opts.label },
+ priority,
+ },
+ buttons
+ );
+ notification.removeAttribute("dismissable");
+ }
+
+ _showMoreMenu(event, menuitems) {
+ let menu = document.getElementById("calendarInvitationPanelMoreMenu");
+ menu.replaceChildren();
+ for (let { type, l10nId, name, command } of menuitems) {
+ let menuitem = document.createXULElement("menuitem");
+ if (type) {
+ menuitem.type = type;
+ }
+ if (name) {
+ menuitem.name = name;
+ }
+ if (command) {
+ menuitem.addEventListener("command", command);
+ }
+ document.l10n.setAttributes(menuitem, l10nId);
+ menu.appendChild(menuitem);
+ }
+ menu.openPopup(event.originalTarget, "after_start", 0, 0, false, false, event);
+ return true;
+ }
+ }
+ customElements.define("calendar-invitation-panel-status-bar", InvitationPanelStatusBar);
+
+ /**
+ * InvitationInterval displays the formatted interval of the event. Formatting
+ * relies on cal.dtz.formatter.formatIntervalParts().
+ */
+ class InvitationInterval extends HTMLElement {
+ /**
+ * The item whose interval to show.
+ *
+ * @type {calIEvent}
+ */
+ set item(value) {
+ let [startDate, endDate] = cal.dtz.formatter.getItemDates(value);
+ let timezone = startDate.timezone.displayName;
+ let parts = cal.dtz.formatter.formatIntervalParts(startDate, endDate);
+ document.l10n.setAttributes(this, `calendar-invitation-interval-${parts.type}`, {
+ ...parts,
+ timezone,
+ });
+ }
+ }
+ customElements.define("calendar-invitation-interval", InvitationInterval);
+
+ const partStatOrder = ["ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION"];
+
+ /**
+ * InvitationPartStatSummary generates text indicating the aggregated
+ * participation status of each attendee in the event's attendees list.
+ */
+ class InvitationPartStatSummary extends HTMLElement {
+ constructor() {
+ super();
+ this.appendChild(
+ document.getElementById("calendarInvitationPartStatSummary").content.cloneNode(true)
+ );
+ }
+
+ /**
+ * Setting this property will trigger an update of the text displayed.
+ *
+ * @type {calIAttendee[]}
+ */
+ set attendees(attendees) {
+ let counts = {
+ ACCEPTED: 0,
+ DECLINED: 0,
+ TENTATIVE: 0,
+ "NEEDS-ACTION": 0,
+ TOTAL: attendees.length,
+ OTHER: 0,
+ };
+
+ for (let { participationStatus } of attendees) {
+ if (counts.hasOwnProperty(participationStatus)) {
+ counts[participationStatus]++;
+ } else {
+ counts.OTHER++;
+ }
+ }
+ document.l10n.setAttributes(
+ this.querySelector("#partStatTotal"),
+ "calendar-invitation-panel-partstat-total",
+ { count: counts.TOTAL }
+ );
+
+ let shownPartStats = partStatOrder.filter(partStat => counts[partStat]);
+ let breakdown = this.querySelector("#partStatBreakdown");
+ for (let partStat of shownPartStats) {
+ let span = document.createElement("span");
+ span.setAttribute("class", "calendar-invitation-panel-partstat-summary");
+
+ // calendar-invitation-panel-partstat-accepted
+ // calendar-invitation-panel-partstat-declined
+ // calendar-invitation-panel-partstat-tentative
+ // calendar-invitation-panel-partstat-needs-action
+ document.l10n.setAttributes(
+ span,
+ `calendar-invitation-panel-partstat-${partStat.toLowerCase()}`,
+ {
+ count: counts[partStat],
+ }
+ );
+ breakdown.appendChild(span);
+ }
+ }
+ }
+ customElements.define("calendar-invitation-partstat-summary", InvitationPartStatSummary);
+
+ /**
+ * BaseInvitationChangeList is a <ul> element that can visually show changes
+ * between elements of a list value.
+ *
+ * @template T
+ */
+ class BaseInvitationChangeList extends HTMLUListElement {
+ /**
+ * An array containing the old values to be compared against for changes.
+ *
+ * @type {T[]}
+ */
+ oldValue = [];
+
+ /**
+ * String indicating the type of list items to create. This is passed
+ * directly to the "is" argument of document.createElement().
+ *
+ * @abstract
+ */
+ listItem;
+
+ _createListItem(value, status) {
+ let li = document.createElement("li", { is: this.listItem });
+ li.changeStatus = status;
+ li.value = value;
+ return li;
+ }
+
+ /**
+ * Setting this property will trigger rendering of the list. If no prior
+ * values are detected, change indicators are not touched.
+ *
+ * @type {T[]}
+ */
+ set value(list) {
+ if (!this.oldValue.length) {
+ for (let value of list) {
+ this.append(this._createListItem(value));
+ }
+ return;
+ }
+ for (let [value, status] of this.getChanges(this.oldValue, list)) {
+ this.appendChild(this._createListItem(value, status));
+ }
+ }
+
+ /**
+ * Implemented by sub-classes to generate a list of changes for each element
+ * of the new list.
+ *
+ * @param {T[]} oldValue
+ * @param {T[]} newValue
+ * @return {[T, number][]}
+ */
+ getChanges(oldValue, newValue) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ }
+
+ /**
+ * BaseInvitationChangeListItem is the <li> element used for change lists.
+ *
+ * @template {T}
+ */
+ class BaseInvitationChangeListItem extends HTMLLIElement {
+ /**
+ * Indicates whether the item value has changed and should be displayed as
+ * such. Its value is one of the PROPERTY_* constants.
+ *
+ * @type {number}
+ */
+ changeStatus = PROPERTY_UNCHANGED;
+
+ /**
+ * Settings this property will render the list item including a change
+ * indicator if the changeStatus property != PROPERTY_UNCHANGED.
+ *
+ * @type {T}
+ */
+ set value(itemValue) {
+ this.build(itemValue);
+ if (this.changeStatus) {
+ let changeIndicator = document.createElement("calendar-invitation-change-indicator");
+ changeIndicator.type = this.changeStatus;
+ this.append(changeIndicator);
+ }
+ }
+
+ /**
+ * Implemented by sub-classes to build the <li> inner DOM structure.
+ *
+ * @param {T} value
+ * @abstract
+ */
+ build(value) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ }
+
+ /**
+ * InvitationAttendeeList displays a list of all the attendees on an event's
+ * attendee list.
+ */
+ class InvitationAttendeeList extends BaseInvitationChangeList {
+ listItem = "calendar-invitation-panel-attendee-list-item";
+
+ getChanges(oldValue, newValue) {
+ let diff = [];
+ for (let att of newValue) {
+ let oldAtt = oldValue.find(oldAtt => oldAtt.id == att.id);
+ if (!oldAtt) {
+ diff.push([att, PROPERTY_ADDED]); // New attendee.
+ } else if (oldAtt.participationStatus != att.participationStatus) {
+ diff.push([att, PROPERTY_MODIFIED]); // Participation status changed.
+ } else {
+ diff.push([att, PROPERTY_UNCHANGED]); // No change.
+ }
+ }
+
+ // Insert removed attendees into the diff.
+ for (let [idx, att] of oldValue.entries()) {
+ let found = newValue.find(newAtt => newAtt.id == att.id);
+ if (!found) {
+ diff.splice(idx, 0, [att, PROPERTY_REMOVED]);
+ }
+ }
+ return diff;
+ }
+ }
+ customElements.define("calendar-invitation-panel-attendee-list", InvitationAttendeeList, {
+ extends: "ul",
+ });
+
+ /**
+ * InvitationAttendeeListItem displays a single attendee from the attendee
+ * list.
+ */
+ class InvitationAttendeeListItem extends BaseInvitationChangeListItem {
+ build(value) {
+ let span = document.createElement("span");
+ if (this.changeStatus == PROPERTY_REMOVED) {
+ span.setAttribute("class", "removed");
+ }
+ span.textContent = value;
+ this.appendChild(span);
+ }
+ }
+ customElements.define(
+ "calendar-invitation-panel-attendee-list-item",
+ InvitationAttendeeListItem,
+ {
+ extends: "li",
+ }
+ );
+
+ /**
+ * InvitationAttachmentList displays a list of all attachments in the invitation
+ * that have URIs. Binary attachments are not supported.
+ */
+ class InvitationAttachmentList extends BaseInvitationChangeList {
+ listItem = "calendar-invitation-panel-attachment-list-item";
+
+ getChanges(oldValue, newValue) {
+ let diff = [];
+ for (let attch of newValue) {
+ if (!attch.uri) {
+ continue;
+ }
+ let oldAttch = oldValue.find(
+ oldAttch => oldAttch.uri && oldAttch.uri.spec == attch.uri.spec
+ );
+
+ if (!oldAttch) {
+ // New attachment.
+ diff.push([attch, PROPERTY_ADDED]);
+ continue;
+ }
+ if (
+ attch.hashId != oldAttch.hashId ||
+ attch.getParameter("FILENAME") != oldAttch.getParameter("FILENAME")
+ ) {
+ // Contents changed or renamed.
+ diff.push([attch, PROPERTY_MODIFIED]);
+ continue;
+ }
+ // No change.
+ diff.push([attch, PROPERTY_UNCHANGED]);
+ }
+
+ // Insert removed attachments into the diff.
+ for (let [idx, attch] of oldValue.entries()) {
+ if (!attch.uri) {
+ continue;
+ }
+ let found = newValue.find(newAtt => newAtt.uri && newAtt.uri.spec == attch.uri.spec);
+ if (!found) {
+ diff.splice(idx, 0, [attch, PROPERTY_REMOVED]);
+ }
+ }
+ return diff;
+ }
+ }
+ customElements.define("calendar-invitation-panel-attachment-list", InvitationAttachmentList, {
+ extends: "ul",
+ });
+
+ /**
+ * InvitationAttachmentListItem displays a link to an attachment attached to the
+ * event.
+ */
+ class InvitationAttachmentListItem extends BaseInvitationChangeListItem {
+ /**
+ * Indicates whether the attachment has changed and should be displayed as
+ * such. Its value is one of the PROPERTY_* constants.
+ *
+ * @type {number}
+ */
+ changeStatus = PROPERTY_UNCHANGED;
+
+ /**
+ * Sets up the attachment to be displayed as a link with appropriate icon.
+ * Links are opened externally.
+ *
+ * @param {calIAttachment}
+ */
+ build(value) {
+ let icon = document.createElement("img");
+ let iconSrc = value.uri.spec.length ? value.uri.spec : "dummy.html";
+ if (!value.uri.schemeIs("file")) {
+ // Using an uri directly, with e.g. a http scheme, wouldn't render any icon.
+ if (value.formatType) {
+ iconSrc = "goat?contentType=" + value.formatType;
+ } else {
+ // Let's try to auto-detect.
+ let parts = iconSrc.substr(value.uri.scheme.length + 2).split("/");
+ if (parts.length) {
+ iconSrc = parts[parts.length - 1];
+ }
+ }
+ }
+ icon.setAttribute("src", "moz-icon://" + iconSrc);
+ this.append(icon);
+
+ let title = value.getParameter("FILENAME") || value.uri.spec;
+ if (this.changeStatus == PROPERTY_REMOVED) {
+ let span = document.createElement("span");
+ span.setAttribute("class", "removed");
+ span.textContent = title;
+ this.append(span);
+ } else {
+ let link = document.createElement("a");
+ link.textContent = title;
+ link.setAttribute("href", value.uri.spec);
+ link.addEventListener("click", event => {
+ event.preventDefault();
+ openLinkExternally(event.target.href);
+ });
+ this.append(link);
+ }
+ }
+ }
+ customElements.define(
+ "calendar-invitation-panel-attachment-list-item",
+ InvitationAttachmentListItem,
+ {
+ extends: "li",
+ }
+ );
+
+ /**
+ * InvitationChangeIndicator is a visual indicator for indicating some piece
+ * of data has changed.
+ */
+ class InvitationChangeIndicator extends HTMLElement {
+ _typeMap = {
+ [PROPERTY_REMOVED]: "removed",
+ [PROPERTY_ADDED]: "added",
+ [PROPERTY_MODIFIED]: "modified",
+ };
+
+ /**
+ * One of the PROPERTY_* constants that indicates what kind of change we
+ * are indicating (add/modify/delete) etc.
+ *
+ * @type {number}
+ */
+ set type(value) {
+ let key = this._typeMap[value];
+ document.l10n.setAttributes(this, `calendar-invitation-change-indicator-${key}`);
+ }
+ }
+ customElements.define("calendar-invitation-change-indicator", InvitationChangeIndicator);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml
new file mode 100644
index 0000000000..aaca3c1a17
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-invitation-panel.xhtml
@@ -0,0 +1,96 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- Template for <calendar-invitation-panel/> -->
+<template id="calendarInvitationPanel" xmlns="http://www.w3.org/1999/xhtml">
+ <calendar-invitation-panel-status-bar/>
+ <div class="calendar-invitation-panel-wrapper">
+ <div class="calendar-invitation-panel-preview">
+ <calendar-minidate/>
+ </div>
+ <div class="calendar-invitation-panel-details">
+ <div class="calendar-invitation-panel-title">
+ <calendar-invitation-change-indicator id="titleChangeIndicator"
+ hidden="hidden"/>
+ <h1 class="calendar-invitation-panel-title" id="title"></h1>
+ </div>
+ <table id="props" class="calendar-invitation-panel-props">
+ <tbody>
+ <tr class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-when"></th>
+ <td class="calendar-invitation-when">
+ <calendar-invitation-change-indicator id="intervalChangeIndicator"
+ hidden="hidden"/>
+ <calendar-invitation-interval id="when"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-recurrence"></th>
+ <td id="recurrence" class="calendar-invitation-recurrence">
+ <calendar-invitation-change-indicator id="recurrenceChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-location"></th>
+ <td id="location" class="content">
+ <calendar-invitation-change-indicator id="locationChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-attendees"></th>
+ <td>
+ <calendar-invitation-partstat-summary id="summary"/>
+ <ul id="attendees"
+ is="calendar-invitation-panel-attendee-list"
+ class="calendar-invitation-panel-list"></ul>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-description"></th>
+ <td id="description" class="content">
+ <calendar-invitation-change-indicator id="descriptionChangeIndicator"
+ hidden="hidden"/>
+ </td>
+ </tr>
+ <tr hidden="hidden" class="calendar-invitation-row">
+ <th data-l10n-id="calendar-invitation-panel-prop-title-attachments"></th>
+ <td class="content">
+ <ul id="attachments"
+ is="calendar-invitation-panel-attachment-list"
+ class="calendar-invitation-panel-list"></ul>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div id="footer" class="calendar-invitation-panel-details-footer" hidden="hidden">
+ <div id="actionButtons" class="calendar-invitation-panel-response-buttons">
+ <button id="acceptButton"
+ data-action="accepted"
+ class="primary"
+ data-l10n-id="calendar-invitation-panel-accept-button"></button>
+ <button id="declineButton"
+ data-action="declined"
+ data-l10n-id="calendar-invitation-panel-decline-button"></button>
+ <button id="tentativeButton"
+ data-action="tentative"
+ data-l10n-id="calendar-invitation-panel-tentative-button"></button>
+ </div>
+ </div>
+</template>
+
+<!-- Template for <calendar-invitation-partstat-summary/> -->
+<template id="calendarInvitationPartStatSummary" xmlns="http://www.w3.org/1999/xhtml">
+ <div class="calendar-invitation-attendees-summary">
+ <span id="partStatTotal"
+ class="calendar-invitation-panel-partstat-summary-total"></span>
+ <span id="partStatBreakdown" class="calendar-invitation-panel-partstat-breakdown"></span>
+ </div>
+</template>
+
+<!-- Menu for the "More" button in the invitation panel. Populated via JavaScript.-->
+<menupopup id="calendarInvitationPanelMoreMenu"></menupopup>
diff --git a/comm/calendar/base/content/widgets/calendar-item-summary.js b/comm/calendar/base/content/widgets/calendar-item-summary.js
new file mode 100644
index 0000000000..747c5e1d5d
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-item-summary.js
@@ -0,0 +1,761 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global MozElements MozXULElement */
+
+/* import-globals-from ../../src/calApplicationUtils.js */
+/* import-globals-from ../dialogs/calendar-summary-dialog.js */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ var { recurrenceStringFromItem } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+ );
+
+ /**
+ * Represents a mostly read-only summary of a calendar item. Used in places
+ * like the calendar summary dialog and calendar import dialog. All instances
+ * should have an ID attribute.
+ */
+ class CalendarItemSummary extends MozXULElement {
+ static get markup() {
+ return `<vbox class="item-summary-box" flex="1">
+ <!-- General -->
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.general.label;" class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <html:table class="calendar-summary-table">
+ <html:tr>
+ <html:th>
+ &read.only.title.label;
+ </html:th>
+ <html:td class="item-title">
+ </html:td>
+ </html:tr>
+ <html:tr class="calendar-row" hidden="hidden">
+ <html:th>
+ &read.only.calendar.label;
+ </html:th>
+ <html:td class="item-calendar">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-date-row">
+ <html:th class="item-start-row-label"
+ taskStartLabel="&read.only.task.start.label;"
+ eventStartLabel="&read.only.event.start.label;">
+ </html:th>
+ <html:td class="item-date-row-start-date">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-date-row">
+ <html:th class="item-due-row-label"
+ taskDueLabel="&read.only.task.due.label;"
+ eventEndLabel="&read.only.event.end.label;">
+ </html:th>
+ <html:td class="item-date-row-end-date">
+ </html:td>
+ </html:tr>
+ <html:tr class="repeat-row" hidden="hidden">
+ <html:th>
+ &read.only.repeat.label;
+ </html:th>
+ <html:td class="repeat-details">
+ </html:td>
+ </html:tr>
+ <html:tr class="location-row" hidden="hidden">
+ <html:th>
+ &read.only.location.label;
+ </html:th>
+ <html:td class="item-location">
+ </html:td>
+ </html:tr>
+ <html:tr class="category-row" hidden="hidden">
+ <html:th>
+ &read.only.category.label;
+ </html:th>
+ <html:td class="item-category">
+ </html:td>
+ </html:tr>
+ <html:tr class="item-organizer-row" hidden="hidden">
+ <html:th>
+ &read.only.organizer.label;
+ </html:th>
+ <html:td class="item-organizer-cell">
+ </html:td>
+ </html:tr>
+ <html:tr class="status-row" hidden="hidden">
+ <html:th>
+ &task.status.label;
+ </html:th>
+ <html:td class="status-row-td">
+ <html:div hidden="true" status="TENTATIVE">&newevent.status.tentative.label;</html:div>
+ <html:div hidden="true" status="CONFIRMED">&newevent.status.confirmed.label;</html:div>
+ <html:div hidden="true" status="CANCELLED">&newevent.eventStatus.cancelled.label;</html:div>
+ <html:div hidden="true" status="CANCELLED">&newevent.todoStatus.cancelled.label;</html:div>
+ <html:div hidden="true" status="NEEDS-ACTION">&newevent.status.needsaction.label;</html:div>
+ <html:div hidden="true" status="IN-PROCESS">&newevent.status.inprogress.label;</html:div>
+ <html:div hidden="true" status="COMPLETED">&newevent.status.completed.label;</html:div>
+ </html:td>
+ </html:tr>
+ <separator class="groove" flex="1" hidden="true"/>
+ <html:tr class="reminder-row" hidden="hidden">
+ <html:th class="reminder-label">
+ &read.only.reminder.label;
+ </html:th>
+ <html:td class="reminder-details">
+ </html:td>
+ </html:tr>
+ <html:tr class="attachments-row item-attachments-row" hidden="hidden" >
+ <html:th class="attachments-label">
+ &read.only.attachments.label;
+ </html:th>
+ <html:td>
+ <vbox class="item-attachment-cell">
+ <!-- attachment box template -->
+ <hbox class="attachment-template"
+ hidden="true"
+ align="center"
+ disable-on-readonly="true">
+ <html:img class="attachment-icon invisible-on-broken"
+ alt="" />
+ <label class="text-link item-attachment-cell-label"
+ crop="end"
+ flex="1" />
+ </hbox>
+ </vbox>
+ </html:td>
+ </html:tr>
+ </html:table>
+ <!-- Attendees -->
+ <box class="item-attendees-description">
+ <box class="item-attendees" orient="vertical" hidden="true">
+ <spacer class="default-spacer"/>
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.attendees.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <vbox class="item-attendees-list-container"
+ flex="1"
+ context="attendee-popup"
+ oncontextmenu="onAttendeeContextMenu(event)">
+ </vbox>
+ </box>
+
+ <splitter id="attendeeDescriptionSplitter"
+ class="item-summary-splitter"
+ collapse="after"
+ orient="vertical"
+ state="open"/>
+
+ <!-- Description -->
+ <box class="item-description-box" hidden="true" orient="vertical">
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.description.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <iframe class="item-description"
+ type="content"
+ flex="1"
+ oncontextmenu="openDescriptionContextMenu(event);">
+ </iframe>
+ </box>
+ </box>
+
+ <!-- URL link -->
+ <box class="event-grid-link-row" hidden="true" orient="vertical">
+ <spacer class="default-spacer"/>
+ <hbox class="calendar-caption" align="center">
+ <label value="&read.only.link.label;"
+ class="header"/>
+ <separator class="groove" flex="1"/>
+ </hbox>
+ <label class="url-link text-link default-indent"
+ crop="end"/>
+ </box>
+ </vbox>`;
+ }
+
+ static get entities() {
+ return [
+ "chrome://calendar/locale/global.dtd",
+ "chrome://calendar/locale/calendar.dtd",
+ "chrome://calendar/locale/calendar-event-dialog.dtd",
+ "chrome://branding/locale/brand.dtd",
+ ];
+ }
+
+ static get alarmMenulistFragment() {
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(
+ `<hbox align="center">
+ <menulist class="item-alarm"
+ disable-on-readonly="true">
+ <menupopup>
+ <menuitem label="&event.reminder.none.label;"
+ selected="true"
+ value="none"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.0minutes.before.label;"
+ length="0"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.5minutes.before.label;"
+ length="5"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.15minutes.before.label;"
+ length="15"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuitem label="&event.reminder.30minutes.before.label;"
+ length="30"
+ origin="before"
+ relation="START"
+ unit="minutes"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.1hour.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuitem label="&event.reminder.2hours.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuitem label="&event.reminder.12hours.before.label;"
+ length="12"
+ origin="before"
+ relation="START"
+ unit="hours"/>
+ <menuseparator/>
+ <menuitem label="&event.reminder.1day.before.label;"
+ length="1"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuitem label="&event.reminder.2days.before.label;"
+ length="2"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuitem label="&event.reminder.1week.before.label;"
+ length="7"
+ origin="before"
+ relation="START"
+ unit="days"/>
+ <menuseparator/>
+ <menuitem class="reminder-custom-menuitem"
+ label="&event.reminder.custom.label;"
+ value="custom"/>
+ </menupopup>
+ </menulist>
+ <hbox class="reminder-details">
+ <hbox class="alarm-icons-box" align="center"/>
+ <!-- TODO oncommand? onkeypress? -->
+ <label class="reminder-multiple-alarms-label text-link"
+ hidden="true"
+ value="&event.reminder.multiple.label;"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"/>
+ <label class="reminder-single-alarms-label text-link"
+ hidden="true"
+ disable-on-readonly="true"
+ flex="1"
+ hyperlink="true"/>
+ </hbox>
+ </hbox>`,
+ CalendarItemSummary.entities
+ ),
+ true
+ );
+ Object.defineProperty(this, "alarmMenulistFragment", { value: frag });
+ return frag;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasConnected) {
+ return;
+ }
+ this.hasConnected = true;
+
+ this.appendChild(this.constructor.fragment);
+
+ this.mItem = null;
+ this.mCalendar = null;
+ this.mReadOnly = true;
+ this.mIsInvitation = false;
+
+ this.mIsToDoItem = null;
+
+ let urlLink = this.querySelector(".url-link");
+ urlLink.addEventListener("click", event => {
+ launchBrowser(urlLink.getAttribute("href"), event);
+ });
+ urlLink.addEventListener("command", event => {
+ launchBrowser(urlLink.getAttribute("href"), event);
+ });
+ }
+
+ set item(item) {
+ this.mItem = item;
+ this.mIsToDoItem = item.isTodo();
+
+ // When used in places like the import dialog, there is no calendar (yet).
+ if (item.calendar) {
+ this.mCalendar = item.calendar;
+
+ this.mIsInvitation =
+ item.calendar.supportsScheduling &&
+ item.calendar.getSchedulingSupport()?.isInvitation(item);
+
+ this.mReadOnly = !(
+ cal.acl.isCalendarWritable(this.mCalendar) &&
+ (cal.acl.userCanModifyItem(item) ||
+ (this.mIsInvitation && cal.acl.userCanRespondToInvitation(item)))
+ );
+ }
+
+ if (!item.descriptionHTML || !item.getAttendees().length) {
+ // Hide the splitter when there is no description or attendees.
+ document.getElementById("attendeeDescriptionSplitter").setAttribute("hidden", "true");
+ }
+ }
+
+ get item() {
+ return this.mItem;
+ }
+
+ get calendar() {
+ return this.mCalendar;
+ }
+
+ get readOnly() {
+ return this.mReadOnly;
+ }
+
+ get isInvitation() {
+ return this.mIsInvitation;
+ }
+
+ /**
+ * Update the item details in the UI. To be called when this element is
+ * first rendered and when the item changes.
+ */
+ updateItemDetails() {
+ if (!this.item) {
+ // Setup not complete, do nothing for now.
+ return;
+ }
+ let item = this.item;
+ let isToDoItem = this.mIsToDoItem;
+
+ this.querySelector(".item-title").textContent = item.title;
+
+ if (this.calendar) {
+ this.querySelector(".calendar-row").removeAttribute("hidden");
+ this.querySelector(".item-calendar").textContent = this.calendar.name;
+ }
+
+ // Show start date.
+ let itemStartDate = item[cal.dtz.startDateProp(item)];
+
+ let itemStartRowLabel = this.querySelector(".item-start-row-label");
+ let itemDateRowStartDate = this.querySelector(".item-date-row-start-date");
+
+ itemStartRowLabel.style.visibility = itemStartDate ? "visible" : "collapse";
+ itemDateRowStartDate.style.visibility = itemStartDate ? "visible" : "collapse";
+
+ if (itemStartDate) {
+ itemStartRowLabel.textContent = itemStartRowLabel.getAttribute(
+ isToDoItem ? "taskStartLabel" : "eventStartLabel"
+ );
+ itemDateRowStartDate.textContent = cal.dtz.getStringForDateTime(itemStartDate);
+ }
+
+ // Show due date / end date.
+ let itemDueDate = item[cal.dtz.endDateProp(item)];
+
+ let itemDueRowLabel = this.querySelector(".item-due-row-label");
+ let itemDateRowEndDate = this.querySelector(".item-date-row-end-date");
+
+ itemDueRowLabel.style.visibility = itemDueDate ? "visible" : "collapse";
+ itemDateRowEndDate.style.visibility = itemDueDate ? "visible" : "collapse";
+
+ if (itemDueDate) {
+ // For all-day events, display the last day, not the finish time.
+ if (itemDueDate.isDate) {
+ itemDueDate = itemDueDate.clone();
+ itemDueDate.day--;
+ }
+ itemDueRowLabel.textContent = itemDueRowLabel.getAttribute(
+ isToDoItem ? "taskDueLabel" : "eventEndLabel"
+ );
+ itemDateRowEndDate.textContent = cal.dtz.getStringForDateTime(itemDueDate);
+ }
+
+ let alarms = item.getAlarms();
+ let hasAlarms = alarms && alarms.length;
+ let canShowReadOnlyReminders = hasAlarms && item.calendar;
+ let shouldShowReminderMenu =
+ !this.readOnly &&
+ this.isInvitation &&
+ item.calendar &&
+ item.calendar.getProperty("capabilities.alarms.oninvitations.supported") !== false;
+
+ // For invitations where the reminders can be edited, show a menu to
+ // allow setting the reminder, because you can't edit an invitation in
+ // the edit item dialog. For all other cases, show a plain text
+ // representation of the reminders but only if there are any.
+ if (shouldShowReminderMenu) {
+ if (!this.mAlarmsMenu) {
+ // Attempt to vertically align the label. It's not perfect but it's the best we've got.
+ let reminderLabel = this.querySelector(".reminder-label");
+ reminderLabel.style.verticalAlign = "middle";
+ let reminderCell = this.querySelector(".reminder-details");
+ while (reminderCell.lastChild) {
+ reminderCell.lastChild.remove();
+ }
+
+ // Add the menulist dynamically only if it's going to be used. This removes a
+ // significant performance penalty in most use cases.
+ reminderCell.append(this.constructor.alarmMenulistFragment.cloneNode(true));
+ this.mAlarmsMenu = this.querySelector(".item-alarm");
+ this.mLastAlarmSelection = 0;
+
+ this.mAlarmsMenu.addEventListener("command", () => {
+ this.updateReminder();
+ });
+
+ this.querySelector(".reminder-multiple-alarms-label").addEventListener("click", () => {
+ this.updateReminder();
+ });
+
+ this.querySelector(".reminder-single-alarms-label").addEventListener("click", () => {
+ this.updateReminder();
+ });
+ }
+
+ if (hasAlarms) {
+ this.mLastAlarmSelection = loadReminders(alarms, this.mAlarmsMenu, this.mItem.calendar);
+ }
+ this.updateReminder();
+ } else if (canShowReadOnlyReminders) {
+ this.updateReminderReadOnly(alarms);
+ }
+
+ if (shouldShowReminderMenu || canShowReadOnlyReminders) {
+ this.querySelector(".reminder-row").removeAttribute("hidden");
+ }
+
+ let recurrenceDetails = recurrenceStringFromItem(
+ item,
+ "calendar-event-dialog",
+ "ruleTooComplexSummary"
+ );
+ this.updateRecurrenceDetails(recurrenceDetails);
+ this.updateAttendees(item);
+
+ let url = item.getProperty("URL")?.trim() || "";
+
+ let link = this.querySelector(".url-link");
+ link.setAttribute("href", url);
+ link.setAttribute("value", url);
+ // Hide the row if there is no url.
+ this.querySelector(".event-grid-link-row").hidden = !url;
+
+ let location = item.getProperty("LOCATION");
+ if (location) {
+ this.updateLocation(location);
+ }
+
+ let categories = item.getCategories();
+ if (categories.length > 0) {
+ this.querySelector(".category-row").removeAttribute("hidden");
+ // TODO: this join is unfriendly for l10n (categories.join(", ")).
+ this.querySelector(".item-category").textContent = categories.join(", ");
+ }
+
+ if (item.organizer && item.organizer.id) {
+ this.updateOrganizer(item);
+ }
+
+ let status = item.getProperty("STATUS");
+ if (status && status.length) {
+ this.updateStatus(status, isToDoItem);
+ }
+
+ let descriptionText = item.descriptionText?.trim();
+ if (descriptionText) {
+ this.updateDescription(descriptionText, item.descriptionHTML);
+ }
+
+ let attachments = item.getAttachments();
+ if (attachments.length) {
+ this.updateAttachments(attachments);
+ }
+ }
+
+ /**
+ * Updates the reminder, called when a reminder has been selected in the
+ * menulist.
+ */
+ updateReminder() {
+ this.mLastAlarmSelection = commonUpdateReminder(
+ this.mAlarmsMenu,
+ this.mItem,
+ this.mLastAlarmSelection,
+ this.mItem.calendar,
+ this.querySelector(".reminder-details"),
+ null,
+ false
+ );
+ }
+
+ /**
+ * Updates the reminder to display the set reminders as read-only text.
+ * Depends on updateReminder() to get the text to display.
+ */
+ updateReminderReadOnly(alarms) {
+ let reminderLabel = this.querySelector(".reminder-label");
+ reminderLabel.style.verticalAlign = null;
+ let reminderCell = this.querySelector(".reminder-details");
+ while (reminderCell.lastChild) {
+ reminderCell.lastChild.remove();
+ }
+ delete this.mAlarmsMenu;
+
+ switch (alarms.length) {
+ case 0:
+ reminderCell.textContent = "";
+ break;
+ case 1:
+ reminderCell.textContent = alarms[0].toString(this.item);
+ break;
+ default:
+ for (let a of alarms) {
+ reminderCell.appendChild(document.createTextNode(a.toString(this.item)));
+ reminderCell.appendChild(document.createElement("br"));
+ }
+ break;
+ }
+ }
+
+ /**
+ * Updates the item's recurrence details, i.e. shows text describing them,
+ * or hides the recurrence row if the item does not recur.
+ *
+ * @param {string | null} details - Recurrence details as a string or null.
+ * Passing null hides the recurrence row.
+ */
+ updateRecurrenceDetails(details) {
+ let repeatRow = this.querySelector(".repeat-row");
+ let repeatDetails = repeatRow.querySelector(".repeat-details");
+
+ repeatRow.toggleAttribute("hidden", !details);
+ repeatDetails.textContent = details ? details.replace(/\n/g, " ") : "";
+ }
+
+ /**
+ * Updates the attendee listbox, displaying all attendees invited to the item.
+ */
+ updateAttendees(item) {
+ let attendees = item.getAttendees();
+ if (attendees && attendees.length) {
+ this.querySelector(".item-attendees").removeAttribute("hidden");
+ this.querySelector(".item-attendees-list-container").appendChild(
+ cal.invitation.createAttendeesList(document, attendees)
+ );
+ }
+ }
+
+ /**
+ * Updates the location, creating a link if the value is a URL.
+ *
+ * @param {string} location - The value of the location property.
+ */
+ updateLocation(location) {
+ this.querySelector(".location-row").removeAttribute("hidden");
+ let urlMatch = location.match(/(https?:\/\/[^ ]*)/);
+ let url = urlMatch && urlMatch[1];
+ let itemLocation = this.querySelector(".item-location");
+ if (url) {
+ let link = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
+ link.setAttribute("class", "item-location-link text-link");
+ link.setAttribute("href", url);
+ link.title = url;
+ link.setAttribute("onclick", "launchBrowser(this.getAttribute('href'), event)");
+ link.setAttribute("oncommand", "launchBrowser(this.getAttribute('href'), event)");
+
+ let label = document.createXULElement("label");
+ label.setAttribute("context", "location-link-context-menu");
+ label.textContent = location;
+ link.appendChild(label);
+
+ itemLocation.replaceChildren(link);
+ } else {
+ itemLocation.textContent = location;
+ }
+ }
+
+ /**
+ * Update the organizer part of the UI.
+ *
+ * @param {calIItemBase} item - The calendar item.
+ */
+ updateOrganizer(item) {
+ this.querySelector(".item-organizer-row").removeAttribute("hidden");
+ let organizerLabel = cal.invitation.createAttendeeLabel(
+ document,
+ item.organizer,
+ item.getAttendees()
+ );
+ let organizerName = organizerLabel.querySelector(".attendee-name");
+ organizerName.classList.add("text-link");
+ organizerName.addEventListener("click", () => sendMailToOrganizer(this.mItem));
+ this.querySelector(".item-organizer-cell").appendChild(organizerLabel);
+ }
+
+ /**
+ * Update the status part of the UI.
+ *
+ * @param {string} status - The status of the calendar item.
+ * @param {boolean} isToDoItem - True if the calendar item is a todo, false if an event.
+ */
+ updateStatus(status, isToDoItem) {
+ let statusRow = this.querySelector(".status-row");
+ let statusRowData = this.querySelector(".status-row-td");
+
+ for (let i = 0; i < statusRowData.children.length; i++) {
+ if (statusRowData.children[i].getAttribute("status") == status) {
+ statusRow.removeAttribute("hidden");
+
+ if (status == "CANCELLED" && isToDoItem) {
+ // There are two status elements for CANCELLED, the second one is for
+ // todo items. Increment the counter here.
+ i++;
+ }
+ statusRowData.children[i].removeAttribute("hidden");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Update the description part of the UI.
+ *
+ * @param {string} descriptionText - The value of the DESCRIPTION property.
+ * @param {string} descriptionHTML - HTML description if available.
+ */
+ async updateDescription(descriptionText, descriptionHTML) {
+ this.querySelector(".item-description-box").removeAttribute("hidden");
+ let itemDescription = this.querySelector(".item-description");
+ if (itemDescription.contentDocument.readyState != "complete") {
+ // The iframe's document hasn't loaded yet. If we add to it now, what we add will be
+ // overwritten. Wait for the initial document to load.
+ await new Promise(resolve => {
+ itemDescription._listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ onStateChange(webProgress, request, stateFlags, status) {
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ itemDescription.browsingContext.webProgress.removeProgressListener(this);
+ delete itemDescription._listener;
+ resolve();
+ }
+ },
+ };
+ itemDescription.browsingContext.webProgress.addProgressListener(
+ itemDescription._listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ });
+ }
+ let docFragment = cal.view.textToHtmlDocumentFragment(
+ descriptionText,
+ itemDescription.contentDocument,
+ descriptionHTML
+ );
+
+ // Make any links open in the user's default browser, not in Thunderbird.
+ for (let anchor of docFragment.querySelectorAll("a")) {
+ anchor.addEventListener("click", function (event) {
+ event.preventDefault();
+ if (event.isTrusted) {
+ launchBrowser(anchor.getAttribute("href"), event);
+ }
+ });
+ }
+
+ itemDescription.contentDocument.body.appendChild(docFragment);
+
+ const link = itemDescription.contentDocument.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "chrome://messenger/skin/shared/editorContent.css";
+ itemDescription.contentDocument.head.appendChild(link);
+ }
+
+ /**
+ * Update the attachments part of the UI.
+ *
+ * @param {calIAttachment[]} attachments - Array of attachment objects.
+ */
+ updateAttachments(attachments) {
+ // We only want to display URI type attachments and no ones received inline with the
+ // invitation message (having a CID: prefix results in about:blank) here.
+ let attCounter = 0;
+ attachments.forEach(aAttachment => {
+ if (aAttachment.uri && aAttachment.uri.spec != "about:blank") {
+ let attachment = this.querySelector(".attachment-template").cloneNode(true);
+ attachment.removeAttribute("id");
+ attachment.removeAttribute("hidden");
+
+ let label = attachment.querySelector("label");
+ label.setAttribute("value", aAttachment.uri.spec);
+
+ label.addEventListener("click", () => {
+ openAttachmentFromItemSummary(aAttachment.hashId, this.mItem);
+ });
+
+ let icon = attachment.querySelector("img");
+ let iconSrc = aAttachment.uri.spec.length ? aAttachment.uri.spec : "dummy.html";
+ if (aAttachment.uri && !aAttachment.uri.schemeIs("file")) {
+ // Using an uri directly, with e.g. a http scheme, wouldn't render any icon.
+ if (aAttachment.formatType) {
+ iconSrc = "goat?contentType=" + aAttachment.formatType;
+ } else {
+ // Let's try to auto-detect.
+ let parts = iconSrc.substr(aAttachment.uri.scheme.length + 2).split("/");
+ if (parts.length) {
+ iconSrc = parts[parts.length - 1];
+ }
+ }
+ }
+ icon.setAttribute("src", "moz-icon://" + iconSrc);
+
+ this.querySelector(".item-attachment-cell").appendChild(attachment);
+ attCounter++;
+ }
+ });
+
+ if (attCounter > 0) {
+ this.querySelector(".attachments-row").removeAttribute("hidden");
+ }
+ }
+ }
+
+ customElements.define("calendar-item-summary", CalendarItemSummary);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-minidate.js b/comm/calendar/base/content/widgets/calendar-minidate.js
new file mode 100644
index 0000000000..ebc270bd5b
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minidate.js
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const format = new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+
+ const parts = ["month", "day", "year"];
+
+ function getParts(date) {
+ return format.formatToParts(date).reduce((prev, curr) => {
+ if (parts.includes(curr.type)) {
+ prev[curr.type] = curr.value;
+ }
+ return prev;
+ }, {});
+ }
+
+ /**
+ * CalendarMinidate displays a date in a visually appealing box meant to be
+ * glanced at quickly to figure out the date of an event.
+ */
+ class CalendarMinidate extends HTMLElement {
+ /**
+ * @type {HTMLElement}
+ */
+ _monthSpan;
+
+ /**
+ * @type {HTMLElement}
+ */
+ _daySpan;
+
+ /**
+ * @type {HTMLElement}
+ */
+ _yearSpan;
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ document.l10n.connectRoot(this.shadowRoot);
+ this.shadowRoot.appendChild(
+ document.getElementById("calendarMinidate").content.cloneNode(true)
+ );
+ this._monthSpan = this.shadowRoot.querySelector(".calendar-minidate-month");
+ this._daySpan = this.shadowRoot.querySelector(".calendar-minidate-day");
+ this._yearSpan = this.shadowRoot.querySelector(".calendar-minidate-year");
+ }
+
+ /**
+ * Setting the date property will trigger the rendering of this widget.
+ *
+ * @type {calIDateTime}
+ */
+ set date(value) {
+ let { month, day, year } = getParts(cal.dtz.dateTimeToJsDate(value));
+ this._monthSpan.textContent = month;
+ this._daySpan.textContent = day;
+ this._yearSpan.textContent = year;
+ }
+
+ /**
+ * Provides the displayed date as a string in the format
+ * "month day year".
+ *
+ * @type {string}
+ */
+ get fullDate() {
+ return `${this._monthSpan.textContent} ${this._daySpan.textContent} ${this._yearSpan.textContent}`;
+ }
+ }
+ customElements.define("calendar-minidate", CalendarMinidate);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-minidate.xhtml b/comm/calendar/base/content/widgets/calendar-minidate.xhtml
new file mode 100644
index 0000000000..ab8f4ecba2
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minidate.xhtml
@@ -0,0 +1,17 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+<!-- Template for <calendar-minidate /> -->
+<template id="calendarMinidate" xmlns="http://www.w3.org/1999/xhtml">
+ <div class="calendar-minidate-wrapper">
+ <link rel="stylesheet" href="chrome://calendar/skin/shared/widgets/calendar-minidate.css"/>
+ <div class="calendar-minidate-header">
+ <span class="calendar-minidate-month"></span>
+ </div>
+ <div class="calendar-minidate-body">
+ <span class="calendar-minidate-day"></span>
+ <span class="calendar-minidate-year"></span>
+ </div>
+ </div>
+</template>
diff --git a/comm/calendar/base/content/widgets/calendar-minimonth.js b/comm/calendar/base/content/widgets/calendar-minimonth.js
new file mode 100644
index 0000000000..403841e69c
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-minimonth.js
@@ -0,0 +1,1055 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals cal MozXULElement */
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+ const lazy = {};
+ ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+
+ let dayFormatter = new Services.intl.DateTimeFormat(undefined, { day: "numeric" });
+ let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "long" });
+
+ /**
+ * MiniMonth Calendar: day-of-month grid component.
+ * Displays month name and year above grid of days of month by week rows.
+ * Arrows move forward or back a month or a year.
+ * Clicking on a day cell selects that day.
+ * At site, can provide id, and code to run when value changed by picker.
+ * <calendar-minimonth id="my-date-picker" onchange="myDatePick( this );"/>
+ *
+ * May get/set value in javascript with
+ * document.querySelector("#my-date-picker").value = new Date();
+ *
+ * @implements {calIObserver}
+ * @implements {calICompositeObserver}
+ */
+ class CalendarMinimonth extends MozXULElement {
+ constructor() {
+ super();
+ // Set up custom interfaces.
+ this.calIObserver = this.getCustomInterfaceCallback(Ci.calIObserver);
+ this.calICompositeObserver = this.getCustomInterfaceCallback(Ci.calICompositeObserver);
+
+ let onPreferenceChanged = () => {
+ this.dayBoxes.clear(); // Days have moved, force a refresh of the grid.
+ this.refreshDisplay();
+ };
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "weekStart",
+ "calendar.week.start",
+ 0,
+ onPreferenceChanged
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "showWeekNumber",
+ "calendar.view-minimonth.showWeekNumber",
+ true,
+ onPreferenceChanged
+ );
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".minimonth-header": "readonly,month,year",
+ ".minimonth-year-name": "value=year",
+ };
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback() || this.hasChildNodes()) {
+ return;
+ }
+
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl");
+
+ const minimonthHeader = `
+ <html:div class="minimonth-header minimonth-month-box"
+ xmlns="http://www.w3.org/1999/xhtml">
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn today-button"
+ data-l10n-id="calendar-today-button-tooltip"
+ type="button"
+ dir="0">
+ </button>
+ </div>
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn months-back-button"
+ data-l10n-id="calendar-nav-button-prev-tooltip-month"
+ type="button"
+ dir="-1">
+ </button>
+ <div class="minimonth-nav-item">
+ <input class="minimonth-month-name" tabindex="-1" readonly="true" disabled="disabled" />
+ </div>
+ <button class="button icon-button icon-only minimonth-nav-btn months-forward-button"
+ data-l10n-id="calendar-nav-button-next-tooltip-month"
+ type="button"
+ dir="1">
+ </button>
+ </div>
+ <div class="minimonth-nav-section">
+ <button class="button icon-button icon-only minimonth-nav-btn years-back-button"
+ data-l10n-id="calendar-nav-button-prev-tooltip-year"
+ type="button"
+ dir="-1">
+ </button>
+ <div class="minimonth-nav-item">
+ <input class="yearcell minimonth-year-name" tabindex="-1" readonly="true" disabled="disabled" />
+ </div>
+ <button class="button icon-button icon-only minimonth-nav-btn years-forward-button"
+ data-l10n-id="calendar-nav-button-next-tooltip-year"
+ type="button"
+ dir="1">
+ </button>
+ </div>
+ </html:div>
+ `;
+
+ const minimonthWeekRow = `
+ <html:tr class="minimonth-row-body">
+ <html:th class="minimonth-week" scope="row"></html:th>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ <html:td class="minimonth-day" tabindex="-1"></html:td>
+ </html:tr>
+ `;
+
+ this.appendChild(
+ MozXULElement.parseXULToFragment(
+ `
+ ${minimonthHeader}
+ <html:div class="minimonth-readonly-header minimonth-month-box"></html:div>
+ <html:table class="minimonth-calendar minimonth-cal-box">
+ <html:tr class="minimonth-row-head">
+ <html:th class="minimonth-row-header-week" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ <html:th class="minimonth-row-header" scope="col"></html:th>
+ </html:tr>
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ ${minimonthWeekRow}
+ </html:table>
+ `,
+ ["chrome://calendar/locale/global.dtd"]
+ )
+ );
+ this.initializeAttributeInheritance();
+ this.setAttribute("orient", "vertical");
+
+ // Set up header buttons.
+ this.querySelector(".months-back-button").addEventListener("click", () =>
+ this.advanceMonth(-1)
+ );
+ this.querySelector(".months-forward-button").addEventListener("click", () =>
+ this.advanceMonth(1)
+ );
+ this.querySelector(".years-back-button").addEventListener("click", () =>
+ this.advanceYear(-1)
+ );
+ this.querySelector(".years-forward-button").addEventListener("click", () =>
+ this.advanceYear(1)
+ );
+ this.querySelector(".today-button").addEventListener("click", () => {
+ this.value = new Date();
+ });
+
+ this.dayBoxes = new Map();
+ this.mValue = null;
+ this.mEditorDate = null;
+ this.mExtraDate = null;
+ this.mPixelScrollDelta = 0;
+ this.mObservesComposite = false;
+ this.mToday = null;
+ this.mSelected = null;
+ this.mExtra = null;
+ this.mValue = new Date(); // Default to "today".
+ this.mFocused = null;
+
+ let width = 0;
+ // Start loop from 1 as it is needed to get the first month name string
+ // and avoid extra computation of adding one.
+ for (let i = 1; i <= 12; i++) {
+ let dateString = cal.l10n.getDateFmtString(`month.${i}.name`);
+ width = Math.max(dateString.length, width);
+ }
+ this.querySelector(".minimonth-month-name").style.width = `${width + 1}ch`;
+
+ this.refreshDisplay();
+ if (this.hasAttribute("freebusy")) {
+ this._setFreeBusy(this.getAttribute("freebusy") == "true");
+ }
+
+ // Add event listeners.
+ this.addEventListener("click", event => {
+ if (event.button == 0 && event.target.classList.contains("minimonth-day")) {
+ this.onDayActivate(event);
+ }
+ });
+
+ this.addEventListener("keypress", event => {
+ if (event.target.classList.contains("minimonth-day")) {
+ if (event.altKey || event.metaKey) {
+ return;
+ }
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_LEFT:
+ this.onDayMovement(event, 0, 0, -1);
+ break;
+ case KeyEvent.DOM_VK_RIGHT:
+ this.onDayMovement(event, 0, 0, 1);
+ break;
+ case KeyEvent.DOM_VK_UP:
+ this.onDayMovement(event, 0, 0, -7);
+ break;
+ case KeyEvent.DOM_VK_DOWN:
+ this.onDayMovement(event, 0, 0, 7);
+ break;
+ case KeyEvent.DOM_VK_PAGE_UP:
+ if (event.shiftKey) {
+ this.onDayMovement(event, -1, 0, 0);
+ } else {
+ this.onDayMovement(event, 0, -1, 0);
+ }
+ break;
+ case KeyEvent.DOM_VK_PAGE_DOWN:
+ if (event.shiftKey) {
+ this.onDayMovement(event, 1, 0, 0);
+ } else {
+ this.onDayMovement(event, 0, 1, 0);
+ }
+ break;
+ case KeyEvent.DOM_VK_ESCAPE:
+ this.focusDate(this.mValue || this.mExtraDate);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ case KeyEvent.DOM_VK_HOME: {
+ const today = new Date();
+ this.update(today);
+ this.focusDate(today);
+ event.stopPropagation();
+ event.preventDefault();
+ break;
+ }
+ case KeyEvent.DOM_VK_RETURN:
+ this.onDayActivate(event);
+ break;
+ }
+ }
+ });
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 150;
+ let deltaView = 0;
+ if (this.getAttribute("readonly") == "true") {
+ // No scrolling on readonly months.
+ return;
+ }
+ if (event.deltaMode == event.DOM_DELTA_LINE || event.deltaMode == event.DOM_DELTA_PAGE) {
+ if (event.deltaY != 0) {
+ deltaView = event.deltaY > 0 ? 1 : -1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ this.mPixelScrollDelta += event.deltaY;
+ if (this.mPixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.mPixelScrollDelta = 0;
+ } else if (this.mPixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.mPixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ const classList = event.target.classList;
+
+ if (
+ classList.contains("years-forward-button") ||
+ classList.contains("yearcell") ||
+ classList.contains("years-back-button")
+ ) {
+ this.advanceYear(deltaView);
+ } else if (!classList.contains("today-button")) {
+ this.advanceMonth(deltaView);
+ }
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+ }
+
+ set value(val) {
+ this.update(val);
+ }
+
+ get value() {
+ return this.mValue;
+ }
+
+ set extra(val) {
+ this.mExtraDate = val;
+ }
+
+ get extra() {
+ return this.mExtraDate;
+ }
+
+ /**
+ * Returns the first (inclusive) date of the minimonth as a calIDateTime object.
+ */
+ get firstDate() {
+ let date = this._getCalBoxNode(1, 1).date;
+ return cal.dtz.jsDateToDateTime(date);
+ }
+
+ /**
+ * Returns the last (exclusive) date of the minimonth as a calIDateTime object.
+ */
+ get lastDate() {
+ let date = this._getCalBoxNode(6, 7).date;
+ let lastDateTime = cal.dtz.jsDateToDateTime(date);
+ lastDateTime.day = lastDateTime.day + 1;
+ return lastDateTime;
+ }
+
+ get mReadOnlyHeader() {
+ return this.querySelector(".minimonth-readonly-header");
+ }
+
+ setBusyDaysForItem(aItem, aState) {
+ let items = aItem.recurrenceInfo
+ ? aItem.getOccurrencesBetween(this.firstDate, this.lastDate)
+ : [aItem];
+ items.forEach(item => this.setBusyDaysForOccurrence(item, aState));
+ }
+
+ parseBoxBusy(aBox) {
+ let boxBusy = {};
+
+ let busyStr = aBox.getAttribute("busy");
+ if (busyStr && busyStr.length > 0) {
+ let calChunks = busyStr.split("\u001A");
+ for (let chunk of calChunks) {
+ let expr = chunk.split("=");
+ boxBusy[expr[0]] = parseInt(expr[1], 10);
+ }
+ }
+
+ return boxBusy;
+ }
+
+ updateBoxBusy(aBox, aBoxBusy) {
+ let calChunks = [];
+
+ for (let calId in aBoxBusy) {
+ if (aBoxBusy[calId]) {
+ calChunks.push(calId + "=" + aBoxBusy[calId]);
+ }
+ }
+
+ if (calChunks.length > 0) {
+ let busyStr = calChunks.join("\u001A");
+ aBox.setAttribute("busy", busyStr);
+ } else {
+ aBox.removeAttribute("busy");
+ }
+ }
+
+ removeCalendarFromBoxBusy(aBox, aCalendar) {
+ let boxBusy = this.parseBoxBusy(aBox);
+ if (boxBusy[aCalendar.id]) {
+ delete boxBusy[aCalendar.id];
+ }
+ this.updateBoxBusy(aBox, boxBusy);
+ }
+
+ setBusyDaysForOccurrence(aOccurrence, aState) {
+ if (aOccurrence.getProperty("TRANSP") == "TRANSPARENT") {
+ // Skip transparent events.
+ return;
+ }
+ let start = aOccurrence[cal.dtz.startDateProp(aOccurrence)] || aOccurrence.dueDate;
+ let end = aOccurrence[cal.dtz.endDateProp(aOccurrence)] || start;
+ if (!start) {
+ return;
+ }
+
+ if (start.compare(this.firstDate) < 0) {
+ start = this.firstDate.clone();
+ }
+
+ if (end.compare(this.lastDate) > 0) {
+ end = this.lastDate.clone();
+ end.day++;
+ }
+
+ // We need to compare with midnight of the current day, so reset the
+ // time here.
+ let current = start.clone().getInTimezone(cal.dtz.defaultTimezone);
+ current.hour = 0;
+ current.minute = 0;
+ current.second = 0;
+
+ // Cache the result so the compare isn't called in each iteration.
+ let compareResult = start.compare(end) == 0 ? 1 : 0;
+
+ // Setup the busy days.
+ while (current.compare(end) < compareResult) {
+ let box = this.getBoxForDate(current);
+ if (box) {
+ let busyCalendars = this.parseBoxBusy(box);
+ if (!busyCalendars[aOccurrence.calendar.id]) {
+ busyCalendars[aOccurrence.calendar.id] = 0;
+ }
+ busyCalendars[aOccurrence.calendar.id] += aState ? 1 : -1;
+ this.updateBoxBusy(box, busyCalendars);
+ }
+ current.day++;
+ }
+ }
+
+ // calIObserver methods.
+ calendarsInBatch = new Set();
+
+ onStartBatch(aCalendar) {
+ this.calendarsInBatch.add(aCalendar);
+ }
+
+ onEndBatch(aCalendar) {
+ this.calendarsInBatch.delete(aCalendar);
+ }
+
+ onLoad(aCalendar) {
+ this.getItems(aCalendar);
+ }
+
+ onAddItem(aItem) {
+ if (this.calendarsInBatch.has(aItem.calendar)) {
+ return;
+ }
+
+ this.setBusyDaysForItem(aItem, true);
+ }
+
+ onDeleteItem(aItem) {
+ this.setBusyDaysForItem(aItem, false);
+ }
+
+ onModifyItem(aNewItem, aOldItem) {
+ if (this.calendarsInBatch.has(aNewItem.calendar)) {
+ return;
+ }
+
+ this.setBusyDaysForItem(aOldItem, false);
+ this.setBusyDaysForItem(aNewItem, true);
+ }
+
+ onError(aCalendar, aErrNo, aMessage) {}
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ switch (aName) {
+ case "disabled":
+ this.resetAttributesForDate();
+ this.getItems();
+ break;
+ }
+ }
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.onPropertyChanged(aCalendar, aName, null, null);
+ }
+
+ // End of calIObserver methods.
+ // calICompositeObserver methods.
+
+ onCalendarAdded(aCalendar) {
+ if (!aCalendar.getProperty("disabled")) {
+ this.getItems(aCalendar);
+ }
+ }
+
+ onCalendarRemoved(aCalendar) {
+ if (!aCalendar.getProperty("disabled")) {
+ for (let box of this.dayBoxes.values()) {
+ this.removeCalendarFromBoxBusy(box, aCalendar);
+ }
+ }
+ }
+
+ onDefaultCalendarChanged(aCalendar) {}
+
+ // End calICompositeObserver methods.
+
+ refreshDisplay() {
+ if (!this.mValue) {
+ this.mValue = new Date();
+ }
+ this.setHeader();
+ this.showMonth(this.mValue);
+ this.updateAccessibleLabel();
+ }
+
+ _getCalBoxNode(aRow, aCol) {
+ if (!this.mCalBox) {
+ this.mCalBox = this.querySelector(".minimonth-calendar");
+ }
+ return this.mCalBox.children[aRow].children[aCol];
+ }
+
+ setHeader() {
+ // Reset the headers.
+ let dayList = new Array(7);
+ let longDayList = new Array(7);
+ let tempDate = new Date();
+ let i, j;
+ let useOSFormat;
+ tempDate.setDate(tempDate.getDate() - (tempDate.getDay() - this.weekStart));
+ for (i = 0; i < 7; i++) {
+ // If available, use UILocale days, else operating system format.
+ try {
+ dayList[i] = cal.l10n.getDateFmtString(`day.${tempDate.getDay() + 1}.short`);
+ } catch (e) {
+ dayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "short" });
+ useOSFormat = true;
+ }
+ longDayList[i] = tempDate.toLocaleDateString(undefined, { weekday: "long" });
+ tempDate.setDate(tempDate.getDate() + 1);
+ }
+
+ if (useOSFormat) {
+ // To keep datepicker popup compact, shrink localized weekday
+ // abbreviations down to 1 or 2 chars so each column of week can
+ // be as narrow as 2 digits.
+ //
+ // 1. Compute the minLength of the day name abbreviations.
+ let minLength = dayList.map(name => name.length).reduce((min, len) => Math.min(min, len));
+
+ // 2. If some day name abbrev. is longer than 2 chars (not Catalan),
+ // and ALL localized day names share same prefix (as in Chinese),
+ // then trim shared "day-" prefix.
+ if (dayList.some(dayAbbr => dayAbbr.length > 2)) {
+ for (let endPrefix = 0; endPrefix < minLength; endPrefix++) {
+ let suffix = dayList[0][endPrefix];
+ if (dayList.some(dayAbbr => dayAbbr[endPrefix] != suffix)) {
+ if (endPrefix > 0) {
+ for (i = 0; i < dayList.length; i++) {
+ // trim prefix chars.
+ dayList[i] = dayList[i].substring(endPrefix);
+ }
+ }
+ break;
+ }
+ }
+ }
+ // 3. Trim each day abbreviation to 1 char if unique, else 2 chars.
+ for (i = 0; i < dayList.length; i++) {
+ let foundMatch = 1;
+ for (j = 0; j < dayList.length; j++) {
+ if (i != j) {
+ if (dayList[i].substring(0, 1) == dayList[j].substring(0, 1)) {
+ foundMatch = 2;
+ break;
+ }
+ }
+ }
+ dayList[i] = dayList[i].substring(0, foundMatch);
+ }
+ }
+
+ this._getCalBoxNode(0, 0).hidden = !this.showWeekNumber;
+ for (let column = 1; column < 8; column++) {
+ let node = this._getCalBoxNode(0, column);
+ node.textContent = dayList[column - 1];
+ node.setAttribute("aria-label", longDayList[column - 1]);
+ }
+ }
+
+ showMonth(aDate) {
+ // Use mExtraDate if aDate is null.
+ aDate = new Date(aDate || this.mExtraDate);
+
+ aDate.setDate(1);
+ // We set the hour and minute to something highly unlikely to be the
+ // exact change point of DST, so timezones like America/Sao Paulo
+ // don't display some days twice.
+ aDate.setHours(12);
+ aDate.setMinutes(34);
+ aDate.setSeconds(0);
+ aDate.setMilliseconds(0);
+ // Don't fire onmonthchange event upon initialization
+ let monthChanged = this.mEditorDate && this.mEditorDate.valueOf() != aDate.valueOf();
+ this.mEditorDate = aDate; // Only place mEditorDate is set.
+
+ if (this.mSelected) {
+ this.mSelected.removeAttribute("selected");
+ this.mSelected = null;
+ }
+
+ // Get today's date.
+ let today = new Date();
+
+ if (!monthChanged && this.dayBoxes.size > 0) {
+ this.mSelected = this.getBoxForDate(this.value);
+ if (this.mSelected) {
+ this.mSelected.setAttribute("selected", "true");
+ }
+
+ let todayBox = this.getBoxForDate(today);
+ if (this.mToday != todayBox) {
+ if (this.mToday) {
+ this.mToday.removeAttribute("today");
+ }
+ this.mToday = todayBox;
+ if (this.mToday) {
+ this.mToday.setAttribute("today", "true");
+ }
+ }
+ return;
+ }
+
+ if (this.mToday) {
+ this.mToday.removeAttribute("today");
+ this.mToday = null;
+ }
+
+ if (this.mExtra) {
+ this.mExtra.removeAttribute("extra");
+ this.mExtra = null;
+ }
+
+ // Update the month and year title.
+ this.setAttribute("year", aDate.getFullYear());
+ this.setAttribute("month", aDate.getMonth());
+
+ let miniMonthName = this.querySelector(".minimonth-month-name");
+ let dateString = cal.l10n.getDateFmtString(`month.${aDate.getMonth() + 1}.name`);
+ miniMonthName.setAttribute("value", dateString);
+ miniMonthName.setAttribute("monthIndex", aDate.getMonth());
+ this.mReadOnlyHeader.textContent = dateString + " " + aDate.getFullYear();
+
+ // Update the calendar.
+ let calbox = this.querySelector(".minimonth-calendar");
+ let date = this._getStartDate(aDate);
+
+ if (aDate.getFullYear() == (this.mValue || this.mExtraDate).getFullYear()) {
+ calbox.setAttribute("aria-label", dateString);
+ } else {
+ let monthName = cal.l10n.formatMonth(aDate.getMonth() + 1, "calendar", "monthInYear");
+ let label = cal.l10n.getCalString("monthInYear", [monthName, aDate.getFullYear()]);
+ calbox.setAttribute("aria-label", label);
+ }
+
+ this.dayBoxes.clear();
+ let defaultTz = cal.dtz.defaultTimezone;
+ for (let k = 1; k < 7; k++) {
+ // Set the week number.
+ let firstElement = this._getCalBoxNode(k, 0);
+ firstElement.hidden = !this.showWeekNumber;
+ if (this.showWeekNumber) {
+ let weekNumber = cal.weekInfoService.getWeekTitle(
+ cal.dtz.jsDateToDateTime(date, defaultTz)
+ );
+ let weekTitle = cal.l10n.getCalString("WeekTitle", [weekNumber]);
+ firstElement.textContent = weekNumber;
+ firstElement.setAttribute("aria-label", weekTitle);
+ }
+
+ for (let i = 1; i < 8; i++) {
+ let day = this._getCalBoxNode(k, i);
+ this.setBoxForDate(date, day);
+
+ if (this.getAttribute("readonly") != "true") {
+ day.setAttribute("interactive", "true");
+ }
+
+ if (aDate.getMonth() == date.getMonth()) {
+ day.removeAttribute("othermonth");
+ } else {
+ day.setAttribute("othermonth", "true");
+ }
+
+ // Highlight today.
+ if (this._sameDay(today, date)) {
+ this.mToday = day;
+ day.setAttribute("today", "true");
+ }
+
+ // Highlight the current date.
+ let val = this.value;
+ if (this._sameDay(val, date)) {
+ this.mSelected = day;
+ day.setAttribute("selected", "true");
+ }
+
+ // Highlight the extra date.
+ if (this._sameDay(this.mExtraDate, date)) {
+ this.mExtra = day;
+ day.setAttribute("extra", "true");
+ }
+
+ if (aDate.getMonth() == date.getMonth() && aDate.getFullYear() == date.getFullYear()) {
+ day.setAttribute("aria-label", dayFormatter.format(date));
+ } else {
+ day.setAttribute("aria-label", dateFormatter.format(date));
+ }
+
+ day.removeAttribute("busy");
+
+ day.date = new Date(date);
+ day.textContent = date.getDate();
+ date.setDate(date.getDate() + 1);
+
+ this.resetAttributesForBox(day);
+ }
+ }
+
+ if (!this.mFocused) {
+ this.setFocusedDate(this.mValue || this.mExtraDate);
+ }
+
+ this.fireEvent("monthchange");
+
+ if (this.getAttribute("freebusy") == "true") {
+ this.getItems();
+ }
+ }
+
+ /**
+ * Attention - duplicate!!!!
+ */
+ fireEvent(aEventName) {
+ this.dispatchEvent(new CustomEvent(aEventName, { bubbles: true }));
+ }
+
+ _boxKeyForDate(aDate) {
+ if (aDate instanceof lazy.CalDateTime || aDate instanceof Ci.calIDateTime) {
+ return aDate.getInTimezone(cal.dtz.defaultTimezone).toString().substring(0, 10);
+ }
+ return [
+ aDate.getFullYear(),
+ (aDate.getMonth() + 1).toString().padStart(2, "0"),
+ aDate.getDate().toString().padStart(2, "0"),
+ ].join("-");
+ }
+
+ /**
+ * Fetches the table cell for the given date, or null if the date isn't displayed.
+ *
+ * @param {calIDateTime|Date} aDate
+ * @returns {HTMLTableCellElement|null}
+ */
+ getBoxForDate(aDate) {
+ return this.dayBoxes.get(this._boxKeyForDate(aDate)) ?? null;
+ }
+
+ /**
+ * Stores the table cell for the given date.
+ *
+ * @param {Date} aDate
+ * @param {HTMLTableCellElement} aBox
+ */
+ setBoxForDate(aDate, aBox) {
+ this.dayBoxes.set(this._boxKeyForDate(aDate), aBox);
+ }
+
+ /**
+ * Remove attributes that may have been added to a table cell.
+ *
+ * @param {HTMLTableCellElement} aBox
+ */
+ resetAttributesForBox(aBox) {
+ let allowedAttributes = 0;
+ while (aBox.attributes.length > allowedAttributes) {
+ switch (aBox.attributes[allowedAttributes].nodeName) {
+ case "selected":
+ case "othermonth":
+ case "today":
+ case "extra":
+ case "interactive":
+ case "class":
+ case "tabindex":
+ case "role":
+ case "aria-label":
+ allowedAttributes++;
+ break;
+ default:
+ aBox.removeAttribute(aBox.attributes[allowedAttributes].nodeName);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Remove attributes that may have been added to a table cell, or all table cells.
+ *
+ * @param {Date} [aDate] - If specified, the date of the cell to reset,
+ * otherwise all date cells will be reset.
+ */
+ resetAttributesForDate(aDate) {
+ if (aDate) {
+ let box = this.getBoxForDate(aDate);
+ if (box) {
+ this.resetAttributesForBox(box);
+ }
+ } else {
+ for (let k = 1; k < 7; k++) {
+ for (let i = 1; i < 8; i++) {
+ this.resetAttributesForBox(this._getCalBoxNode(k, i));
+ }
+ }
+ }
+ }
+
+ _setFreeBusy(aFreeBusy) {
+ if (aFreeBusy) {
+ if (!this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).addObserver(this.calICompositeObserver);
+ this.mObservesComposite = true;
+ this.getItems();
+ }
+ } else if (this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver);
+ this.mObservesComposite = false;
+ }
+ }
+
+ removeAttribute(aAttr) {
+ if (aAttr == "freebusy") {
+ this._setFreeBusy(false);
+ }
+ return super.removeAttribute(aAttr);
+ }
+
+ setAttribute(aAttr, aVal) {
+ if (aAttr == "freebusy") {
+ this._setFreeBusy(aVal == "true");
+ }
+ return super.setAttribute(aAttr, aVal);
+ }
+
+ async getItems(aCalendar) {
+ // The minimonth automatically clears extra styles on a month change.
+ // Therefore we only need to fill the minimonth with new info.
+
+ let calendar = aCalendar || cal.view.getCompositeCalendar(window);
+ let filter =
+ calendar.ITEM_FILTER_COMPLETED_ALL |
+ calendar.ITEM_FILTER_CLASS_OCCURRENCES |
+ calendar.ITEM_FILTER_ALL_ITEMS;
+
+ // Get new info.
+ for await (let items of cal.iterate.streamValues(
+ calendar.getItems(filter, 0, this.firstDate, this.lastDate)
+ )) {
+ items.forEach(item => this.setBusyDaysForOccurrence(item, true));
+ }
+ }
+
+ updateAccessibleLabel() {
+ let label;
+ if (this.mValue) {
+ label = dateFormatter.format(this.mValue);
+ } else {
+ label = cal.l10n.getCalString("minimonthNoSelectedDate");
+ }
+ this.setAttribute("aria-label", label);
+ }
+
+ update(aValue) {
+ let changed =
+ this.mValue &&
+ aValue &&
+ (this.mValue.getFullYear() != aValue.getFullYear() ||
+ this.mValue.getMonth() != aValue.getMonth() ||
+ this.mValue.getDate() != aValue.getDate());
+
+ this.mValue = aValue;
+ if (changed) {
+ this.fireEvent("change");
+ }
+ this.showMonth(aValue);
+ if (aValue) {
+ this.setFocusedDate(aValue);
+ }
+ this.updateAccessibleLabel();
+ }
+
+ setFocusedDate(aDate, aForceFocus) {
+ let newFocused = this.getBoxForDate(aDate);
+ if (!newFocused) {
+ return;
+ }
+ if (this.mFocused) {
+ this.mFocused.setAttribute("tabindex", "-1");
+ }
+ this.mFocused = newFocused;
+ this.mFocused.setAttribute("tabindex", "0");
+ // Only actually move the focus if it is already in the calendar box.
+ if (!aForceFocus) {
+ let calbox = this.querySelector(".minimonth-calendar");
+ aForceFocus = calbox.contains(document.commandDispatcher.focusedElement);
+ }
+ if (aForceFocus) {
+ this.mFocused.focus();
+ }
+ }
+
+ focusDate(aDate) {
+ this.showMonth(aDate);
+ this.setFocusedDate(aDate);
+ }
+
+ switchMonth(aMonth) {
+ let newMonth = new Date(this.mEditorDate);
+ newMonth.setMonth(aMonth);
+ this.showMonth(newMonth);
+ }
+
+ switchYear(aYear) {
+ let newMonth = new Date(this.mEditorDate);
+ newMonth.setFullYear(aYear);
+ this.showMonth(newMonth);
+ }
+
+ selectDate(aDate, aMainDate) {
+ if (
+ !aMainDate ||
+ aDate < this._getStartDate(aMainDate) ||
+ aDate > this._getEndDate(aMainDate)
+ ) {
+ aMainDate = new Date(aDate);
+ aMainDate.setDate(1);
+ }
+ // Note that aMainDate and this.mEditorDate refer to the first day
+ // of the corresponding month.
+ let sameMonth = this._sameDay(aMainDate, this.mEditorDate);
+ let sameDate = this._sameDay(aDate, this.mValue);
+ if (!sameMonth && !sameDate) {
+ // Change month and select day.
+ this.mValue = aDate;
+ this.showMonth(aMainDate);
+ } else if (!sameMonth) {
+ // Change month only.
+ this.showMonth(aMainDate);
+ } else if (!sameDate) {
+ // Select day only.
+ let day = this.getBoxForDate(aDate);
+ if (this.mSelected) {
+ this.mSelected.removeAttribute("selected");
+ }
+ this.mSelected = day;
+ day.setAttribute("selected", "true");
+ this.mValue = aDate;
+ this.setFocusedDate(aDate);
+ }
+ }
+
+ _getStartDate(aMainDate) {
+ let date = new Date(aMainDate);
+ let firstWeekday = (7 + aMainDate.getDay() - this.weekStart) % 7;
+ date.setDate(date.getDate() - firstWeekday);
+ return date;
+ }
+
+ _getEndDate(aMainDate) {
+ let date = this._getStartDate(aMainDate);
+ let calbox = this.querySelector(".minimonth-calendar");
+ let days = (calbox.children.length - 1) * 7;
+ date.setDate(date.getDate() + days - 1);
+ return date;
+ }
+
+ _sameDay(aDate1, aDate2) {
+ if (
+ aDate1 &&
+ aDate2 &&
+ aDate1.getDate() == aDate2.getDate() &&
+ aDate1.getMonth() == aDate2.getMonth() &&
+ aDate1.getFullYear() == aDate2.getFullYear()
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ advanceMonth(aDir) {
+ let advEditorDate = new Date(this.mEditorDate); // At 1st of month.
+ let advMonth = this.mEditorDate.getMonth() + aDir;
+ advEditorDate.setMonth(advMonth);
+ this.showMonth(advEditorDate);
+ }
+
+ advanceYear(aDir) {
+ let advEditorDate = new Date(this.mEditorDate); // At 1st of month.
+ let advYear = this.mEditorDate.getFullYear() + aDir;
+ advEditorDate.setFullYear(advYear);
+ this.showMonth(advEditorDate);
+ }
+
+ moveDateByOffset(aYears, aMonths, aDays) {
+ const date = new Date(
+ this.mFocused.date.getFullYear() + aYears,
+ this.mFocused.date.getMonth() + aMonths,
+ this.mFocused.date.getDate() + aDays
+ );
+ this.focusDate(date);
+ }
+
+ focusCalendar() {
+ this.mFocused.focus();
+ }
+
+ onDayActivate(aEvent) {
+ // The associated date might change when setting this.value if month changes.
+ const date = aEvent.target.date;
+ if (this.getAttribute("readonly") != "true") {
+ this.value = date;
+ this.fireEvent("select");
+ }
+ this.setFocusedDate(date, true);
+ aEvent.stopPropagation();
+ aEvent.preventDefault();
+ }
+
+ onDayMovement(event, years, months, days) {
+ this.moveDateByOffset(years, months, days);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ disconnectedCallback() {
+ if (this.mObservesComposite) {
+ cal.view.getCompositeCalendar(window).removeObserver(this.calICompositeObserver);
+ }
+ }
+ }
+
+ MozXULElement.implementCustomInterface(CalendarMinimonth, [
+ Ci.calIObserver,
+ Ci.calICompositeObserver,
+ ]);
+ customElements.define("calendar-minimonth", CalendarMinimonth);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-modebox.js b/comm/calendar/base/content/widgets/calendar-modebox.js
new file mode 100644
index 0000000000..417c790e34
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-modebox.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ /**
+ * A calendar-modebox directly extends to a xul:box element with extra functionality. Like a
+ * xul:hbox it has a horizontal orientation. It is designed to be displayed only:
+ * 1) in given application modes (e.g "task" mode, "calendar" mode) and
+ * 2) only in relation to the "checked" attribute of a control (e.g. a command or checkbox).
+ *
+ * - The attribute "mode" denotes a comma-separated list of all modes that the modebox should
+ * not be collapsed in, e.g. `mode="calendar,task"`.
+ * - The attribute "current" denotes the current viewing mode.
+ * - The attribute "refcontrol" points to a control, either a "command", "checkbox" or other
+ * elements that support a "checked" attribute, that is often used to denote whether a
+ * modebox should be displayed or not. If "refcontrol" is set to the id of a command you
+ * can there set the oncommand attribute like:
+ * `oncommand='document.getElementById('my-mode-pane').togglePane(event)`.
+ * In case it is a checkbox element or derived checkbox element this is done automatically
+ * by listening to the event "CheckboxChange". So if the current application mode is one of
+ * the modes listed in the "mode" attribute it is additionally verified whether the element
+ * denoted by "refcontrol" is checked or not.
+ * - The attribute "collapsedinmodes" is a comma-separated list of the modes the modebox
+ * should be collapsed in (e.g. "mail,calendar"). For example, if the user collapses a
+ * modebox when in a given mode, that mode would be added to "collapsedinmodes". This
+ * attribute is made persistent across restarts.
+ *
+ * @augments {MozXULElement}
+ */
+ class CalendarModebox extends MozXULElement {
+ static get observedAttributes() {
+ return ["current"];
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.mRefControl = null;
+
+ if (this.hasAttribute("refcontrol")) {
+ this.mRefControl = document.getElementById(this.getAttribute("refcontrol"));
+ if (this.mRefControl && this.mRefControl.localName == "checkbox") {
+ this.mRefControl.addEventListener("CheckboxStateChange", this, true);
+ }
+ }
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name == "current" && oldValue != newValue) {
+ let display = this.isVisibleInMode(newValue);
+ this.setVisible(display, false, true);
+ }
+ }
+
+ get currentMode() {
+ return this.getAttribute("current");
+ }
+
+ /**
+ * The event handler for various events relevant to CalendarModebox.
+ *
+ * @param {Event} event - The event.
+ */
+ handleEvent(event) {
+ if (event.type == "CheckboxStateChange") {
+ this.onCheckboxStateChange(event);
+ }
+ }
+
+ /**
+ * A "mode attribute" contains comma-separated lists of values, for example:
+ * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in
+ * the "mode" attribute: `mode="mail,calendar,task"`. This function sets a new value for
+ * a given mode in a given "mode attribute".
+ *
+ * @param {string} attributeName - A "mode attribute" in which to set a new value.
+ * @param {string} value - A new value to set.
+ * @param {string} [mode=this.currentMode] - Set the value for this mode.
+ */
+ setModeAttribute(attributeName, value, mode = this.currentMode) {
+ if (!this.hasAttribute(attributeName)) {
+ return;
+ }
+ let attributeValues = this.getAttribute(attributeName).split(",");
+ let modes = this.getAttribute("mode").split(",");
+ attributeValues[modes.indexOf(mode)] = value;
+ this.setAttribute(attributeName, attributeValues.join(","));
+ }
+
+ /**
+ * A "mode attribute" contains comma-separated lists of values, for example:
+ * `modewidths="200,200,200"`. Each of these values corresponds to one of the modes in
+ * the "mode" attribute: `mode="mail,calendar,task"`. This function returns the value
+ * for a given mode in a given "mode attribute".
+ *
+ * @param {string} attributeName - A "mode attribute" to get a value from.
+ * @param {string} [mode=this.currentMode] - Get the value for this mode.
+ * @returns {string} The value found in the mode attribute or an empty string.
+ */
+ getModeAttribute(attributeName, mode = this.currentMode) {
+ if (!this.hasAttribute(attributeName)) {
+ return "";
+ }
+ let attributeValues = this.getAttribute(attributeName).split(",");
+ let modes = this.getAttribute("mode").split(",");
+ return attributeValues[modes.indexOf(mode)];
+ }
+
+ /**
+ * Sets the visibility (collapsed state) of this modebox and (optionally) updates the
+ * `collapsedinmode` attribute and (optionally) notifies the `refcontrol`.
+ *
+ * @param {boolean} visible - Whether the modebox should become visible or not.
+ * @param {boolean} [toPushModeCollapsedAttribute=true] - Whether to push the current mode
+ * to `collapsedinmodes` attribute.
+ * @param {boolean} [toNotifyRefControl=true] - Whether to notify the `refcontrol`.
+ */
+ setVisible(visible, toPushModeCollapsedAttribute = true, toNotifyRefControl = true) {
+ let pushModeCollapsedAttribute = toPushModeCollapsedAttribute === true;
+ let notifyRefControl = toNotifyRefControl === true;
+
+ let collapsedModes = [];
+ let modeIndex = -1;
+ let collapsedInMode = false;
+
+ if (this.hasAttribute("collapsedinmodes")) {
+ collapsedModes = this.getAttribute("collapsedinmodes").split(",");
+ modeIndex = collapsedModes.indexOf(this.currentMode);
+ collapsedInMode = modeIndex > -1;
+ }
+
+ let display = visible;
+ if (display && !pushModeCollapsedAttribute) {
+ display = !collapsedInMode;
+ }
+
+ this.collapsed = !display || !this.isVisibleInMode();
+
+ if (pushModeCollapsedAttribute) {
+ if (!display) {
+ if (modeIndex == -1) {
+ collapsedModes.push(this.currentMode);
+ if (this.getAttribute("collapsedinmodes") == ",") {
+ collapsedModes.splice(0, 2);
+ }
+ }
+ } else if (modeIndex > -1) {
+ collapsedModes.splice(modeIndex, 1);
+ if (collapsedModes.join(",") == "") {
+ collapsedModes[0] = ",";
+ }
+ }
+ this.setAttribute("collapsedinmodes", collapsedModes.join(","));
+
+ Services.xulStore.persist(this, "collapsedinmodes");
+ }
+
+ if (notifyRefControl && this.hasAttribute("refcontrol")) {
+ let command = document.getElementById(this.getAttribute("refcontrol"));
+ if (command) {
+ command.setAttribute("checked", display);
+ command.disabled = !this.isVisibleInMode();
+ }
+ }
+ }
+
+ /**
+ * Return whether this modebox is visible for a given mode, according to both its
+ * `mode` and `collapsedinmodes` attributes.
+ *
+ * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode?
+ * @returns {boolean} Whether this modebox is visible for the given mode.
+ */
+ isVisible(mode = this.currentMode) {
+ if (!this.isVisibleInMode(mode)) {
+ return false;
+ }
+ let collapsedModes = this.getAttribute("collapsedinmodes").split(",");
+ return !collapsedModes.includes(mode);
+ }
+
+ /**
+ * Returns whether this modebox is visible for a given mode, according to its
+ * `mode` attribute.
+ *
+ * @param {string} [mode=this.currentMode] - Is the modebox visible for this mode?
+ * @returns {boolean} Whether this modebox is visible for the given mode.
+ */
+ isVisibleInMode(mode = this.currentMode) {
+ return this.hasAttribute("mode") ? this.getAttribute("mode").split(",").includes(mode) : true;
+ }
+
+ /**
+ * Used to toggle the checked state of a command connected to this modebox, and set the
+ * visibility of this modebox accordingly.
+ *
+ * @param {Event} event - An event with a command (with a checked attribute) as its target.
+ */
+ togglePane(event) {
+ let command = event.target;
+ let newValue = command.getAttribute("checked") == "true" ? "false" : "true";
+ command.setAttribute("checked", newValue);
+ this.setVisible(newValue == "true", true, true);
+ }
+
+ /**
+ * Handles a change in a checkbox state, by making this modebox visible or not.
+ *
+ * @param {Event} event - An event with a target that has a `checked` attribute.
+ */
+ onCheckboxStateChange(event) {
+ let newValue = event.target.checked;
+ this.setVisible(newValue, true, true);
+ }
+ }
+
+ customElements.define("calendar-modebox", CalendarModebox);
+
+ /**
+ * A `calendar-modebox` but with a vertical orientation like a `vbox`. (Different Custom
+ * Elements cannot be defined using the same class, thus we need this subclass.)
+ *
+ * @augments {CalendarModebox}
+ */
+ class CalendarModevbox extends CalendarModebox {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ super.connectedCallback();
+ this.setAttribute("orient", "vertical");
+ }
+ }
+
+ customElements.define("calendar-modevbox", CalendarModevbox);
+}
diff --git a/comm/calendar/base/content/widgets/calendar-notifications-setting.js b/comm/calendar/base/content/widgets/calendar-notifications-setting.js
new file mode 100644
index 0000000000..1f772992c7
--- /dev/null
+++ b/comm/calendar/base/content/widgets/calendar-notifications-setting.js
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+
+ /**
+ * A calendar-notifications-setting provides controls to config notifications
+ * times of a calendar.
+ *
+ * @augments {MozXULElement}
+ */
+ class CalendarNotificationsSetting extends MozXULElement {
+ connectedCallback() {
+ MozXULElement.insertFTLIfNeeded("calendar/calendar-widgets.ftl");
+ }
+
+ /**
+ * @type {string} A string in the form of "PT5M PT0M" to represent the notifications times.
+ */
+ get value() {
+ return [...this._elList.children]
+ .map(row => {
+ let count = row.querySelector("input").value;
+ let unit = row.querySelector(".unit-menu").value;
+ let [relation, tag] = row.querySelector(".relation-menu").value.split("-");
+
+ tag = tag == "END" ? "END:" : "";
+ relation = relation == "before" ? "-" : "";
+ let durTag = unit == "D" ? "P" : "PT";
+ return `${tag}${relation}${durTag}${count}${unit}`;
+ })
+ .join(",");
+ }
+
+ set value(value) {
+ // An array of notifications times, each item is in the form of [5, "M",
+ // "before-start"], i.e. a triple of time, unit and relation.
+ let items = [];
+ let durations = value?.split(",") || [];
+ for (let dur of durations) {
+ dur = dur.trim();
+ if (!dur) {
+ continue;
+ }
+ let [relation, value] = dur.split(":");
+ if (!value) {
+ value = relation;
+ relation = "START";
+ }
+ if (value.startsWith("-")) {
+ relation = `before-${relation}`;
+ value = value.slice(1);
+ } else {
+ relation = `after-${relation}`;
+ }
+ let prefix = value.slice(0, 2);
+ if (prefix != "PT") {
+ prefix = value[0];
+ }
+ let unit = value.slice(-1);
+ if ((prefix == "P" && unit != "D") || (prefix == "PT" && !["M", "H"].includes(unit))) {
+ continue;
+ }
+ value = value.slice(prefix.length, -1);
+ items.push([value, unit, relation]);
+ }
+ this._render(items);
+ }
+
+ /**
+ * @type {boolean} If true, all form controls should be disabled.
+ */
+ set disabled(disabled) {
+ this._disabled = disabled;
+ this._updateDisabled();
+ }
+
+ /**
+ * Update the disabled attributes of all form controls to this._disabled.
+ */
+ _updateDisabled() {
+ for (let el of this.querySelectorAll("label, input, button, menulist")) {
+ el.disabled = this._disabled;
+ }
+ }
+
+ /**
+ * Because form controls can be dynamically added/removed, we bind events to
+ * _elButtonAdd and _elList.
+ */
+ _bindEvents() {
+ this._elButtonAdd.addEventListener("click", e => {
+ // Add a notification time row.
+ this._addNewRow(0, "M", "before-START");
+ this._emit();
+ });
+
+ this._elList.addEventListener("change", e => {
+ if (!HTMLInputElement.isInstance(e.target)) {
+ // We only care about change event of input elements.
+ return;
+ }
+ // We don't want this to interfere with the 'change' event emitted by
+ // calendar-notifications-setting itself.
+ e.stopPropagation();
+ this._updateMenuLists();
+ this._emit();
+ });
+
+ this._elList.addEventListener("command", e => {
+ let el = e.target;
+ if (el.tagName == "menuitem") {
+ this._emit();
+ } else if (el.tagName == "button") {
+ // Remove a notification time row.
+ el.closest("hbox").remove();
+ this._updateAddButton();
+ this._emit();
+ }
+ });
+ }
+
+ /**
+ * Render the layout and the add button, then bind events. This is delayed
+ * until the first `set value` call, so that l10n works correctly.
+ */
+ _renderLayout() {
+ this.appendChild(
+ MozXULElement.parseXULToFragment(`
+ <hbox align="center">
+ <label data-l10n-id="calendar-notifications-label"></label>
+ <spacer flex="1"></spacer>
+ <button class="add-button"
+ data-l10n-id="calendar-add-notification-button"/>
+ </hbox>
+ <separator class="thin"/>
+ <vbox class="calendar-notifications-list indent"></vbox>
+ `)
+ );
+ this._elList = this.querySelector(".calendar-notifications-list");
+ this._elButtonAdd = this.querySelector("button");
+ this._bindEvents();
+ }
+
+ /**
+ * Render this_items to a list of rows.
+ *
+ * @param {Array<[number, string, string]>} items - An array of count, unit and relation.
+ */
+ _render(items) {
+ this._renderLayout();
+
+ // Render a row for each item in this._items.
+ items.forEach(([value, unit, relation]) => {
+ this._addNewRow(value, unit, relation);
+ });
+ if (items.length) {
+ this._updateMenuLists();
+ this._updateDisabled();
+ }
+ }
+
+ /**
+ * Render a notification entry to a row. Each row contains a time input, a
+ * unit menulist, a relation menulist and a remove button.
+ */
+ _addNewRow(value, unit, relation) {
+ let fragment = MozXULElement.parseXULToFragment(`
+ <hbox class="calendar-notifications-row" align="center">
+ <html:input class="size3" value="${value}" type="number" min="0"/>
+ <menulist class="unit-menu" crop="none" value="${unit}">
+ <menupopup>
+ <menuitem value="M"/>
+ <menuitem value="H"/>
+ <menuitem value="D"/>
+ </menupopup>
+ </menulist>
+ <menulist class="relation-menu" crop="none" value="${relation}">
+ <menupopup class="reminder-relation-origin-menupopup">
+ <menuitem data-id="reminderCustomOriginBeginBeforeEvent"
+ value="before-START"/>
+ <menuitem data-id="reminderCustomOriginBeginAfterEvent"
+ value="after-START"/>
+ <menuitem data-id="reminderCustomOriginEndBeforeEvent"
+ value="before-END"/>
+ <menuitem data-id="reminderCustomOriginEndAfterEvent"
+ value="after-END"/>
+ </menupopup>
+ </menulist>
+ <button class="remove-button"></button>
+ </hbox>
+ `);
+ this._elList.appendChild(fragment);
+ this._updateMenuLists();
+ this._updateAddButton();
+ }
+
+ /**
+ * To prevent a too crowded UI, hide the add button if already have 5 rows.
+ */
+ _updateAddButton() {
+ if (this._elList.childElementCount >= 5) {
+ this._elButtonAdd.hidden = true;
+ } else {
+ this._elButtonAdd.hidden = false;
+ }
+ }
+
+ /**
+ * Iterate all rows, update the plurality of menulist (unit) to the input
+ * value (time).
+ */
+ _updateMenuLists() {
+ for (let row of this._elList.children) {
+ let input = row.querySelector("input");
+ let menulist = row.querySelector(".unit-menu");
+ this._updateMenuList(input.value, menulist);
+ for (let menuItem of row.querySelectorAll(".relation-menu menuitem")) {
+ menuItem.label = cal.l10n.getString("calendar-alarms", menuItem.dataset.id);
+ }
+ }
+ }
+
+ /**
+ * Update the plurality of a menulist (unit) options to the input value (time).
+ */
+ _updateMenuList(length, menu) {
+ let getUnitEntry = unit =>
+ ({
+ M: "unitMinutes",
+ H: "unitHours",
+ D: "unitDays",
+ }[unit] || "unitMinutes");
+
+ for (let menuItem of menu.getElementsByTagName("menuitem")) {
+ menuItem.label = PluralForm.get(length, cal.l10n.getCalString(getUnitEntry(menuItem.value)))
+ .replace("#1", "")
+ .trim();
+ }
+ }
+
+ /**
+ * Emit a change event.
+ */
+ _emit() {
+ this.dispatchEvent(new CustomEvent("change", { detail: this.value }));
+ }
+ }
+
+ customElements.define("calendar-notifications-setting", CalendarNotificationsSetting);
+}
diff --git a/comm/calendar/base/content/widgets/datetimepickers.js b/comm/calendar/base/content/widgets/datetimepickers.js
new file mode 100644
index 0000000000..ae2c87caf8
--- /dev/null
+++ b/comm/calendar/base/content/widgets/datetimepickers.js
@@ -0,0 +1,1529 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global MozElements, MozXULElement */
+
+// Wrap in a block to prevent leaking to window scope.
+{
+ const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+ // Leave these first arguments as `undefined`, to use the OS style if
+ // intl.regional_prefs.use_os_locales is true or the app language matches the OS language.
+ // Otherwise, the app language is used.
+ let dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" });
+ let timeFormatter = new Services.intl.DateTimeFormat(undefined, { timeStyle: "short" });
+
+ let probeSucceeded;
+ let alphaMonths;
+ let yearIndex, monthIndex, dayIndex;
+ let ampmIndex, amRegExp, pmRegExp;
+ let parseTimeRegExp, parseShortDateRegex;
+
+ class MozTimepickerMinute extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "selected"];
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 50;
+ let deltaView = 0;
+
+ if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) {
+ // Line/Page scrolling is usually vertical
+ if (event.deltaY) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ // The natural direction for pixel scrolling is left/right
+ this.pixelScrollDelta += event.deltaX;
+ if (this.pixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.pixelScrollDelta = 0;
+ } else if (this.pixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.pixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveMinutes(deltaView);
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ this.clickMinute = (minuteItem, minuteNumber) => {
+ this.closest("timepicker-grids").clickMinute(minuteItem, minuteNumber);
+ };
+ this.moveMinutes = number => {
+ this.closest("timepicker-grids").moveMinutes(number);
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes()) {
+ return;
+ }
+
+ const spacer = document.createXULElement("spacer");
+ spacer.setAttribute("flex", "1");
+
+ const minutebox = document.createXULElement("vbox");
+ minutebox.addEventListener("click", () => {
+ this.clickMinute(this, this.getAttribute("value"));
+ });
+
+ const box = document.createXULElement("box");
+
+ this.label = document.createXULElement("label");
+ this.label.classList.add("time-picker-minute-label");
+
+ box.appendChild(this.label);
+ minutebox.appendChild(box);
+
+ this.appendChild(spacer.cloneNode());
+ this.appendChild(minutebox);
+ this.appendChild(spacer);
+
+ this.pixelScrollDelta = 0;
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.label) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this.label.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this.label.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("selected")) {
+ this.label.setAttribute("selected", this.getAttribute("selected"));
+ } else {
+ this.label.removeAttribute("selected");
+ }
+ }
+ }
+
+ class MozTimepickerHour extends MozXULElement {
+ static get observedAttributes() {
+ return ["label", "selected"];
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("wheel", event => {
+ const pixelThreshold = 50;
+ let deltaView = 0;
+
+ if (event.deltaMode == event.DOM_DELTA_PAGE || event.deltaMode == event.DOM_DELTA_LINE) {
+ // Line/Page scrolling is usually vertical
+ if (event.deltaY) {
+ deltaView = event.deltaY < 0 ? -1 : 1;
+ }
+ } else if (event.deltaMode == event.DOM_DELTA_PIXEL) {
+ // The natural direction for pixel scrolling is left/right
+ this.pixelScrollDelta += event.deltaX;
+ if (this.pixelScrollDelta > pixelThreshold) {
+ deltaView = 1;
+ this.pixelScrollDelta = 0;
+ } else if (this.pixelScrollDelta < -pixelThreshold) {
+ deltaView = -1;
+ this.pixelScrollDelta = 0;
+ }
+ }
+
+ if (deltaView != 0) {
+ this.moveHours(deltaView);
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+ });
+
+ this.clickHour = (hourItem, hourNumber) => {
+ this.closest("timepicker-grids").clickHour(hourItem, hourNumber);
+ };
+ this.moveHours = number => {
+ this.closest("timepicker-grids").moveHours(number);
+ };
+ this.doubleClickHour = (hourItem, hourNumber) => {
+ this.closest("timepicker-grids").doubleClickHour(hourItem, hourNumber);
+ };
+ }
+
+ connectedCallback() {
+ if (this.hasChildNodes()) {
+ return;
+ }
+
+ const spacer = document.createXULElement("spacer");
+ spacer.setAttribute("flex", "1");
+
+ const hourbox = document.createXULElement("vbox");
+ hourbox.addEventListener("click", () => {
+ this.clickHour(this, this.getAttribute("value"));
+ });
+ hourbox.addEventListener("dblclick", () => {
+ this.doubleClickHour(this, this.getAttribute("value"));
+ });
+
+ const box = document.createXULElement("box");
+
+ this.label = document.createXULElement("label");
+ this.label.classList.add("time-picker-hour-label");
+
+ box.appendChild(this.label);
+ hourbox.appendChild(box);
+ hourbox.appendChild(spacer.cloneNode());
+
+ this.appendChild(spacer.cloneNode());
+ this.appendChild(hourbox);
+ this.appendChild(spacer);
+
+ this._updateAttributes();
+ }
+
+ attributeChangedCallback() {
+ this._updateAttributes();
+ }
+
+ _updateAttributes() {
+ if (!this.label) {
+ return;
+ }
+
+ if (this.hasAttribute("label")) {
+ this.label.setAttribute("value", this.getAttribute("label"));
+ } else {
+ this.label.removeAttribute("value");
+ }
+
+ if (this.hasAttribute("selected")) {
+ this.label.setAttribute("selected", this.getAttribute("selected"));
+ } else {
+ this.label.removeAttribute("selected");
+ }
+ }
+ }
+
+ /**
+ * The MozTimepickerGrids widget displays the grid of times to select, e.g. for an event.
+ * Typically it represents the popup content that let's the user select a time, in a
+ * <timepicker> widget.
+ *
+ * @augments MozXULElement
+ */
+ class MozTimepickerGrids extends MozXULElement {
+ constructor() {
+ super();
+
+ this.content = MozXULElement.parseXULToFragment(`
+ <vbox class="time-picker-grids">
+ <vbox class="time-picker-hour-grid" format12hours="false">
+ <hbox flex="1" class="timepicker-topRow-hour-class">
+ <timepicker-hour class="time-picker-hour-box-class" value="0" label="0"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="1" label="1"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="2" label="2"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="3" label="3"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="4" label="4"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="5" label="5"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="6" label="6"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="7" label="7"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="8" label="8"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="9" label="9"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="10" label="10"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="11" label="11"></timepicker-hour>
+ <hbox class="timepicker-amLabelBox-class amLabelBox" hidden="true">
+ <label></label>
+ </hbox>
+ </hbox>
+ <hbox flex="1" class="timepicker-bottomRow-hour-class">
+ <timepicker-hour class="time-picker-hour-box-class" value="12" label="12"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="13" label="13"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="14" label="14"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="15" label="15"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="16" label="16"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="17" label="17"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="18" label="18"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="19" label="19"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="20" label="20"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="21" label="21"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="22" label="22"></timepicker-hour>
+ <timepicker-hour class="time-picker-hour-box-class" value="23" label="23"></timepicker-hour>
+ <hbox class="pmLabelBox timepicker-pmLabelBox-class" hidden="true">
+ <label></label>
+ </hbox>
+ </hbox>
+ </vbox>
+ <vbox class="time-picker-five-minute-grid-box">
+ <vbox class="time-picker-five-minute-grid">
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-five-minute-class" value="0" label=":00" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="5" label=":05" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="10" label=":10" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="15" label=":15" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="20" label=":20" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="25" label=":25" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-five-minute-class" value="30" label=":30" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="35" label=":35" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="40" label=":40" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="45" label=":45" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="50" label=":50" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-five-minute-class" value="55" label=":55" flex="1"></timepicker-minute>
+ </hbox>
+ </vbox>
+ <hbox class="time-picker-minutes-bottom">
+ <spacer flex="1"></spacer>
+ <label class="time-picker-more-control-label" value="Β»" onclick="clickMore()"></label>
+ </hbox>
+ </vbox>
+ <vbox class="time-picker-one-minute-grid-box" flex="1" hidden="true">
+ <vbox class="time-picker-one-minute-grid" flex="1">
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="0" label=":00" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="1" label=":01" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="2" label=":02" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="3" label=":03" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="4" label=":04" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="5" label=":05" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="6" label=":06" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="7" label=":07" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="8" label=":08" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="9" label=":09" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="10" label=":10" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="11" label=":11" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="12" label=":12" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="13" label=":13" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="14" label=":14" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="15" label=":15" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="16" label=":16" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="17" label=":17" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="18" label=":18" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="19" label=":19" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="20" label=":20" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="21" label=":21" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="22" label=":22" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="23" label=":23" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="24" label=":24" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="25" label=":25" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="26" label=":26" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="27" label=":27" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="28" label=":28" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="29" label=":29" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="30" label=":30" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="31" label=":31" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="32" label=":32" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="33" label=":33" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="34" label=":34" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="35" label=":35" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="36" label=":36" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="37" label=":37" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="38" label=":38" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="39" label=":39" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="40" label=":40" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="41" label=":41" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="42" label=":42" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="43" label=":43" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="44" label=":44" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="45" label=":45" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="46" label=":46" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="47" label=":47" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="48" label=":48" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="49" label=":49" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="50" label=":50" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="51" label=":51" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="52" label=":52" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="53" label=":53" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="54" label=":54" flex="1"></timepicker-minute>
+ </hbox>
+ <hbox flex="1">
+ <timepicker-minute class="time-picker-one-minute-class" value="55" label=":55" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="56" label=":56" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="57" label=":57" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="58" label=":58" flex="1"></timepicker-minute>
+ <timepicker-minute class="time-picker-one-minute-class" value="59" label=":59" flex="1"></timepicker-minute>
+ </hbox>
+ </vbox>
+ <hbox class="time-picker-minutes-bottom">
+ <spacer flex="1"></spacer>
+ <label class="time-picker-more-control-label" value="Β«" onclick="clickLess()"></label>
+ </hbox>
+ </vbox>
+ </vbox>
+ `);
+ }
+
+ connectedCallback() {
+ if (!this.hasChildNodes()) {
+ this.appendChild(document.importNode(this.content, true));
+ }
+
+ // set by onPopupShowing
+ this.mPicker = null;
+
+ // The currently selected time
+ this.mSelectedTime = new Date();
+ // The selected hour and selected minute items
+ this.mSelectedHourItem = null;
+ this.mSelectedMinuteItem = null;
+ // constants use to specify one and five minute view
+ this.kMINUTE_VIEW_FIVE = 5;
+ this.kMINUTE_VIEW_ONE = 1;
+ }
+
+ /**
+ * Sets new mSelectedTime.
+ *
+ * @param {string | Array} val new mSelectedTime value
+ */
+ set value(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ this.mSelectedTime = val;
+ }
+
+ /**
+ * @returns {Array} An array containing mSelectedTime hours and mSelectedTime minutes
+ */
+ get value() {
+ return [this.mSelectedTime.getHours(), this.mSelectedTime.getMinutes()];
+ }
+
+ /**
+ * Set up the picker, called when the popup pops.
+ */
+ onPopupShowing() {
+ // select the hour item
+ let hours24 = this.mSelectedTime.getHours();
+ let hourItem = this.querySelector(`.time-picker-hour-box-class[value="${hours24}"]`);
+ this.selectHourItem(hourItem);
+
+ // Show the five minute view if we are an even five minutes,
+ // otherwise one minute view
+ let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime);
+
+ if (minutesByFive == this.mSelectedTime.getMinutes()) {
+ this.clickLess();
+ } else {
+ this.clickMore();
+ }
+ }
+
+ /**
+ * Switches popup to minute view and selects the selected minute item.
+ */
+ clickMore() {
+ // switch to one minute view
+ this.switchMinuteView(this.kMINUTE_VIEW_ONE);
+
+ // select minute box corresponding to the time
+ let minutes = this.mSelectedTime.getMinutes();
+ let oneMinuteItem = this.querySelector(`.time-picker-one-minute-class[value="${minutes}"]`);
+ this.selectMinuteItem(oneMinuteItem);
+ }
+
+ /**
+ * Switches popup to five-minute view and selects the five-minute item nearest to selected
+ * minute item.
+ */
+ clickLess() {
+ // switch to five minute view
+ this.switchMinuteView(this.kMINUTE_VIEW_FIVE);
+
+ // select closest five minute box,
+ // BUT leave the selected time at what may NOT be an even five minutes
+ // So that If they click more again the proper non-even-five minute
+ // box will be selected
+ let minutesByFive = this.calcNearestFiveMinutes(this.mSelectedTime);
+ let fiveMinuteItem = this.querySelector(
+ `.time-picker-five-minute-class[value="${minutesByFive}"]`
+ );
+ this.selectMinuteItem(fiveMinuteItem);
+ }
+
+ /**
+ * Selects the hour item which was clicked.
+ *
+ * @param {Node} hourItem - Hour item which was clicked
+ * @param {number} hourNumber - Hour value of the clicked hour item
+ */
+ clickHour(hourItem, hourNumber) {
+ // select the item
+ this.selectHourItem(hourItem);
+
+ // Change the hour in the selected time.
+ this.mSelectedTime.setHours(hourNumber);
+
+ this.hasChanged = true;
+ }
+
+ /**
+ * Called when one of the hour boxes is double clicked.
+ * Sets the time to the selected hour, on the hour, and closes the popup.
+ *
+ * @param {Node} hourItem - Hour item which was clicked
+ * @param {number} hourNumber - Hour value of the clicked hour item
+ */
+ doubleClickHour(hourItem, hourNumber) {
+ // set the minutes to :00
+ this.mSelectedTime.setMinutes(0);
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Changes selectedTime's minute, calls the client's onchange and closes
+ * the popup.
+ *
+ * @param {Node} minuteItem - Minute item which was clicked
+ * @param {number} minuteNumber - Minute value of the clicked minute item
+ */
+ clickMinute(minuteItem, minuteNumber) {
+ // set the minutes in the selected time
+ this.mSelectedTime.setMinutes(minuteNumber);
+ this.selectMinuteItem(minuteItem);
+ this.hasChanged = true;
+
+ this.dispatchEvent(new CustomEvent("select"));
+ }
+
+ /**
+ * Helper function to switch between "one" and "five" minute views.
+ *
+ * @param {number} view - Number representing minute view
+ */
+ switchMinuteView(view) {
+ let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box");
+ let oneMinuteBox = this.querySelector(".time-picker-one-minute-grid-box");
+
+ if (view == this.kMINUTE_VIEW_ONE) {
+ fiveMinuteBox.setAttribute("hidden", true);
+ oneMinuteBox.setAttribute("hidden", false);
+ } else {
+ fiveMinuteBox.setAttribute("hidden", false);
+ oneMinuteBox.setAttribute("hidden", true);
+ }
+ }
+
+ /**
+ * Selects an hour item.
+ *
+ * @param {Node} hourItem - Hour item node to be selected
+ */
+ selectHourItem(hourItem) {
+ // clear old selection, if there is one
+ if (this.mSelectedHourItem != null) {
+ this.mSelectedHourItem.removeAttribute("selected");
+ }
+ // set selected attribute, to cause the selected style to apply
+ hourItem.setAttribute("selected", "true");
+ // remember the selected item so we can deselect it
+ this.mSelectedHourItem = hourItem;
+ }
+
+ /**
+ * Selects a minute item.
+ *
+ * @param {Node} minuteItem - Minute item node to be selected
+ */
+ selectMinuteItem(minuteItem) {
+ // clear old selection, if there is one
+ if (this.mSelectedMinuteItem != null) {
+ this.mSelectedMinuteItem.removeAttribute("selected");
+ }
+ // set selected attribute, to cause the selected style to apply
+ minuteItem.setAttribute("selected", "true");
+ // remember the selected item so we can deselect it
+ this.mSelectedMinuteItem = minuteItem;
+ }
+
+ /**
+ * Moves minute by the number passed and handle rollover cases where the minutes gets
+ * greater than 59 or less than 60.
+ *
+ * @param {number} number - Moves minute by the number 'number'
+ */
+ moveMinutes(number) {
+ if (!this.mSelectedTime) {
+ return;
+ }
+
+ let idPrefix = ".time-picker-one-minute-class";
+
+ // Everything above assumes that we are showing the one-minute-grid,
+ // If not, we need to do these corrections;
+ let fiveMinuteBox = this.querySelector(".time-picker-five-minute-grid-box");
+
+ if (!fiveMinuteBox.hidden) {
+ number *= 5;
+ idPrefix = ".time-picker-five-minute-class";
+
+ // If the detailed view was shown before, then mSelectedTime.getMinutes
+ // might not be a multiple of 5.
+ this.mSelectedTime.setMinutes(this.calcNearestFiveMinutes(this.mSelectedTime));
+ }
+
+ let newMinutes = this.mSelectedTime.getMinutes() + number;
+
+ // Handle rollover cases
+ if (newMinutes < 0) {
+ newMinutes += 60;
+ }
+ if (newMinutes > 59) {
+ newMinutes -= 60;
+ }
+
+ this.mSelectedTime.setMinutes(newMinutes);
+
+ let minuteItemId = `${idPrefix}[value="${this.mSelectedTime.getMinutes()}"]`;
+ let minuteItem = this.querySelector(minuteItemId);
+
+ this.selectMinuteItem(minuteItem);
+ this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime);
+ this.hasChanged = true;
+ }
+
+ /**
+ * Moves hours by the number passed and handle rollover cases where the hours gets greater
+ * than 23 or less than 0.
+ *
+ * @param {number} number - Moves hours by the number 'number'
+ */
+ moveHours(number) {
+ if (!this.mSelectedTime) {
+ return;
+ }
+
+ let newHours = this.mSelectedTime.getHours() + number;
+
+ // Handle rollover cases
+ if (newHours < 0) {
+ newHours += 24;
+ }
+ if (newHours > 23) {
+ newHours -= 24;
+ }
+
+ this.mSelectedTime.setHours(newHours);
+
+ let hourItemId = `.time-picker-hour-box-class[value="${this.mSelectedTime.getHours()}"]`;
+ let hourItem = this.querySelector(hourItemId);
+
+ this.selectHourItem(hourItem);
+ this.mPicker.kTextBox.value = this.mPicker.formatTime(this.mSelectedTime);
+ this.hasChanged = true;
+ }
+
+ /**
+ * Calculates the nearest even five minutes.
+ *
+ * @param {calDateTime} time - Time near to which nearest five minutes have to be found
+ */
+ calcNearestFiveMinutes(time) {
+ let minutes = time.getMinutes();
+ let minutesByFive = Math.round(minutes / 5) * 5;
+
+ if (minutesByFive > 59) {
+ minutesByFive = 55;
+ }
+ return minutesByFive;
+ }
+
+ /**
+ * Changes to 12 hours format by showing am/pm label.
+ *
+ * @param {string} amLabel - amLabelBox value
+ * @param {string} pmLabel - pmLabelBox value
+ */
+ changeTo12HoursFormat(amLabel, pmLabel) {
+ if (!this.firstElementChild) {
+ this.appendChild(document.importNode(this.content, true));
+ }
+
+ let amLabelBox = this.querySelector(".amLabelBox");
+ amLabelBox.removeAttribute("hidden");
+ amLabelBox.firstElementChild.setAttribute("value", amLabel);
+ let pmLabelBox = this.querySelector(".pmLabelBox");
+ pmLabelBox.removeAttribute("hidden");
+ pmLabelBox.firstElementChild.setAttribute("value", pmLabel);
+ this.querySelector(".time-picker-hour-box-class[value='0']").setAttribute("label", "12");
+ for (let i = 13; i < 24; i++) {
+ this.querySelector(`.time-picker-hour-box-class[value="${i}"]`).setAttribute(
+ "label",
+ i - 12
+ );
+ }
+ this.querySelector(".time-picker-hour-grid").setAttribute("format12hours", "true");
+ }
+ }
+
+ class CalendarDatePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.prepend(CalendarDatePicker.fragment.cloneNode(true));
+ this._menulist = this.querySelector(".datepicker-menulist");
+ this._inputField = this._menulist._inputField;
+ this._popup = this._menulist.menupopup;
+ this._minimonth = this.querySelector("calendar-minimonth");
+
+ if (this.getAttribute("type") == "forever") {
+ this._valueIsForever = false;
+ this._foreverString = cal.l10n.getString(
+ "calendar-event-dialog",
+ "eventRecurrenceForeverLabel"
+ );
+
+ this._foreverItem = document.createXULElement("button");
+ this._foreverItem.setAttribute("label", this._foreverString);
+ this._popup.appendChild(document.createXULElement("menuseparator"));
+ this._popup.appendChild(this._foreverItem);
+
+ this._foreverItem.addEventListener("command", () => {
+ this.value = "forever";
+ this._popup.hidePopup();
+ });
+ }
+
+ this.value = this.getAttribute("value") || new Date();
+
+ // Other attributes handled in inheritedAttributes.
+ this._handleMutation = mutations => {
+ this.value = this.getAttribute("value");
+ };
+ this._attributeObserver = new MutationObserver(this._handleMutation);
+ this._attributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["value"],
+ });
+
+ this.initializeAttributeInheritance();
+
+ this.addEventListener("keydown", event => {
+ if (event.key == "Escape") {
+ this._popup.hidePopup();
+ }
+ });
+ this._menulist.addEventListener("change", event => {
+ event.stopPropagation();
+
+ let value = parseDateTime(this._inputBoxValue);
+ if (!value) {
+ this._inputBoxValue = this._minimonthValue;
+ return;
+ }
+ this._inputBoxValue = this._minimonthValue = value;
+ this._valueIsForever = false;
+
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ this._popup.addEventListener("popupshown", () => {
+ this._minimonth.focusDate(this._minimonthValue);
+ const calendar = this._minimonth.querySelector(".minimonth-calendar");
+ calendar.querySelector("td[selected]").focus();
+ });
+ this._minimonth.addEventListener("change", event => {
+ event.stopPropagation();
+ });
+ this._minimonth.addEventListener("select", () => {
+ this._inputBoxValue = this._minimonthValue;
+ this._valueIsForever = false;
+ this._popup.hidePopup();
+
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this._attributeObserver.disconnect();
+
+ if (this._menulist) {
+ this._menulist.remove();
+ this._menulist = null;
+ this._inputField = null;
+ this._popup = null;
+ this._minimonth = null;
+ this._foreverItem = null;
+ }
+ }
+
+ static get fragment() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <menulist is="menulist-editable" class="datepicker-menulist" editable="true" sizetopopup="false">
+ <menupopup ignorekeys="true" popupanchor="bottomright" popupalign="topright">
+ <calendar-minimonth tabindex="0"/>
+ </menupopup>
+ </menulist>
+ `),
+ true
+ );
+
+ Object.defineProperty(this, "fragment", { value: frag });
+ return frag;
+ }
+
+ static get inheritedAttributes() {
+ return { ".datepicker-menulist": "disabled" };
+ }
+
+ set value(val) {
+ let wasForever = this._valueIsForever;
+ if (this.getAttribute("type") == "forever" && val == "forever") {
+ this._valueIsForever = true;
+ this._inputBoxValue = val;
+ if (!wasForever) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ return;
+ } else if (typeof val == "string") {
+ val = parseDateTime(val);
+ }
+
+ let existingValue = this._minimonthValue;
+ this._valueIsForever = false;
+ this._inputBoxValue = this._minimonthValue = val;
+
+ if (
+ wasForever ||
+ existingValue.getFullYear() != val.getFullYear() ||
+ existingValue.getMonth() != val.getMonth() ||
+ existingValue.getDate() != val.getDate()
+ ) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ }
+
+ get value() {
+ if (this._valueIsForever) {
+ return "forever";
+ }
+ return this._minimonth.value;
+ }
+
+ focus() {
+ this._menulist.focus();
+ }
+
+ set _inputBoxValue(val) {
+ if (val == "forever") {
+ this._inputField.value = this._foreverString;
+ return;
+ }
+ this._inputField.value = formatDate(val);
+ }
+
+ get _inputBoxValue() {
+ return this._inputField.value;
+ }
+
+ set _minimonthValue(val) {
+ if (val == "forever") {
+ return;
+ }
+ this._minimonth.value = val;
+ }
+
+ get _minimonthValue() {
+ return this._minimonth.value;
+ }
+ }
+
+ const MenuBaseControl = MozElements.BaseControlMixin(MozElements.MozElementMixin(XULMenuElement));
+ MenuBaseControl.implementCustomInterface(CalendarDatePicker, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ class CalendarTimePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.prepend(CalendarTimePicker.fragment.cloneNode(true));
+ this._menulist = this.firstElementChild;
+ this._inputField = this._menulist._inputField;
+ this._popup = this._menulist.menupopup;
+ this._grid = this._popup.firstElementChild;
+
+ this.value = this.getAttribute("value") || new Date();
+
+ // Change the grids in the timepicker-grids for 12-hours time format.
+ if (ampmIndex) {
+ // Find the locale strings for the AM/PM prefix/suffix.
+ let amTime = new Date(2000, 0, 1, 6, 12, 34);
+ let pmTime = new Date(2000, 0, 1, 18, 12, 34);
+ amTime = timeFormatter.format(amTime);
+ pmTime = timeFormatter.format(pmTime);
+ let amLabel = parseTimeRegExp.exec(amTime)[ampmIndex] || "AM";
+ let pmLabel = parseTimeRegExp.exec(pmTime)[ampmIndex] || "PM";
+
+ this._grid.changeTo12HoursFormat(amLabel, pmLabel);
+ }
+
+ // Other attributes handled in inheritedAttributes.
+ this._handleMutation = mutations => {
+ this.value = this.getAttribute("value");
+ };
+ this._attributeObserver = new MutationObserver(this._handleMutation);
+ this._attributeObserver.observe(this, {
+ attributes: true,
+ attributeFilter: ["value"],
+ });
+
+ this.initializeAttributeInheritance();
+
+ this._inputField.addEventListener("change", event => {
+ event.stopPropagation();
+
+ let value = parseTime(this._inputBoxValue);
+ if (!value) {
+ this._inputBoxValue = this._gridValue;
+ return;
+ }
+ this.value = value;
+ });
+ this._menulist.menupopup.addEventListener("popupshowing", () => {
+ this._grid.onPopupShowing();
+ });
+ this._menulist.menupopup.addEventListener("popuphiding", () => {
+ this.value = this._gridValue;
+ });
+ this._grid.addEventListener("select", event => {
+ event.stopPropagation();
+
+ this.value = this._gridValue;
+ this._popup.hidePopup();
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ this._attributeObserver.disconnect();
+
+ if (this._menulist) {
+ this._menulist.remove();
+ this._menulist = null;
+ this._inputField = null;
+ this._popup = null;
+ this._grid = null;
+ }
+ }
+
+ static get fragment() {
+ // Accessibility information of these nodes will be
+ // presented on XULComboboxAccessible generated from <menulist>;
+ // hide these nodes from the accessibility tree.
+ let frag = document.importNode(
+ MozXULElement.parseXULToFragment(`
+ <menulist is="menulist-editable" class="timepicker-menulist" editable="true" sizetopopup="false">
+ <menupopup popupanchor="bottomright" popupalign="topright">
+ <timepicker-grids/>
+ </menupopup>
+ </menulist>
+ `),
+ true
+ );
+
+ Object.defineProperty(this, "fragment", { value: frag });
+ return frag;
+ }
+
+ static get inheritedAttributes() {
+ return { ".timepicker-menulist": "disabled" };
+ }
+
+ set value(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ if (val.getHours() != this._hours || val.getMinutes() != this._minutes) {
+ let settingInitalValue = this._hours === undefined;
+
+ this._inputBoxValue = this._gridValue = val;
+ [this._hours, this._minutes] = this._gridValue;
+
+ if (!settingInitalValue) {
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ }
+ }
+ }
+
+ get value() {
+ return [this._hours, this._minutes];
+ }
+
+ focus() {
+ this._menulist.focus();
+ }
+
+ set _inputBoxValue(val) {
+ if (typeof val == "string") {
+ val = parseTime(val);
+ } else if (Array.isArray(val)) {
+ let [hours, minutes] = val;
+ val = new Date();
+ val.setHours(hours);
+ val.setMinutes(minutes);
+ }
+ this._inputField.value = formatTime(val);
+ }
+
+ get _inputBoxValue() {
+ return this._inputField.value;
+ }
+
+ set _gridValue(val) {
+ this._grid.value = val;
+ }
+
+ get _gridValue() {
+ return this._grid.value;
+ }
+ }
+
+ MenuBaseControl.implementCustomInterface(CalendarTimePicker, [
+ Ci.nsIDOMXULMenuListElement,
+ Ci.nsIDOMXULSelectControlElement,
+ ]);
+
+ class CalendarDateTimePicker extends MozXULElement {
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this._datepicker = document.createXULElement("datepicker");
+ this._datepicker.classList.add("datetimepicker-datepicker");
+ this._datepicker.setAttribute("anonid", "datepicker");
+ this._timepicker = document.createXULElement("timepicker");
+ this._timepicker.classList.add("datetimepicker-timepicker");
+ this._timepicker.setAttribute("anonid", "timepicker");
+ this.appendChild(this._datepicker);
+ this.appendChild(this._timepicker);
+
+ if (this.getAttribute("value")) {
+ this._datepicker.value = this.getAttribute("value");
+ this._timepicker.value = this.getAttribute("value");
+ }
+
+ this.initializeAttributeInheritance();
+
+ this._datepicker.addEventListener("change", event => {
+ event.stopPropagation();
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ this._timepicker.addEventListener("change", event => {
+ event.stopPropagation();
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
+ });
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ if (this._datepicker) {
+ this._datepicker.remove();
+ }
+ if (this._timepicker) {
+ this._timepicker.remove();
+ }
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".datetimepicker-datepicker": "value,disabled,disabled=datepickerdisabled",
+ ".datetimepicker-timepicker": "value,disabled,disabled=timepickerdisabled",
+ };
+ }
+
+ set value(val) {
+ this._datepicker.value = this._timepicker.value = val;
+ }
+
+ get value() {
+ let dateValue = this._datepicker.value;
+ let [hours, minutes] = this._timepicker.value;
+ dateValue.setHours(hours);
+ dateValue.setMinutes(minutes);
+ dateValue.setSeconds(0);
+ dateValue.setMilliseconds(0);
+ return dateValue;
+ }
+
+ focus() {
+ this._datepicker.focus();
+ }
+ }
+
+ initDateFormat();
+ initTimeFormat();
+ customElements.define("timepicker-minute", MozTimepickerMinute);
+ customElements.define("timepicker-hour", MozTimepickerHour);
+ customElements.define("timepicker-grids", MozTimepickerGrids);
+ customElements.whenDefined("menulist-editable").then(() => {
+ customElements.define("datepicker", CalendarDatePicker);
+ customElements.define("timepicker", CalendarTimePicker);
+ customElements.define("datetimepicker", CalendarDateTimePicker);
+ });
+
+ /**
+ * Parameter aValue may be a date or a date time. Dates are
+ * read according to locale/OS setting (d-m-y or m-d-y or ...).
+ * (see initDateFormat). Uses parseTime() for times.
+ */
+ function parseDateTime(aValue) {
+ let tempDate = null;
+ if (!probeSucceeded) {
+ return null; // avoid errors accessing uninitialized data.
+ }
+
+ let year = Number.MIN_VALUE;
+ let month = -1;
+ let day = -1;
+ let timeString = null;
+
+ if (alphaMonths == null) {
+ // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D.
+ // Made of digits & nonDigits. (Nondigits may be unicode letters
+ // which do not match \w, esp. in CJK locales.)
+ // (.*)? binds to null if no suffix.
+ let parseNumShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)(.*)?$/;
+ let dateNumbersArray = parseNumShortDateRegex.exec(aValue);
+ if (dateNumbersArray != null) {
+ year = Number(dateNumbersArray[yearIndex]);
+ month = Number(dateNumbersArray[monthIndex]) - 1; // 0-based
+ day = Number(dateNumbersArray[dayIndex]);
+ timeString = dateNumbersArray[4];
+ }
+ } else {
+ // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy"
+ // (\d+|[^\d\W]) is digits or letters, not both together.
+ // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not).
+ // Allows Dec 31, 1999 (comma and space between parts)
+ // (Only accepts ASCII month names; JavaScript RegExp does not have an
+ // easy way to describe unicode letters short of a HUGE character range
+ // regexp derived from the Alphabetic ranges in
+ // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt)
+ // (.*)? binds to null if no suffix.
+ let parseAlphShortDateRegex =
+ /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)(.*)?$/;
+ let datePartsArray = parseAlphShortDateRegex.exec(aValue);
+ if (datePartsArray != null) {
+ year = Number(datePartsArray[yearIndex]);
+ let monthString = datePartsArray[monthIndex].toUpperCase();
+ for (let monthIdx = 0; monthIdx < alphaMonths.length; monthIdx++) {
+ if (monthString == alphaMonths[monthIdx]) {
+ month = monthIdx;
+ break;
+ }
+ }
+ day = Number(datePartsArray[dayIndex]);
+ timeString = datePartsArray[4];
+ }
+ }
+ if (year != Number.MIN_VALUE && month != -1 && day != -1) {
+ // year, month, day successfully parsed
+ if (year >= 0 && year < 100) {
+ // If 0 <= year < 100, treat as 2-digit year (like formatDate):
+ // parse year as up to 30 years in future or 69 years in past.
+ // (Covers 30-year mortgage and most working people's birthdate.)
+ // otherwise will be treated as four digit year.
+ let currentYear = new Date().getFullYear();
+ let currentCentury = currentYear - (currentYear % 100);
+ year = currentCentury + year;
+ if (year < currentYear - 69) {
+ year += 100;
+ }
+ if (year > currentYear + 30) {
+ year -= 100;
+ }
+ }
+ // if time is also present, parse it
+ let hours = 0;
+ let minutes = 0;
+ let seconds = 0;
+ if (timeString != null) {
+ let time = parseTime(timeString);
+ if (time != null) {
+ hours = time.getHours();
+ minutes = time.getMinutes();
+ seconds = time.getSeconds();
+ }
+ }
+ tempDate = new Date(year, month, day, hours, minutes, seconds, 0);
+ } // else did not match regex, not a valid date
+ return tempDate;
+ }
+
+ /**
+ * Parse a variety of time formats so that cut and paste is likely to work.
+ * separator: ':' '.' ' ' symbol none
+ * "12:34:56" "12.34.56" "12 34 56" "12h34m56s" "123456"
+ * seconds optional: "02:34" "02.34" "02 34" "02h34m" "0234"
+ * minutes optional: "12" "12" "12" "12h" "12"
+ * 1st hr digit optional:"9:34" " 9.34" "9 34" "9H34M" "934am"
+ * skip nondigit prefix " 12:34" "t12.34" " 12 34" "T12H34M" "T0234"
+ * am/pm optional "02:34 a.m.""02.34pm" "02 34 A M" "02H34M P.M." "0234pm"
+ * am/pm prefix "a.m. 02:34""pm02.34" "A M 02 34" "P.M. 02H34M" "pm0234"
+ * am/pm cyrillic "02:34\u0430.\u043c." "02 34 \u0420 \u041c"
+ * am/pm arabic "\u063502:34" (RTL 02:34a) "\u0645 02.34" (RTL 02.34 p)
+ * above/below noon "\u4e0a\u534802:34" "\u4e0b\u5348 02 34"
+ * noon before/after "\u5348\u524d02:34" "\u5348\u5f8c 02 34"
+ */
+ function parseTime(aValue) {
+ let now = new Date();
+
+ let noon = cal.l10n.getDateFmtString("noon");
+ if (aValue.toLowerCase() == noon.toLowerCase()) {
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0);
+ }
+
+ let midnight = cal.l10n.getDateFmtString("midnight");
+ if (aValue.toLowerCase() == midnight.toLowerCase()) {
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ }
+
+ let time = null;
+ let timePartsArray = parseTimeRegExp.exec(aValue);
+ const PRE_INDEX = 1,
+ HR_INDEX = 2,
+ MIN_INDEX = 4,
+ SEC_INDEX = 6,
+ POST_INDEX = 8;
+
+ if (timePartsArray != null) {
+ let hoursString = timePartsArray[HR_INDEX];
+ let hours = Number(hoursString);
+ if (!(hours >= 0 && hours < 24)) {
+ return null;
+ }
+
+ let minutesString = timePartsArray[MIN_INDEX];
+ let minutes = minutesString == null ? 0 : Number(minutesString);
+ if (!(minutes >= 0 && minutes < 60)) {
+ return null;
+ }
+
+ let secondsString = timePartsArray[SEC_INDEX];
+ let seconds = secondsString == null ? 0 : Number(secondsString);
+ if (!(seconds >= 0 && seconds < 60)) {
+ return null;
+ }
+
+ let ampmCode = null;
+ if (timePartsArray[PRE_INDEX] || timePartsArray[POST_INDEX]) {
+ if (ampmIndex && timePartsArray[ampmIndex]) {
+ // try current format order first
+ let ampmString = timePartsArray[ampmIndex];
+ if (amRegExp.test(ampmString)) {
+ ampmCode = "AM";
+ } else if (pmRegExp.test(ampmString)) {
+ ampmCode = "PM";
+ }
+ }
+ if (ampmCode == null) {
+ // not yet found
+ // try any format order
+ let preString = timePartsArray[PRE_INDEX];
+ let postString = timePartsArray[POST_INDEX];
+ if (
+ (preString && amRegExp.test(preString)) ||
+ (postString && amRegExp.test(postString))
+ ) {
+ ampmCode = "AM";
+ } else if (
+ (preString && pmRegExp.test(preString)) ||
+ (postString && pmRegExp.test(postString))
+ ) {
+ ampmCode = "PM";
+ } // else no match, ignore and treat as 24hour time.
+ }
+ }
+ if (ampmCode == "AM") {
+ if (hours == 12) {
+ hours = 0;
+ }
+ } else if (ampmCode == "PM") {
+ if (hours < 12) {
+ hours += 12;
+ }
+ }
+ time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, seconds, 0);
+ } // else did not match regex, not valid time
+ return time;
+ }
+
+ function initDateFormat() {
+ // probe the dateformat
+ yearIndex = -1;
+ monthIndex = -1;
+ dayIndex = -1;
+ alphaMonths = null;
+ probeSucceeded = false;
+
+ // SHORT NUMERIC DATE, such as 2002-03-04, 4/3/2002, or CE2002Y03M04D.
+ // Made of digits & nonDigits. (Nondigits may be unicode letters
+ // which do not match \w, esp. in CJK locales.)
+ parseShortDateRegex = /^\D*(\d+)\D+(\d+)\D+(\d+)\D?$/;
+ // Make sure to use UTC date and timezone here to avoid the pattern
+ // detection to fail if the probe date output would have an timezone
+ // offset due to our lack of support of historic timezone definitions.
+ let probeDate = new Date(Date.UTC(2002, 3, 6)); // month is 0-based
+ let probeString = formatDate(probeDate, cal.dtz.UTC);
+ let probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray) {
+ // Numeric month format
+ for (let i = 1; i <= 3; i++) {
+ switch (Number(probeArray[i])) {
+ case 2: // falls through
+ case 2002:
+ yearIndex = i;
+ break;
+ case 4:
+ monthIndex = i;
+ break;
+ case 5: // falls through for OS timezones western to GMT
+ case 6:
+ dayIndex = i;
+ break;
+ }
+ }
+ // All three indexes are set (not -1) at this point.
+ probeSucceeded = true;
+ } else {
+ // SHORT DATE WITH ALPHABETIC MONTH, such as "dd MMM yy" or "MMMM dd, yyyy"
+ // (\d+|[^\d\W]) is digits or letters, not both together.
+ // Allows 31dec1999 (no delimiters between parts) if OS does (w2k does not).
+ // Allows Dec 31, 1999 (comma and space between parts)
+ // (Only accepts ASCII month names; JavaScript RegExp does not have an
+ // easy way to describe unicode letters short of a HUGE character range
+ // regexp derived from the Alphabetic ranges in
+ // http://www.unicode.org/Public/UNIDATA/DerivedCoreProperties.txt)
+ parseShortDateRegex = /^\s*(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\W{0,2}(\d+|[^\d\W]+)\s*$/;
+ probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray != null) {
+ for (let j = 1; j <= 3; j++) {
+ switch (Number(probeArray[j])) {
+ case 2: // falls through
+ case 2002:
+ yearIndex = j;
+ break;
+ case 5: // falls through for OS timezones western to GMT
+ case 6:
+ dayIndex = j;
+ break;
+ default:
+ monthIndex = j;
+ break;
+ }
+ }
+ if (yearIndex != -1 && dayIndex != -1 && monthIndex != -1) {
+ probeSucceeded = true;
+ // Fill alphaMonths with month names.
+ alphaMonths = new Array(12);
+ for (let monthIdx = 0; monthIdx < 12; monthIdx++) {
+ probeDate.setMonth(monthIdx);
+ probeString = formatDate(probeDate);
+ probeArray = parseShortDateRegex.exec(probeString);
+ if (probeArray) {
+ alphaMonths[monthIdx] = probeArray[monthIndex].toUpperCase();
+ } else {
+ probeSucceeded = false;
+ }
+ }
+ }
+ }
+ }
+ if (!probeSucceeded) {
+ dump("\nOperating system short date format is not recognized: " + probeString + "\n");
+ }
+ }
+
+ /**
+ * Time format in 24-hour format or 12-hour format with am/pm string.
+ * Should match formats
+ * HH:mm, H:mm, HH:mm:ss, H:mm:ss
+ * hh:mm tt, h:mm tt, hh:mm:ss tt, h:mm:ss tt
+ * tt hh:mm, tt h:mm, tt hh:mm:ss, tt h:mm:ss
+ * where
+ * HH is 24 hour digits, with leading 0. H is 24 hour digits, no leading 0.
+ * hh is 12 hour digits, with leading 0. h is 12 hour digits, no leading 0.
+ * mm and ss are is minutes and seconds digits, with leading 0.
+ * tt is localized AM or PM string.
+ * ':' may be ':' or a units marker such as 'h', 'm', or 's' in 15h12m00s
+ * or may be omitted as in 151200.
+ */
+ function initTimeFormat() {
+ // probe the Time format
+ ampmIndex = null;
+ // Digits HR sep MIN sep SEC sep
+ // Index: 2 3 4 5 6 7
+ // prettier-ignore
+ let digitsExpr = "(\\d?\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?(?:(\\d\\d)\\s?(\\D)?\\s?)?)?";
+ // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8.
+ let probeTimeRegExp = new RegExp("^\\s*(\\D*)\\s?" + digitsExpr + "\\s?(\\D*)\\s*$");
+ const PRE_INDEX = 1,
+ HR_INDEX = 2,
+ // eslint-disable-next-line no-unused-vars
+ MIN_INDEX = 4,
+ SEC_INDEX = 6,
+ POST_INDEX = 8;
+ let amProbeTime = new Date(2000, 0, 1, 6, 12, 34);
+ let pmProbeTime = new Date(2000, 0, 1, 18, 12, 34);
+ let amProbeString = timeFormatter.format(amProbeTime);
+ let pmProbeString = timeFormatter.format(pmProbeTime);
+ let amFormatExpr = null,
+ pmFormatExpr = null;
+ if (amProbeString != pmProbeString) {
+ let amProbeArray = probeTimeRegExp.exec(amProbeString);
+ let pmProbeArray = probeTimeRegExp.exec(pmProbeString);
+ if (amProbeArray != null && pmProbeArray != null) {
+ if (
+ amProbeArray[PRE_INDEX] &&
+ pmProbeArray[PRE_INDEX] &&
+ amProbeArray[PRE_INDEX] != pmProbeArray[PRE_INDEX]
+ ) {
+ ampmIndex = PRE_INDEX;
+ } else if (amProbeArray[POST_INDEX] && pmProbeArray[POST_INDEX]) {
+ if (amProbeArray[POST_INDEX] == pmProbeArray[POST_INDEX]) {
+ // check if need to append previous character,
+ // captured by the optional separator pattern after seconds digits,
+ // or after minutes if no seconds, or after hours if no minutes.
+ for (let k = SEC_INDEX; k >= HR_INDEX; k -= 2) {
+ let nextSepI = k + 1;
+ let nextDigitsI = k + 2;
+ if (
+ (k == SEC_INDEX || (!amProbeArray[nextDigitsI] && !pmProbeArray[nextDigitsI])) &&
+ amProbeArray[nextSepI] &&
+ pmProbeArray[nextSepI] &&
+ amProbeArray[nextSepI] != pmProbeArray[nextSepI]
+ ) {
+ amProbeArray[POST_INDEX] = amProbeArray[nextSepI] + amProbeArray[POST_INDEX];
+ pmProbeArray[POST_INDEX] = pmProbeArray[nextSepI] + pmProbeArray[POST_INDEX];
+ ampmIndex = POST_INDEX;
+ break;
+ }
+ }
+ } else {
+ ampmIndex = POST_INDEX;
+ }
+ }
+ if (ampmIndex) {
+ let makeFormatRegExp = function (string) {
+ // make expr to accept either as provided, lowercased, or uppercased
+ let regExp = string.replace(/(\W)/g, "[$1]"); // escape punctuation
+ let lowercased = string.toLowerCase();
+ if (string != lowercased) {
+ regExp += "|" + lowercased;
+ }
+ let uppercased = string.toUpperCase();
+ if (string != uppercased) {
+ regExp += "|" + uppercased;
+ }
+ return regExp;
+ };
+ amFormatExpr = makeFormatRegExp(amProbeArray[ampmIndex]);
+ pmFormatExpr = makeFormatRegExp(pmProbeArray[ampmIndex]);
+ }
+ }
+ }
+ // International formats ([roman, cyrillic]|arabic|chinese/kanji characters)
+ // covering languages of U.N. (en,fr,sp,ru,ar,zh) and G8 (en,fr,de,it,ru,ja).
+ // See examples at parseTimeOfDay.
+ let amExpr = "[Aa\u0410\u0430][. ]?[Mm\u041c\u043c][. ]?|\u0635|\u4e0a\u5348|\u5348\u524d";
+ let pmExpr = "[Pp\u0420\u0440][. ]?[Mm\u041c\u043c][. ]?|\u0645|\u4e0b\u5348|\u5348\u5f8c";
+ if (ampmIndex) {
+ amExpr = amFormatExpr + "|" + amExpr;
+ pmExpr = pmFormatExpr + "|" + pmExpr;
+ }
+ let ampmExpr = amExpr + "|" + pmExpr;
+ // Must build am/pm formats into parse time regexp so that it can
+ // match them without mistaking the initial char for an optional divider.
+ // (For example, want to be able to parse both "12:34pm" and
+ // "12H34M56Spm" for any characters H,M,S and any language's "pm".
+ // The character between the last digit and the "pm" is optional.
+ // Must recognize "pm" directly, otherwise in "12:34pm" the "S" pattern
+ // matches the "p" character so only "m" is matched as ampm suffix.)
+ //
+ // digitsExpr has 6 captures, so index of first ampmExpr is 1, of last is 8.
+ parseTimeRegExp = new RegExp(
+ "(" + ampmExpr + ")?\\s?" + digitsExpr + "(" + ampmExpr + ")?\\s*$"
+ );
+ amRegExp = new RegExp("^(?:" + amExpr + ")$");
+ pmRegExp = new RegExp("^(?:" + pmExpr + ")$");
+ }
+
+ function formatDate(aDate, aTimezone) {
+ // Usually, floating is ok here, so no need to pass aTimezone - we just need to pass
+ // it in if we need to make sure formatting happens without a timezone conversion.
+ let formatter = aTimezone
+ ? new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeZone: aTimezone.tzid,
+ })
+ : dateFormatter;
+ return formatter.format(aDate);
+ }
+
+ function formatTime(aValue) {
+ return timeFormatter.format(aValue);
+ }
+}
diff --git a/comm/calendar/base/content/widgets/mouseoverPreviews.js b/comm/calendar/base/content/widgets/mouseoverPreviews.js
new file mode 100644
index 0000000000..38e5c1e24f
--- /dev/null
+++ b/comm/calendar/base/content/widgets/mouseoverPreviews.js
@@ -0,0 +1,439 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Code which generates event and task (todo) preview tooltips/titletips
+ * when the mouse hovers over either the event list, the task list, or
+ * an event or task box in one of the grid views.
+ *
+ * (Portions of this code were previously in calendar.js and unifinder.js,
+ * some of it duplicated.)
+ */
+
+/* exported onMouseOverItem, showToolTip, getPreviewForItem,
+ getEventStatusString, getToDoStatusString */
+
+/* import-globals-from ../calendar-ui-utils.js */
+
+/**
+ * PUBLIC: This changes the mouseover preview based on the start and end dates
+ * of an occurrence of a (one-time or recurring) calEvent or calToDo.
+ * Used by all grid views.
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * PUBLIC: Displays a tooltip with details when hovering over an item in the views
+ *
+ * @param {DOMEvent} occurrenceBoxMouseEvent the triggering event
+ * @returns {boolean} true, if the tooltip is displayed
+ */
+function onMouseOverItem(occurrenceBoxMouseEvent) {
+ if ("occurrence" in occurrenceBoxMouseEvent.currentTarget) {
+ // occurrence of repeating event or todo
+ let occurrence = occurrenceBoxMouseEvent.currentTarget.occurrence;
+ const toolTip = document.getElementById("itemTooltip");
+ return showToolTip(toolTip, occurrence);
+ }
+ return false;
+}
+
+/**
+ * PUBLIC: Displays a tooltip for a given item
+ *
+ * @param {Node} aTooltip the node to hold the tooltip
+ * @param {CalIEvent|calIToDo} aItem the item to create the tooltip for
+ * @returns {boolean} true, if the tooltip is displayed
+ */
+function showToolTip(aToolTip, aItem) {
+ if (aItem) {
+ let holderBox = getPreviewForItem(aItem);
+ if (holderBox) {
+ while (aToolTip.lastChild) {
+ aToolTip.lastChild.remove();
+ }
+ aToolTip.appendChild(holderBox);
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * PUBLIC: Called when a user hovers over a todo element and the text for the
+ * mouse over is changed.
+ *
+ * @param {calIToDo} toDoItem - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForItem(aItem, aIsTooltip = true) {
+ if (aItem.isEvent()) {
+ return getPreviewForEvent(aItem, aIsTooltip);
+ } else if (aItem.isTodo()) {
+ return getPreviewForTask(aItem, aIsTooltip);
+ }
+ return null;
+}
+
+/**
+ * PUBLIC: Returns the string for status (none), Tentative, Confirmed, or
+ * Cancelled for a given event
+ *
+ * @param {calIEvent} aEvent The event
+ * @returns {string} The string for the status property of the event
+ */
+function getEventStatusString(aEvent) {
+ switch (aEvent.status) {
+ // Event status value keywords are specified in RFC2445sec4.8.1.11
+ case "TENTATIVE":
+ return cal.l10n.getCalString("statusTentative");
+ case "CONFIRMED":
+ return cal.l10n.getCalString("statusConfirmed");
+ case "CANCELLED":
+ return cal.l10n.getCalString("eventStatusCancelled");
+ default:
+ return "";
+ }
+}
+
+/**
+ * PUBLIC: Returns the string for status (none), NeedsAction, InProcess,
+ * Cancelled, orCompleted for a given ToDo
+ *
+ * @param {calIToDo} aToDo The ToDo
+ * @returns {string} The string for the status property of the event
+ */
+function getToDoStatusString(aToDo) {
+ switch (aToDo.status) {
+ // Todo status keywords are specified in RFC2445sec4.8.1.11
+ case "NEEDS-ACTION":
+ return cal.l10n.getCalString("statusNeedsAction");
+ case "IN-PROCESS":
+ return cal.l10n.getCalString("statusInProcess");
+ case "CANCELLED":
+ return cal.l10n.getCalString("todoStatusCancelled");
+ case "COMPLETED":
+ return cal.l10n.getCalString("statusCompleted");
+ default:
+ return "";
+ }
+}
+
+/**
+ * PRIVATE: Called when a user hovers over a todo element and the text for the
+ * mouse overis changed.
+ *
+ * @param {calIToDo} toDoItem - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForTask(toDoItem, aIsTooltip = true) {
+ if (toDoItem) {
+ const vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "tooltipBox");
+ if (aIsTooltip) {
+ // tooltip appears above or below pointer, so may have as little as
+ // one half the screen height available (avoid top going off screen).
+ vbox.style.maxHeight = Math.floor(screen.height / 2);
+ } else {
+ vbox.setAttribute("flex", "1");
+ }
+ boxInitializeHeaderTable(vbox);
+
+ let hasHeader = false;
+
+ if (toDoItem.title) {
+ boxAppendLabeledText(vbox, "tooltipTitle", toDoItem.title);
+ hasHeader = true;
+ }
+
+ let location = toDoItem.getProperty("LOCATION");
+ if (location) {
+ boxAppendLabeledText(vbox, "tooltipLocation", location);
+ hasHeader = true;
+ }
+
+ // First try to get calendar name appearing in tooltip
+ if (toDoItem.calendar.name) {
+ let calendarNameString = toDoItem.calendar.name;
+ boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString);
+ }
+
+ if (toDoItem.entryDate && toDoItem.entryDate.isValid) {
+ boxAppendLabeledDateTime(vbox, "tooltipStart", toDoItem.entryDate);
+ hasHeader = true;
+ }
+
+ if (toDoItem.dueDate && toDoItem.dueDate.isValid) {
+ boxAppendLabeledDateTime(vbox, "tooltipDue", toDoItem.dueDate);
+ hasHeader = true;
+ }
+
+ if (toDoItem.priority && toDoItem.priority != 0) {
+ let priorityInteger = parseInt(toDoItem.priority, 10);
+ let priorityString;
+
+ // These cut-offs should match calendar-event-dialog.js
+ if (priorityInteger >= 1 && priorityInteger <= 4) {
+ priorityString = cal.l10n.getCalString("highPriority");
+ } else if (priorityInteger == 5) {
+ priorityString = cal.l10n.getCalString("normalPriority");
+ } else {
+ priorityString = cal.l10n.getCalString("lowPriority");
+ }
+ boxAppendLabeledText(vbox, "tooltipPriority", priorityString);
+ hasHeader = true;
+ }
+
+ if (toDoItem.status && toDoItem.status != "NONE") {
+ let status = getToDoStatusString(toDoItem);
+ boxAppendLabeledText(vbox, "tooltipStatus", status);
+ hasHeader = true;
+ }
+
+ if (
+ toDoItem.status != null &&
+ toDoItem.percentComplete != 0 &&
+ toDoItem.percentComplete != 100
+ ) {
+ boxAppendLabeledText(vbox, "tooltipPercent", String(toDoItem.percentComplete) + "%");
+ hasHeader = true;
+ } else if (toDoItem.percentComplete == 100) {
+ if (toDoItem.completedDate == null) {
+ boxAppendLabeledText(vbox, "tooltipPercent", "100%");
+ } else {
+ boxAppendLabeledDateTime(vbox, "tooltipCompleted", toDoItem.completedDate);
+ }
+ hasHeader = true;
+ }
+
+ let description = toDoItem.descriptionText;
+ if (description) {
+ // display wrapped description lines like body of message below headers
+ if (hasHeader) {
+ boxAppendBodySeparator(vbox);
+ }
+ boxAppendBody(vbox, description, aIsTooltip);
+ }
+
+ return vbox;
+ }
+ return null;
+}
+
+/**
+ * PRIVATE: Called when mouse moves over a different, or when mouse moves over
+ * event in event list. The instStartDate is date of instance displayed at event
+ * box (recurring or multiday events may be displayed by more than one event box
+ * for different days), or null if should compute next instance from now.
+ *
+ * @param {calIEvent} aEvent - the item to create the preview for
+ * @param {boolean} aIsTooltip enabled if used for tooltip composition (default)
+ */
+function getPreviewForEvent(aEvent, aIsTooltip = true) {
+ let event = aEvent;
+ const vbox = document.createXULElement("vbox");
+ vbox.setAttribute("class", "tooltipBox");
+ if (aIsTooltip) {
+ // tooltip appears above or below pointer, so may have as little as
+ // one half the screen height available (avoid top going off screen).
+ vbox.maxHeight = Math.floor(screen.height / 2);
+ } else {
+ vbox.setAttribute("flex", "1");
+ }
+ boxInitializeHeaderTable(vbox);
+
+ if (event) {
+ if (event.title) {
+ boxAppendLabeledText(vbox, "tooltipTitle", aEvent.title);
+ }
+
+ let location = event.getProperty("LOCATION");
+ if (location) {
+ boxAppendLabeledText(vbox, "tooltipLocation", location);
+ }
+ if (!(event.startDate && event.endDate)) {
+ // Event may be recurrent event. If no displayed instance specified,
+ // use next instance, or previous instance if no next instance.
+ event = getCurrentNextOrPreviousRecurrence(event);
+ }
+ boxAppendLabeledDateTimeInterval(vbox, "tooltipDate", event);
+
+ // First try to get calendar name appearing in tooltip
+ if (event.calendar.name) {
+ let calendarNameString = event.calendar.name;
+ boxAppendLabeledText(vbox, "tooltipCalName", calendarNameString);
+ }
+
+ if (event.status && event.status != "NONE") {
+ let statusString = getEventStatusString(event);
+ boxAppendLabeledText(vbox, "tooltipStatus", statusString);
+ }
+
+ if (event.organizer && event.getAttendees().length > 0) {
+ let organizer = event.organizer;
+ boxAppendLabeledText(vbox, "tooltipOrganizer", organizer);
+ }
+
+ let description = event.descriptionText;
+ if (description) {
+ boxAppendBodySeparator(vbox);
+ // display wrapped description lines, like body of message below headers
+ boxAppendBody(vbox, description, aIsTooltip);
+ }
+ return vbox;
+ }
+ return null;
+}
+
+/**
+ * PRIVATE: Append a separator, a thin space between header and body.
+ *
+ * @param {Node} vbox box to which to append separator.
+ */
+function boxAppendBodySeparator(vbox) {
+ const separator = document.createXULElement("separator");
+ separator.setAttribute("class", "tooltipBodySeparator");
+ vbox.appendChild(separator);
+}
+
+/**
+ * PRIVATE: Append description to box for body text. Rendered as HTML.
+ * Indentation and line breaks are preserved.
+ *
+ * @param {Node} box - Box to which to append the body.
+ * @param {string} textString - Text of the body.
+ * @param {boolean} aIsTooltip - True for "tooltip" and false for "conflict-dialog" case.
+ */
+function boxAppendBody(box, textString, aIsTooltip) {
+ let type = aIsTooltip ? "description" : "vbox";
+ let xulDescription = document.createXULElement(type);
+ xulDescription.setAttribute("class", "tooltipBody");
+ if (!aIsTooltip) {
+ xulDescription.setAttribute("flex", "1");
+ }
+ let docFragment = cal.view.textToHtmlDocumentFragment(textString, document);
+ xulDescription.appendChild(docFragment);
+ box.appendChild(xulDescription);
+}
+
+/**
+ * PRIVATE: Use dateFormatter to format date and time,
+ * and to header table append a row containing localized Label: date.
+ *
+ * @param {Node} box The node to add the date label to
+ * @param {string} labelProperty The label
+ * @param {calIDateTime} date - The datetime object to format and add
+ */
+function boxAppendLabeledDateTime(box, labelProperty, date) {
+ date = date.getInTimezone(cal.dtz.defaultTimezone);
+ let formattedDateTime = cal.dtz.formatter.formatDateTime(date);
+ boxAppendLabeledText(box, labelProperty, formattedDateTime);
+}
+
+/**
+ * PRIVATE: Use dateFormatter to format date and time interval,
+ * and to header table append a row containing localized Label: interval.
+ *
+ * @param box contains header table.
+ * @param labelProperty name of property for localized field label.
+ * @param item the event or task
+ */
+function boxAppendLabeledDateTimeInterval(box, labelProperty, item) {
+ let dateString = cal.dtz.formatter.formatItemInterval(item);
+ boxAppendLabeledText(box, labelProperty, dateString);
+}
+
+/**
+ * PRIVATE: create empty 2-column table for header fields, and append it to box.
+ *
+ * @param {Node} box The node to create a column table for
+ */
+function boxInitializeHeaderTable(box) {
+ let table = document.createElementNS("http://www.w3.org/1999/xhtml", "table");
+ table.setAttribute("class", "tooltipHeaderTable");
+ box.appendChild(table);
+}
+
+/**
+ * PRIVATE: To headers table, append a row containing Label: value, where label
+ * is localized text for labelProperty.
+ *
+ * @param box box containing headers table
+ * @param labelProperty name of property for localized name of header
+ * @param textString value of header field.
+ */
+function boxAppendLabeledText(box, labelProperty, textString) {
+ let labelText = cal.l10n.getCalString(labelProperty);
+ let table = box.querySelector("table");
+ let row = document.createElementNS("http://www.w3.org/1999/xhtml", "tr");
+
+ row.appendChild(createTooltipHeaderLabel(labelText));
+ row.appendChild(createTooltipHeaderDescription(textString));
+
+ table.appendChild(row);
+}
+
+/**
+ * PRIVATE: Creates an element for field label (for header table)
+ *
+ * @param {string} text The text to display in the node
+ * @returns {Node} The node
+ */
+function createTooltipHeaderLabel(text) {
+ let labelCell = document.createElementNS("http://www.w3.org/1999/xhtml", "th");
+ labelCell.setAttribute("class", "tooltipHeaderLabel");
+ labelCell.textContent = text;
+ return labelCell;
+}
+
+/**
+ * PRIVATE: Creates an element for field value (for header table)
+ *
+ * @param {string} text The text to display in the node
+ * @returns {Node} The node
+ */
+function createTooltipHeaderDescription(text) {
+ let descriptionCell = document.createElementNS("http://www.w3.org/1999/xhtml", "td");
+ descriptionCell.setAttribute("class", "tooltipHeaderDescription");
+ descriptionCell.textContent = text;
+ return descriptionCell;
+}
+
+/**
+ * PRIVATE: If now is during an occurrence, return the occurrence. If now is
+ * before an occurrence, return the next occurrence or otherwise the previous
+ * occurrence.
+ *
+ * @param {calIEvent} calendarEvent The text to display in the node
+ * @returns {mixed} Returns a calIDateTime for the detected
+ * occurrence or calIEvent, if this is a
+ * non-recurring event
+ */
+function getCurrentNextOrPreviousRecurrence(calendarEvent) {
+ if (!calendarEvent.recurrenceInfo) {
+ return calendarEvent;
+ }
+
+ let dur = calendarEvent.duration.clone();
+ dur.isNegative = true;
+
+ // To find current event when now is during event, look for occurrence
+ // starting duration ago.
+ let probeTime = cal.dtz.now();
+ probeTime.addDuration(dur);
+
+ let occ = calendarEvent.recurrenceInfo.getNextOccurrence(probeTime);
+
+ if (!occ) {
+ let occs = calendarEvent.recurrenceInfo.getOccurrences(
+ calendarEvent.startDate,
+ probeTime,
+ 0,
+ {}
+ );
+ occ = occs[occs.length - 1];
+ }
+ return occ;
+}
diff --git a/comm/calendar/base/jar.mn b/comm/calendar/base/jar.mn
new file mode 100644
index 0000000000..42953a0586
--- /dev/null
+++ b/comm/calendar/base/jar.mn
@@ -0,0 +1,111 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar.jar:
+% override chrome://messagebody/skin/imip.css chrome://calendar/skin/imip.css
+% override chrome://messagebody/skin/calendar-attendees.css chrome://calendar/skin/shared/calendar-attendees.css
+% override chrome://messagebody/skin/calendar-event-dialog-attendees.png chrome://calendar/skin/shared/calendar-event-dialog-attendees.png
+% content calendar %content/
+ content/calendar-base-view.js (content/calendar-base-view.js)
+ content/calendar-chrome-startup.js (content/calendar-chrome-startup.js)
+ content/calendar-clipboard.js (content/calendar-clipboard.js)
+ content/calendar-command-controller.js (content/calendar-command-controller.js)
+ content/calendar-day-label.js (content/calendar-day-label.js)
+ content/calendar-dnd-listener.js (content/calendar-dnd-listener.js)
+ content/calendar-extract.js (content/calendar-extract.js)
+ content/calendar-invitations-manager.js (content/calendar-invitations-manager.js)
+ content/calendar-management.js (content/calendar-management.js)
+ content/calendar-menus.js (content/calendar-menus.js)
+ content/calendar-migration.js (content/calendar-migration.js)
+ content/calendar-modes.js (content/calendar-modes.js)
+ content/calendar-month-view.js (content/calendar-month-view.js)
+ content/calendar-multiday-view.js (content/calendar-multiday-view.js)
+ content/calendar-print.js (content/calendar-print.js)
+ content/calendar-statusbar.js (content/calendar-statusbar.js)
+ content/calendar-tabs.js (content/calendar-tabs.js)
+ content/calendar-task-tree-utils.js (content/calendar-task-tree-utils.js)
+ content/calendar-task-tree-view.js (content/calendar-task-tree-view.js)
+ content/calendar-task-tree.js (content/calendar-task-tree.js)
+ content/calendar-task-view.js (content/calendar-task-view.js)
+ content/calendar-ui-utils.js (content/calendar-ui-utils.js)
+ content/calendar-unifinder.js (content/calendar-unifinder.js)
+ content/calendar-views-utils.js (content/calendar-views-utils.js)
+ content/calendar-views.js (content/calendar-views.js)
+ content/calendar-editable-item.js (content/calendar-editable-item.js)
+ content/calendar-alarm-dialog.js (content/dialogs/calendar-alarm-dialog.js)
+ content/calendar-alarm-dialog.xhtml (content/dialogs/calendar-alarm-dialog.xhtml)
+ content/calendar-conflicts-dialog.js (content/dialogs/calendar-conflicts-dialog.js)
+ content/calendar-conflicts-dialog.xhtml (content/dialogs/calendar-conflicts-dialog.xhtml)
+ content/calendar-creation.js (content/dialogs/calendar-creation.js)
+ content/calendar-creation.xhtml (content/dialogs/calendar-creation.xhtml)
+ content/calendar-dialog-utils.js (content/dialogs/calendar-dialog-utils.js)
+ content/calendar-error-prompt.xhtml (content/dialogs/calendar-error-prompt.xhtml)
+ content/calendar-error-prompt.js (content/dialogs/calendar-error-prompt.js)
+ content/calendar-event-dialog-attendees.js (content/dialogs/calendar-event-dialog-attendees.js)
+ content/calendar-event-dialog-attendees.xhtml (content/dialogs/calendar-event-dialog-attendees.xhtml)
+ content/calendar-event-dialog-recurrence.js (content/dialogs/calendar-event-dialog-recurrence.js)
+ content/calendar-event-dialog-recurrence.xhtml (content/dialogs/calendar-event-dialog-recurrence.xhtml)
+ content/calendar-event-dialog-reminder.js (content/dialogs/calendar-event-dialog-reminder.js)
+ content/calendar-event-dialog-reminder.xhtml (content/dialogs/calendar-event-dialog-reminder.xhtml)
+ content/calendar-event-dialog-timezone.js (content/dialogs/calendar-event-dialog-timezone.js)
+ content/calendar-event-dialog-timezone.xhtml (content/dialogs/calendar-event-dialog-timezone.xhtml)
+* content/calendar-event-dialog.xhtml (content/dialogs/calendar-event-dialog.xhtml)
+ content/calendar-ics-file-dialog.xhtml (content/dialogs/calendar-ics-file-dialog.xhtml)
+ content/calendar-ics-file-dialog.js (content/dialogs/calendar-ics-file-dialog.js)
+ content/calendar-identity-utils.js (content/dialogs/calendar-identity-utils.js)
+ content/calendar-invitations-dialog.js (content/dialogs/calendar-invitations-dialog.js)
+ content/calendar-invitations-dialog.xhtml (content/dialogs/calendar-invitations-dialog.xhtml)
+ content/calendar-itip-identity-dialog.xhtml (content/dialogs/calendar-itip-identity-dialog.xhtml)
+ content/calendar-itip-identity-dialog.js (content/dialogs/calendar-itip-identity-dialog.js)
+ content/calendar-migration-dialog.js (content/dialogs/calendar-migration-dialog.js)
+ content/calendar-migration-dialog.xhtml (content/dialogs/calendar-migration-dialog.xhtml)
+ content/calendar-occurrence-prompt.js (content/dialogs/calendar-occurrence-prompt.js)
+ content/calendar-occurrence-prompt.xhtml (content/dialogs/calendar-occurrence-prompt.xhtml)
+ content/calendar-properties-dialog.js (content/dialogs/calendar-properties-dialog.js)
+ content/calendar-properties-dialog.xhtml (content/dialogs/calendar-properties-dialog.xhtml)
+ content/calendar-providerUninstall-dialog.js (content/dialogs/calendar-providerUninstall-dialog.js)
+ content/calendar-providerUninstall-dialog.xhtml (content/dialogs/calendar-providerUninstall-dialog.xhtml)
+ content/calendar-summary-dialog.js (content/dialogs/calendar-summary-dialog.js)
+ content/calendar-summary-dialog.xhtml (content/dialogs/calendar-summary-dialog.xhtml)
+ content/calendar-uri-redirect-dialog.xhtml (content/dialogs/calendar-uri-redirect-dialog.xhtml)
+ content/calendar-uri-redirect-dialog.js (content/dialogs/calendar-uri-redirect-dialog.js)
+ content/chooseCalendarDialog.js (content/dialogs/chooseCalendarDialog.js)
+ content/chooseCalendarDialog.xhtml (content/dialogs/chooseCalendarDialog.xhtml)
+ content/publishDialog.js (content/dialogs/publishDialog.js)
+ content/publishDialog.xhtml (content/dialogs/publishDialog.xhtml)
+ content/imip-bar.js (content/imip-bar.js)
+ content/calendar-invitation-display.js (content/calendar-invitation-display.js)
+ content/import-export.js (content/import-export.js)
+ content/calendar-item-editing.js (content/item-editing/calendar-item-editing.js)
+ content/calendar-item-panel.js (content/item-editing/calendar-item-panel.js)
+ content/calendar-item-iframe.xhtml (content/item-editing/calendar-item-iframe.xhtml)
+ content/calendar-item-iframe.js (content/item-editing/calendar-item-iframe.js)
+ content/calendar-task-editing.js (content/item-editing/calendar-task-editing.js)
+ content/preferences/alarms.js (content/preferences/alarms.js)
+ content/preferences/calendar-preferences.js (content/preferences/calendar-preferences.js)
+ content/preferences/categories.js (content/preferences/categories.js)
+ content/preferences/editCategory.js (content/preferences/editCategory.js)
+ content/preferences/editCategory.xhtml (content/preferences/editCategory.xhtml)
+ content/preferences/general.js (content/preferences/general.js)
+ content/preferences/notifications.js (content/preferences/notifications.js)
+ content/preferences/views.js (content/preferences/views.js)
+ content/printing-template.html (content/printing-template.html)
+ content/publish.js (content/publish.js)
+ content/sound.wav (content/sound.wav)
+ content/today-pane.js (content/today-pane.js)
+ content/today-pane-agenda.js (content/today-pane-agenda.js)
+ content/widgets/calendar-alarm-widget.js (content/widgets/calendar-alarm-widget.js)
+ content/widgets/calendar-dnd-widgets.js (content/widgets/calendar-dnd-widgets.js)
+ content/widgets/calendar-filter-tree-view.js (content/widgets/calendar-filter-tree-view.js)
+ content/widgets/calendar-filter.js (content/widgets/calendar-filter.js)
+ content/widgets/calendar-item-summary.js (content/widgets/calendar-item-summary.js)
+ content/widgets/calendar-invitation-panel.js (content/widgets/calendar-invitation-panel.js)
+ content/widgets/calendar-minidate.js (content/widgets/calendar-minidate.js)
+ content/widgets/calendar-minimonth.js (content/widgets/calendar-minimonth.js)
+ content/widgets/calendar-modebox.js (content/widgets/calendar-modebox.js)
+ content/widgets/calendar-notifications-setting.js (content/widgets/calendar-notifications-setting.js)
+ content/widgets/datetimepickers.js (content/widgets/datetimepickers.js)
+ content/widgets/mouseoverPreviews.js (content/widgets/mouseoverPreviews.js)
+ content/calApplicationUtils.js (src/calApplicationUtils.js)
diff --git a/comm/calendar/base/modules/Ical.jsm b/comm/calendar/base/modules/Ical.jsm
new file mode 100644
index 0000000000..470051fe4c
--- /dev/null
+++ b/comm/calendar/base/modules/Ical.jsm
@@ -0,0 +1,9707 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This is ical.js from <https://github.com/kewisch/ical.js>.
+ *
+ * A maintenance branch is used as this version doesn't use ES6 modules,
+ * so that changes can be easily backported to Thunderbird 102 ESR.
+ *
+ * If you would like to change anything in ical.js, it is required to do so
+ * upstream first.
+ *
+ * Current ical.js git revision:
+ * https://github.com/darktrojan/ical.js/commit/0f1af2444b82708bb3a0a6b05d834884dedd8109
+ */
+
+var EXPORTED_SYMBOLS = ["ICAL", "unwrap", "unwrapSetter", "unwrapSingle", "wrapGetter"];
+
+function wrapGetter(type, val) {
+ return val ? new type(val) : null;
+}
+
+function unwrap(type, innerFunc) {
+ return function(val) { return unwrapSetter.call(this, type, val, innerFunc); };
+}
+
+function unwrapSetter(type, val, innerFunc, thisObj) {
+ return innerFunc.call(thisObj || this, unwrapSingle(type, val));
+}
+
+function unwrapSingle(type, val) {
+ if (!val || !val.wrappedJSObject) {
+ return null;
+ } else if (val.wrappedJSObject.innerObject instanceof type) {
+ return val.wrappedJSObject.innerObject;
+ } else {
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ Cu.reportError("Unknown " + (type.icalclass || type) + " passed at " + cal.STACK(10));
+ return null;
+ }
+}
+
+// -- start ical.js --
+
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2021 */
+
+var ICAL = {};
+
+/**
+ * The number of characters before iCalendar line folding should occur
+ * @type {Number}
+ * @default 75
+ */
+ICAL.foldLength = 75;
+
+
+/**
+ * The character(s) to be used for a newline. The default value is provided by
+ * rfc5545.
+ * @type {String}
+ * @default "\r\n"
+ */
+ICAL.newLineChar = '\r\n';
+
+
+/**
+ * Helper functions used in various places within ical.js
+ * @namespace
+ */
+ICAL.helpers = {
+ /**
+ * Compiles a list of all referenced TZIDs in all subcomponents and
+ * removes any extra VTIMEZONE subcomponents. In addition, if any TZIDs
+ * are referenced by a component, but a VTIMEZONE does not exist,
+ * an attempt will be made to generate a VTIMEZONE using ICAL.TimezoneService.
+ *
+ * @param {ICAL.Component} vcal The top-level VCALENDAR component.
+ * @return {ICAL.Component} The ICAL.Component that was passed in.
+ */
+ updateTimezones: function(vcal) {
+ var allsubs, properties, vtimezones, reqTzid, i, tzid;
+
+ if (!vcal || vcal.name !== "vcalendar") {
+ //not a top-level vcalendar component
+ return vcal;
+ }
+
+ //Store vtimezone subcomponents in an object reference by tzid.
+ //Store properties from everything else in another array
+ allsubs = vcal.getAllSubcomponents();
+ properties = [];
+ vtimezones = {};
+ for (i = 0; i < allsubs.length; i++) {
+ if (allsubs[i].name === "vtimezone") {
+ tzid = allsubs[i].getFirstProperty("tzid").getFirstValue();
+ vtimezones[tzid] = allsubs[i];
+ } else {
+ properties = properties.concat(allsubs[i].getAllProperties());
+ }
+ }
+
+ //create an object with one entry for each required tz
+ reqTzid = {};
+ for (i = 0; i < properties.length; i++) {
+ if ((tzid = properties[i].getParameter("tzid"))) {
+ reqTzid[tzid] = true;
+ }
+ }
+
+ //delete any vtimezones that are not on the reqTzid list.
+ for (i in vtimezones) {
+ if (vtimezones.hasOwnProperty(i) && !reqTzid[i]) {
+ vcal.removeSubcomponent(vtimezones[i]);
+ }
+ }
+
+ //create any missing, but registered timezones
+ for (i in reqTzid) {
+ if (
+ reqTzid.hasOwnProperty(i) &&
+ !vtimezones[i] &&
+ ICAL.TimezoneService.has(i)
+ ) {
+ vcal.addSubcomponent(ICAL.TimezoneService.get(i).component);
+ }
+ }
+
+ return vcal;
+ },
+
+ /**
+ * Checks if the given type is of the number type and also NaN.
+ *
+ * @param {Number} number The number to check
+ * @return {Boolean} True, if the number is strictly NaN
+ */
+ isStrictlyNaN: function(number) {
+ return typeof(number) === 'number' && isNaN(number);
+ },
+
+ /**
+ * Parses a string value that is expected to be an integer, when the valid is
+ * not an integer throws a decoration error.
+ *
+ * @param {String} string Raw string input
+ * @return {Number} Parsed integer
+ */
+ strictParseInt: function(string) {
+ var result = parseInt(string, 10);
+
+ if (ICAL.helpers.isStrictlyNaN(result)) {
+ throw new Error(
+ 'Could not extract integer from "' + string + '"'
+ );
+ }
+
+ return result;
+ },
+
+ /**
+ * Creates or returns a class instance of a given type with the initialization
+ * data if the data is not already an instance of the given type.
+ *
+ * @example
+ * var time = new ICAL.Time(...);
+ * var result = ICAL.helpers.formatClassType(time, ICAL.Time);
+ *
+ * (result instanceof ICAL.Time)
+ * // => true
+ *
+ * result = ICAL.helpers.formatClassType({}, ICAL.Time);
+ * (result isntanceof ICAL.Time)
+ * // => true
+ *
+ *
+ * @param {Object} data object initialization data
+ * @param {Object} type object type (like ICAL.Time)
+ * @return {?} An instance of the found type.
+ */
+ formatClassType: function formatClassType(data, type) {
+ if (typeof(data) === 'undefined') {
+ return undefined;
+ }
+
+ if (data instanceof type) {
+ return data;
+ }
+ return new type(data);
+ },
+
+ /**
+ * Identical to indexOf but will only match values when they are not preceded
+ * by a backslash character.
+ *
+ * @param {String} buffer String to search
+ * @param {String} search Value to look for
+ * @param {Number} pos Start position
+ * @return {Number} The position, or -1 if not found
+ */
+ unescapedIndexOf: function(buffer, search, pos) {
+ while ((pos = buffer.indexOf(search, pos)) !== -1) {
+ if (pos > 0 && buffer[pos - 1] === '\\') {
+ pos += 1;
+ } else {
+ return pos;
+ }
+ }
+ return -1;
+ },
+
+ /**
+ * Find the index for insertion using binary search.
+ *
+ * @param {Array} list The list to search
+ * @param {?} seekVal The value to insert
+ * @param {function(?,?)} cmpfunc The comparison func, that can
+ * compare two seekVals
+ * @return {Number} The insert position
+ */
+ binsearchInsert: function(list, seekVal, cmpfunc) {
+ if (!list.length)
+ return 0;
+
+ var low = 0, high = list.length - 1,
+ mid, cmpval;
+
+ while (low <= high) {
+ mid = low + Math.floor((high - low) / 2);
+ cmpval = cmpfunc(seekVal, list[mid]);
+
+ if (cmpval < 0)
+ high = mid - 1;
+ else if (cmpval > 0)
+ low = mid + 1;
+ else
+ break;
+ }
+
+ if (cmpval < 0)
+ return mid; // insertion is displacing, so use mid outright.
+ else if (cmpval > 0)
+ return mid + 1;
+ else
+ return mid;
+ },
+
+ /**
+ * Convenience function for debug output
+ * @private
+ */
+ dumpn: /* istanbul ignore next */ function() {
+ if (!ICAL.debug) {
+ return;
+ }
+
+ if (typeof (console) !== 'undefined' && 'log' in console) {
+ ICAL.helpers.dumpn = function consoleDumpn(input) {
+ console.log(input);
+ };
+ } else {
+ ICAL.helpers.dumpn = function geckoDumpn(input) {
+ dump(input + '\n');
+ };
+ }
+
+ ICAL.helpers.dumpn(arguments[0]);
+ },
+
+ /**
+ * Clone the passed object or primitive. By default a shallow clone will be
+ * executed.
+ *
+ * @param {*} aSrc The thing to clone
+ * @param {Boolean=} aDeep If true, a deep clone will be performed
+ * @return {*} The copy of the thing
+ */
+ clone: function(aSrc, aDeep) {
+ if (!aSrc || typeof aSrc != "object") {
+ return aSrc;
+ } else if (aSrc instanceof Date) {
+ return new Date(aSrc.getTime());
+ } else if ("clone" in aSrc) {
+ return aSrc.clone();
+ } else if (Array.isArray(aSrc)) {
+ var arr = [];
+ for (var i = 0; i < aSrc.length; i++) {
+ arr.push(aDeep ? ICAL.helpers.clone(aSrc[i], true) : aSrc[i]);
+ }
+ return arr;
+ } else {
+ var obj = {};
+ for (var name in aSrc) {
+ // uses prototype method to allow use of Object.create(null);
+ /* istanbul ignore else */
+ if (Object.prototype.hasOwnProperty.call(aSrc, name)) {
+ if (aDeep) {
+ obj[name] = ICAL.helpers.clone(aSrc[name], true);
+ } else {
+ obj[name] = aSrc[name];
+ }
+ }
+ }
+ return obj;
+ }
+ },
+
+ /**
+ * Performs iCalendar line folding. A line ending character is inserted and
+ * the next line begins with a whitespace.
+ *
+ * @example
+ * SUMMARY:This line will be fold
+ * ed right in the middle of a word.
+ *
+ * @param {String} aLine The line to fold
+ * @return {String} The folded line
+ */
+ foldline: function foldline(aLine) {
+ var result = "";
+ var line = aLine || "", pos = 0, line_length = 0;
+ //pos counts position in line for the UTF-16 presentation
+ //line_length counts the bytes for the UTF-8 presentation
+ while (line.length) {
+ var cp = line.codePointAt(pos);
+ if (cp < 128) ++line_length;
+ else if (cp < 2048) line_length += 2;//needs 2 UTF-8 bytes
+ else if (cp < 65536) line_length += 3;
+ else line_length += 4; //cp is less than 1114112
+ if (line_length < ICAL.foldLength + 1)
+ pos += cp > 65535 ? 2 : 1;
+ else {
+ result += ICAL.newLineChar + " " + line.substring(0, pos);
+ line = line.substring(pos);
+ pos = line_length = 0;
+ }
+ }
+ return result.substr(ICAL.newLineChar.length + 1);
+ },
+
+ /**
+ * Pads the given string or number with zeros so it will have at least two
+ * characters.
+ *
+ * @param {String|Number} data The string or number to pad
+ * @return {String} The number padded as a string
+ */
+ pad2: function pad(data) {
+ if (typeof(data) !== 'string') {
+ // handle fractions.
+ if (typeof(data) === 'number') {
+ data = parseInt(data);
+ }
+ data = String(data);
+ }
+
+ var len = data.length;
+
+ switch (len) {
+ case 0:
+ return '00';
+ case 1:
+ return '0' + data;
+ default:
+ return data;
+ }
+ },
+
+ /**
+ * Truncates the given number, correctly handling negative numbers.
+ *
+ * @param {Number} number The number to truncate
+ * @return {Number} The truncated number
+ */
+ trunc: function trunc(number) {
+ return (number < 0 ? Math.ceil(number) : Math.floor(number));
+ },
+
+ /**
+ * Poor-man's cross-browser inheritance for JavaScript. Doesn't support all
+ * the features, but enough for our usage.
+ *
+ * @param {Function} base The base class constructor function.
+ * @param {Function} child The child class constructor function.
+ * @param {Object} extra Extends the prototype with extra properties
+ * and methods
+ */
+ inherits: function(base, child, extra) {
+ function F() {}
+ F.prototype = base.prototype;
+ child.prototype = new F();
+
+ if (extra) {
+ ICAL.helpers.extend(extra, child.prototype);
+ }
+ },
+
+ /**
+ * Poor-man's cross-browser object extension. Doesn't support all the
+ * features, but enough for our usage. Note that the target's properties are
+ * not overwritten with the source properties.
+ *
+ * @example
+ * var child = ICAL.helpers.extend(parent, {
+ * "bar": 123
+ * });
+ *
+ * @param {Object} source The object to extend
+ * @param {Object} target The object to extend with
+ * @return {Object} Returns the target.
+ */
+ extend: function(source, target) {
+ for (var key in source) {
+ var descr = Object.getOwnPropertyDescriptor(source, key);
+ if (descr && !Object.getOwnPropertyDescriptor(target, key)) {
+ Object.defineProperty(target, key, descr);
+ }
+ }
+ return target;
+ }
+};
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+/** @namespace ICAL */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.design = (function() {
+ 'use strict';
+
+ var FROM_ICAL_NEWLINE = /\\\\|\\;|\\,|\\[Nn]/g;
+ var TO_ICAL_NEWLINE = /\\|;|,|\n/g;
+ var FROM_VCARD_NEWLINE = /\\\\|\\,|\\[Nn]/g;
+ var TO_VCARD_NEWLINE = /\\|,|\n/g;
+
+ function createTextType(fromNewline, toNewline) {
+ var result = {
+ matches: /.*/,
+
+ fromICAL: function(aValue, structuredEscape) {
+ return replaceNewline(aValue, fromNewline, structuredEscape);
+ },
+
+ toICAL: function(aValue, structuredEscape) {
+ var regEx = toNewline;
+ if (structuredEscape)
+ regEx = new RegExp(regEx.source + '|' + structuredEscape, regEx.flags);
+ return aValue.replace(regEx, function(str) {
+ switch (str) {
+ case "\\":
+ return "\\\\";
+ case ";":
+ return "\\;";
+ case ",":
+ return "\\,";
+ case "\n":
+ return "\\n";
+ /* istanbul ignore next */
+ default:
+ return str;
+ }
+ });
+ }
+ };
+ return result;
+ }
+
+ // default types used multiple times
+ var DEFAULT_TYPE_TEXT = { defaultType: "text" };
+ var DEFAULT_TYPE_TEXT_MULTI = { defaultType: "text", multiValue: "," };
+ var DEFAULT_TYPE_TEXT_STRUCTURED = { defaultType: "text", structuredValue: ";" };
+ var DEFAULT_TYPE_INTEGER = { defaultType: "integer" };
+ var DEFAULT_TYPE_DATETIME_DATE = { defaultType: "date-time", allowedTypes: ["date-time", "date"] };
+ var DEFAULT_TYPE_DATETIME = { defaultType: "date-time" };
+ var DEFAULT_TYPE_URI = { defaultType: "uri" };
+ var DEFAULT_TYPE_UTCOFFSET = { defaultType: "utc-offset" };
+ var DEFAULT_TYPE_RECUR = { defaultType: "recur" };
+ var DEFAULT_TYPE_DATE_ANDOR_TIME = { defaultType: "date-and-or-time", allowedTypes: ["date-time", "date", "text"] };
+
+ function replaceNewlineReplace(string) {
+ switch (string) {
+ case "\\\\":
+ return "\\";
+ case "\\;":
+ return ";";
+ case "\\,":
+ return ",";
+ case "\\n":
+ case "\\N":
+ return "\n";
+ /* istanbul ignore next */
+ default:
+ return string;
+ }
+ }
+
+ function replaceNewline(value, newline, structuredEscape) {
+ // avoid regex when possible.
+ if (value.indexOf('\\') === -1) {
+ return value;
+ }
+ if (structuredEscape)
+ newline = new RegExp(newline.source + '|\\\\' + structuredEscape, newline.flags);
+ return value.replace(newline, replaceNewlineReplace);
+ }
+
+ var commonProperties = {
+ "categories": DEFAULT_TYPE_TEXT_MULTI,
+ "url": DEFAULT_TYPE_URI,
+ "version": DEFAULT_TYPE_TEXT,
+ "uid": DEFAULT_TYPE_TEXT
+ };
+
+ var commonValues = {
+ "boolean": {
+ values: ["TRUE", "FALSE"],
+
+ fromICAL: function(aValue) {
+ switch (aValue) {
+ case 'TRUE':
+ return true;
+ case 'FALSE':
+ return false;
+ default:
+ //TODO: parser warning
+ return false;
+ }
+ },
+
+ toICAL: function(aValue) {
+ if (aValue) {
+ return 'TRUE';
+ }
+ return 'FALSE';
+ }
+
+ },
+ float: {
+ matches: /^[+-]?\d+\.\d+$/,
+
+ fromICAL: function(aValue) {
+ var parsed = parseFloat(aValue);
+ if (ICAL.helpers.isStrictlyNaN(parsed)) {
+ // TODO: parser warning
+ return 0.0;
+ }
+ return parsed;
+ },
+
+ toICAL: function(aValue) {
+ return String(aValue);
+ }
+ },
+ integer: {
+ fromICAL: function(aValue) {
+ var parsed = parseInt(aValue);
+ if (ICAL.helpers.isStrictlyNaN(parsed)) {
+ return 0;
+ }
+ return parsed;
+ },
+
+ toICAL: function(aValue) {
+ return String(aValue);
+ }
+ },
+ "utc-offset": {
+ toICAL: function(aValue) {
+ if (aValue.length < 7) {
+ // no seconds
+ // -0500
+ return aValue.substr(0, 3) +
+ aValue.substr(4, 2);
+ } else {
+ // seconds
+ // -050000
+ return aValue.substr(0, 3) +
+ aValue.substr(4, 2) +
+ aValue.substr(7, 2);
+ }
+ },
+
+ fromICAL: function(aValue) {
+ if (aValue.length < 6) {
+ // no seconds
+ // -05:00
+ return aValue.substr(0, 3) + ':' +
+ aValue.substr(3, 2);
+ } else {
+ // seconds
+ // -05:00:00
+ return aValue.substr(0, 3) + ':' +
+ aValue.substr(3, 2) + ':' +
+ aValue.substr(5, 2);
+ }
+ },
+
+ decorate: function(aValue) {
+ return ICAL.UtcOffset.fromString(aValue);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ }
+ };
+
+ var icalParams = {
+ // Although the syntax is DQUOTE uri DQUOTE, I don't think we should
+ // enforce anything aside from it being a valid content line.
+ //
+ // At least some params require - if multi values are used - DQUOTEs
+ // for each of its values - e.g. delegated-from="uri1","uri2"
+ // To indicate this, I introduced the new k/v pair
+ // multiValueSeparateDQuote: true
+ //
+ // "ALTREP": { ... },
+
+ // CN just wants a param-value
+ // "CN": { ... }
+
+ "cutype": {
+ values: ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM", "UNKNOWN"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+
+ "delegated-from": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ "delegated-to": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ // "DIR": { ... }, // See ALTREP
+ "encoding": {
+ values: ["8BIT", "BASE64"]
+ },
+ // "FMTTYPE": { ... }, // See ALTREP
+ "fbtype": {
+ values: ["FREE", "BUSY", "BUSY-UNAVAILABLE", "BUSY-TENTATIVE"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ // "LANGUAGE": { ... }, // See ALTREP
+ "member": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ "partstat": {
+ // TODO These values are actually different per-component
+ values: ["NEEDS-ACTION", "ACCEPTED", "DECLINED", "TENTATIVE",
+ "DELEGATED", "COMPLETED", "IN-PROCESS"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "range": {
+ values: ["THISANDFUTURE"]
+ },
+ "related": {
+ values: ["START", "END"]
+ },
+ "reltype": {
+ values: ["PARENT", "CHILD", "SIBLING"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "role": {
+ values: ["REQ-PARTICIPANT", "CHAIR",
+ "OPT-PARTICIPANT", "NON-PARTICIPANT"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "rsvp": {
+ values: ["TRUE", "FALSE"]
+ },
+ "sent-by": {
+ valueType: "cal-address"
+ },
+ "tzid": {
+ matches: /^\//
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["binary", "boolean", "cal-address", "date", "date-time",
+ "duration", "float", "integer", "period", "recur", "text",
+ "time", "uri", "utc-offset"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ // When adding a value here, be sure to add it to the parameter types!
+ var icalValues = ICAL.helpers.extend(commonValues, {
+ text: createTextType(FROM_ICAL_NEWLINE, TO_ICAL_NEWLINE),
+
+ uri: {
+ // TODO
+ /* ... */
+ },
+
+ "binary": {
+ decorate: function(aString) {
+ return ICAL.Binary.fromString(aString);
+ },
+
+ undecorate: function(aBinary) {
+ return aBinary.toString();
+ }
+ },
+ "cal-address": {
+ // needs to be an uri
+ },
+ "date": {
+ decorate: function(aValue, aProp) {
+ if (design.strict) {
+ return ICAL.Time.fromDateString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromString(aValue, aProp);
+ }
+ },
+
+ /**
+ * undecorates a time object.
+ */
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ // from: 20120901
+ // to: 2012-09-01
+ if (!design.strict && aValue.length >= 15) {
+ // This is probably a date-time, e.g. 20120901T130000Z
+ return icalValues["date-time"].fromICAL(aValue);
+ } else {
+ return aValue.substr(0, 4) + '-' +
+ aValue.substr(4, 2) + '-' +
+ aValue.substr(6, 2);
+ }
+ },
+
+ toICAL: function(aValue) {
+ // from: 2012-09-01
+ // to: 20120901
+ var len = aValue.length;
+
+ if (len == 10) {
+ return aValue.substr(0, 4) +
+ aValue.substr(5, 2) +
+ aValue.substr(8, 2);
+ } else if (len >= 19) {
+ return icalValues["date-time"].toICAL(aValue);
+ } else {
+ //TODO: serialize warning?
+ return aValue;
+ }
+
+ }
+ },
+ "date-time": {
+ fromICAL: function(aValue) {
+ // from: 20120901T130000
+ // to: 2012-09-01T13:00:00
+ if (!design.strict && aValue.length == 8) {
+ // This is probably a date, e.g. 20120901
+ return icalValues.date.fromICAL(aValue);
+ } else {
+ var result = aValue.substr(0, 4) + '-' +
+ aValue.substr(4, 2) + '-' +
+ aValue.substr(6, 2) + 'T' +
+ aValue.substr(9, 2) + ':' +
+ aValue.substr(11, 2) + ':' +
+ aValue.substr(13, 2);
+
+ if (aValue[15] && aValue[15] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ }
+ },
+
+ toICAL: function(aValue) {
+ // from: 2012-09-01T13:00:00
+ // to: 20120901T130000
+ var len = aValue.length;
+
+ if (len == 10 && !design.strict) {
+ return icalValues.date.toICAL(aValue);
+ } else if (len >= 19) {
+ var result = aValue.substr(0, 4) +
+ aValue.substr(5, 2) +
+ // grab the (DDTHH) segment
+ aValue.substr(8, 5) +
+ // MM
+ aValue.substr(14, 2) +
+ // SS
+ aValue.substr(17, 2);
+
+ if (aValue[19] && aValue[19] === 'Z') {
+ result += 'Z';
+ }
+ return result;
+ } else {
+ // TODO: error
+ return aValue;
+ }
+ },
+
+ decorate: function(aValue, aProp) {
+ if (design.strict) {
+ return ICAL.Time.fromDateTimeString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromString(aValue, aProp);
+ }
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ },
+ duration: {
+ decorate: function(aValue) {
+ return ICAL.Duration.fromString(aValue);
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ },
+ period: {
+ fromICAL: function(string) {
+ var parts = string.split('/');
+ parts[0] = icalValues['date-time'].fromICAL(parts[0]);
+
+ if (!ICAL.Duration.isValueString(parts[1])) {
+ parts[1] = icalValues['date-time'].fromICAL(parts[1]);
+ }
+
+ return parts;
+ },
+
+ toICAL: function(parts) {
+ parts = parts.slice();
+ if (!design.strict && parts[0].length == 10) {
+ parts[0] = icalValues.date.toICAL(parts[0]);
+ } else {
+ parts[0] = icalValues['date-time'].toICAL(parts[0]);
+ }
+
+ if (!ICAL.Duration.isValueString(parts[1])) {
+ if (!design.strict && parts[1].length == 10) {
+ parts[1] = icalValues.date.toICAL(parts[1]);
+ } else {
+ parts[1] = icalValues['date-time'].toICAL(parts[1]);
+ }
+ }
+
+ return parts.join("/");
+ },
+
+ decorate: function(aValue, aProp) {
+ return ICAL.Period.fromJSON(aValue, aProp, !design.strict);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toJSON();
+ }
+ },
+ recur: {
+ fromICAL: function(string) {
+ return ICAL.Recur._stringToData(string, true);
+ },
+
+ toICAL: function(data) {
+ var str = "";
+ for (var k in data) {
+ /* istanbul ignore if */
+ if (!Object.prototype.hasOwnProperty.call(data, k)) {
+ continue;
+ }
+ var val = data[k];
+ if (k == "until") {
+ if (val.length > 10) {
+ val = icalValues['date-time'].toICAL(val);
+ } else {
+ val = icalValues.date.toICAL(val);
+ }
+ } else if (k == "wkst") {
+ if (typeof val === 'number') {
+ val = ICAL.Recur.numericDayToIcalDay(val);
+ }
+ } else if (Array.isArray(val)) {
+ val = val.join(",");
+ }
+ str += k.toUpperCase() + "=" + val + ";";
+ }
+ return str.substr(0, str.length - 1);
+ },
+
+ decorate: function decorate(aValue) {
+ return ICAL.Recur.fromData(aValue);
+ },
+
+ undecorate: function(aRecur) {
+ return aRecur.toJSON();
+ }
+ },
+
+ time: {
+ fromICAL: function(aValue) {
+ // from: MMHHSS(Z)?
+ // to: HH:MM:SS(Z)?
+ if (aValue.length < 6) {
+ // TODO: parser exception?
+ return aValue;
+ }
+
+ // HH::MM::SSZ?
+ var result = aValue.substr(0, 2) + ':' +
+ aValue.substr(2, 2) + ':' +
+ aValue.substr(4, 2);
+
+ if (aValue[6] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ },
+
+ toICAL: function(aValue) {
+ // from: HH:MM:SS(Z)?
+ // to: MMHHSS(Z)?
+ if (aValue.length < 8) {
+ //TODO: error
+ return aValue;
+ }
+
+ var result = aValue.substr(0, 2) +
+ aValue.substr(3, 2) +
+ aValue.substr(6, 2);
+
+ if (aValue[8] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ }
+ }
+ });
+
+ var icalProperties = ICAL.helpers.extend(commonProperties, {
+
+ "action": DEFAULT_TYPE_TEXT,
+ "attach": { defaultType: "uri" },
+ "attendee": { defaultType: "cal-address" },
+ "calscale": DEFAULT_TYPE_TEXT,
+ "class": DEFAULT_TYPE_TEXT,
+ "comment": DEFAULT_TYPE_TEXT,
+ "completed": DEFAULT_TYPE_DATETIME,
+ "contact": DEFAULT_TYPE_TEXT,
+ "created": DEFAULT_TYPE_DATETIME,
+ "description": DEFAULT_TYPE_TEXT,
+ "dtend": DEFAULT_TYPE_DATETIME_DATE,
+ "dtstamp": DEFAULT_TYPE_DATETIME,
+ "dtstart": DEFAULT_TYPE_DATETIME_DATE,
+ "due": DEFAULT_TYPE_DATETIME_DATE,
+ "duration": { defaultType: "duration" },
+ "exdate": {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ multiValue: ','
+ },
+ "exrule": DEFAULT_TYPE_RECUR,
+ "freebusy": { defaultType: "period", multiValue: "," },
+ "geo": { defaultType: "float", structuredValue: ";" },
+ "last-modified": DEFAULT_TYPE_DATETIME,
+ "location": DEFAULT_TYPE_TEXT,
+ "method": DEFAULT_TYPE_TEXT,
+ "organizer": { defaultType: "cal-address" },
+ "percent-complete": DEFAULT_TYPE_INTEGER,
+ "priority": DEFAULT_TYPE_INTEGER,
+ "prodid": DEFAULT_TYPE_TEXT,
+ "related-to": DEFAULT_TYPE_TEXT,
+ "repeat": DEFAULT_TYPE_INTEGER,
+ "rdate": {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date", "period"],
+ multiValue: ',',
+ detectType: function(string) {
+ if (string.indexOf('/') !== -1) {
+ return 'period';
+ }
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+ "recurrence-id": DEFAULT_TYPE_DATETIME_DATE,
+ "resources": DEFAULT_TYPE_TEXT_MULTI,
+ "request-status": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "rrule": DEFAULT_TYPE_RECUR,
+ "sequence": DEFAULT_TYPE_INTEGER,
+ "status": DEFAULT_TYPE_TEXT,
+ "summary": DEFAULT_TYPE_TEXT,
+ "transp": DEFAULT_TYPE_TEXT,
+ "trigger": { defaultType: "duration", allowedTypes: ["duration", "date-time"] },
+ "tzoffsetfrom": DEFAULT_TYPE_UTCOFFSET,
+ "tzoffsetto": DEFAULT_TYPE_UTCOFFSET,
+ "tzurl": DEFAULT_TYPE_URI,
+ "tzid": DEFAULT_TYPE_TEXT,
+ "tzname": DEFAULT_TYPE_TEXT
+ });
+
+ // When adding a value here, be sure to add it to the parameter types!
+ var vcardValues = ICAL.helpers.extend(commonValues, {
+ text: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE),
+ uri: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE),
+
+ date: {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date");
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+ fromICAL: function(aValue) {
+ if (aValue.length == 8) {
+ return icalValues.date.fromICAL(aValue);
+ } else if (aValue[0] == '-' && aValue.length == 6) {
+ return aValue.substr(0, 4) + '-' + aValue.substr(4);
+ } else {
+ return aValue;
+ }
+ },
+ toICAL: function(aValue) {
+ if (aValue.length == 10) {
+ return icalValues.date.toICAL(aValue);
+ } else if (aValue[0] == '-' && aValue.length == 7) {
+ return aValue.substr(0, 4) + aValue.substr(5);
+ } else {
+ return aValue;
+ }
+ }
+ },
+
+ time: {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString("T" + aValue, "time");
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+ fromICAL: function(aValue) {
+ var splitzone = vcardValues.time._splitZone(aValue, true);
+ var zone = splitzone[0], value = splitzone[1];
+
+ //console.log("SPLIT: ",splitzone);
+
+ if (value.length == 6) {
+ value = value.substr(0, 2) + ':' +
+ value.substr(2, 2) + ':' +
+ value.substr(4, 2);
+ } else if (value.length == 4 && value[0] != '-') {
+ value = value.substr(0, 2) + ':' + value.substr(2, 2);
+ } else if (value.length == 5) {
+ value = value.substr(0, 3) + ':' + value.substr(3, 2);
+ }
+
+ if (zone.length == 5 && (zone[0] == '-' || zone[0] == '+')) {
+ zone = zone.substr(0, 3) + ':' + zone.substr(3);
+ }
+
+ return value + zone;
+ },
+
+ toICAL: function(aValue) {
+ var splitzone = vcardValues.time._splitZone(aValue);
+ var zone = splitzone[0], value = splitzone[1];
+
+ if (value.length == 8) {
+ value = value.substr(0, 2) +
+ value.substr(3, 2) +
+ value.substr(6, 2);
+ } else if (value.length == 5 && value[0] != '-') {
+ value = value.substr(0, 2) + value.substr(3, 2);
+ } else if (value.length == 6) {
+ value = value.substr(0, 3) + value.substr(4, 2);
+ }
+
+ if (zone.length == 6 && (zone[0] == '-' || zone[0] == '+')) {
+ zone = zone.substr(0, 3) + zone.substr(4);
+ }
+
+ return value + zone;
+ },
+
+ _splitZone: function(aValue, isFromIcal) {
+ var lastChar = aValue.length - 1;
+ var signChar = aValue.length - (isFromIcal ? 5 : 6);
+ var sign = aValue[signChar];
+ var zone, value;
+
+ if (aValue[lastChar] == 'Z') {
+ zone = aValue[lastChar];
+ value = aValue.substr(0, lastChar);
+ } else if (aValue.length > 6 && (sign == '-' || sign == '+')) {
+ zone = aValue.substr(signChar);
+ value = aValue.substr(0, signChar);
+ } else {
+ zone = "";
+ value = aValue;
+ }
+
+ return [zone, value];
+ }
+ },
+
+ "date-time": {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-time");
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ return vcardValues['date-and-or-time'].fromICAL(aValue);
+ },
+
+ toICAL: function(aValue) {
+ return vcardValues['date-and-or-time'].toICAL(aValue);
+ }
+ },
+
+ "date-and-or-time": {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-and-or-time");
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ var parts = aValue.split('T');
+ return (parts[0] ? vcardValues.date.fromICAL(parts[0]) : '') +
+ (parts[1] ? 'T' + vcardValues.time.fromICAL(parts[1]) : '');
+ },
+
+ toICAL: function(aValue) {
+ var parts = aValue.split('T');
+ return vcardValues.date.toICAL(parts[0]) +
+ (parts[1] ? 'T' + vcardValues.time.toICAL(parts[1]) : '');
+
+ }
+ },
+ timestamp: icalValues['date-time'],
+ "language-tag": {
+ matches: /^[a-zA-Z0-9-]+$/ // Could go with a more strict regex here
+ },
+ "phone-number": {
+ fromICAL: function(aValue) {
+ return Array.from(aValue).filter(function(c) {
+ return c === '\\' ? undefined : c;
+ }).join('');
+ },
+ toICAL: function(aValue) {
+ return Array.from(aValue).map(function(c) {
+ return c === ',' || c === ";" ? '\\' + c : c;
+ }).join('');
+ }
+ }
+ });
+
+ var vcardParams = {
+ "type": {
+ valueType: "text",
+ multiValue: ","
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["text", "uri", "date", "time", "date-time", "date-and-or-time",
+ "timestamp", "boolean", "integer", "float", "utc-offset",
+ "language-tag"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ var vcardProperties = ICAL.helpers.extend(commonProperties, {
+ "adr": { defaultType: "text", structuredValue: ";", multiValue: "," },
+ "anniversary": DEFAULT_TYPE_DATE_ANDOR_TIME,
+ "bday": DEFAULT_TYPE_DATE_ANDOR_TIME,
+ "caladruri": DEFAULT_TYPE_URI,
+ "caluri": DEFAULT_TYPE_URI,
+ "clientpidmap": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "email": DEFAULT_TYPE_TEXT,
+ "fburl": DEFAULT_TYPE_URI,
+ "fn": DEFAULT_TYPE_TEXT,
+ "gender": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "geo": DEFAULT_TYPE_URI,
+ "impp": DEFAULT_TYPE_URI,
+ "key": DEFAULT_TYPE_URI,
+ "kind": DEFAULT_TYPE_TEXT,
+ "lang": { defaultType: "language-tag" },
+ "logo": DEFAULT_TYPE_URI,
+ "member": DEFAULT_TYPE_URI,
+ "n": { defaultType: "text", structuredValue: ";", multiValue: "," },
+ "nickname": DEFAULT_TYPE_TEXT_MULTI,
+ "note": DEFAULT_TYPE_TEXT,
+ "org": { defaultType: "text", structuredValue: ";" },
+ "photo": DEFAULT_TYPE_URI,
+ "related": DEFAULT_TYPE_URI,
+ "rev": { defaultType: "timestamp" },
+ "role": DEFAULT_TYPE_TEXT,
+ "sound": DEFAULT_TYPE_URI,
+ "source": DEFAULT_TYPE_URI,
+ "tel": { defaultType: "uri", allowedTypes: ["uri", "text"] },
+ "title": DEFAULT_TYPE_TEXT,
+ "tz": { defaultType: "text", allowedTypes: ["text", "utc-offset", "uri"] },
+ "xml": DEFAULT_TYPE_TEXT
+ });
+
+ var vcard3Values = ICAL.helpers.extend(commonValues, {
+ binary: icalValues.binary,
+ date: vcardValues.date,
+ "date-time": vcardValues["date-time"],
+ "phone-number": vcardValues["phone-number"],
+ uri: icalValues.uri,
+ text: icalValues.text,
+ time: icalValues.time,
+ vcard: icalValues.text,
+ "utc-offset": {
+ toICAL: function(aValue) {
+ return aValue.substr(0, 7);
+ },
+
+ fromICAL: function(aValue) {
+ return aValue.substr(0, 7);
+ },
+
+ decorate: function(aValue) {
+ return ICAL.UtcOffset.fromString(aValue);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ }
+ });
+
+ var vcard3Params = {
+ "type": {
+ valueType: "text",
+ multiValue: ","
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["text", "uri", "date", "date-time", "phone-number", "time",
+ "boolean", "integer", "float", "utc-offset", "vcard", "binary"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ var vcard3Properties = ICAL.helpers.extend(commonProperties, {
+ fn: DEFAULT_TYPE_TEXT,
+ n: { defaultType: "text", structuredValue: ";", multiValue: "," },
+ nickname: DEFAULT_TYPE_TEXT_MULTI,
+ photo: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+ bday: {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ detectType: function(string) {
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+
+ adr: { defaultType: "text", structuredValue: ";", multiValue: "," },
+ label: DEFAULT_TYPE_TEXT,
+
+ tel: { defaultType: "phone-number" },
+ email: DEFAULT_TYPE_TEXT,
+ mailer: DEFAULT_TYPE_TEXT,
+
+ tz: { defaultType: "utc-offset", allowedTypes: ["utc-offset", "text"] },
+ geo: { defaultType: "float", structuredValue: ";" },
+
+ title: DEFAULT_TYPE_TEXT,
+ role: DEFAULT_TYPE_TEXT,
+ logo: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+ agent: { defaultType: "vcard", allowedTypes: ["vcard", "text", "uri"] },
+ org: DEFAULT_TYPE_TEXT_STRUCTURED,
+
+ note: DEFAULT_TYPE_TEXT_MULTI,
+ prodid: DEFAULT_TYPE_TEXT,
+ rev: {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ detectType: function(string) {
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+ "sort-string": DEFAULT_TYPE_TEXT,
+ sound: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+
+ class: DEFAULT_TYPE_TEXT,
+ key: { defaultType: "binary", allowedTypes: ["binary", "text"] }
+ });
+
+ /**
+ * iCalendar design set
+ * @type {ICAL.design.designSet}
+ */
+ var icalSet = {
+ value: icalValues,
+ param: icalParams,
+ property: icalProperties,
+ propertyGroups: false
+ };
+
+ /**
+ * vCard 4.0 design set
+ * @type {ICAL.design.designSet}
+ */
+ var vcardSet = {
+ value: vcardValues,
+ param: vcardParams,
+ property: vcardProperties,
+ propertyGroups: true
+ };
+
+ /**
+ * vCard 3.0 design set
+ * @type {ICAL.design.designSet}
+ */
+ var vcard3Set = {
+ value: vcard3Values,
+ param: vcard3Params,
+ property: vcard3Properties,
+ propertyGroups: true
+ };
+
+ /**
+ * The design data, used by the parser to determine types for properties and
+ * other metadata needed to produce correct jCard/jCal data.
+ *
+ * @alias ICAL.design
+ * @namespace
+ */
+ var design = {
+ /**
+ * A designSet describes value, parameter and property data. It is used by
+ * ther parser and stringifier in components and properties to determine they
+ * should be represented.
+ *
+ * @typedef {Object} designSet
+ * @memberOf ICAL.design
+ * @property {Object} value Definitions for value types, keys are type names
+ * @property {Object} param Definitions for params, keys are param names
+ * @property {Object} property Definitions for properties, keys are property names
+ * @property {boolean} propertyGroups If content lines may include a group name
+ */
+
+ /**
+ * Can be set to false to make the parser more lenient.
+ */
+ strict: true,
+
+ /**
+ * The default set for new properties and components if none is specified.
+ * @type {ICAL.design.designSet}
+ */
+ defaultSet: icalSet,
+
+ /**
+ * The default type for unknown properties
+ * @type {String}
+ */
+ defaultType: 'unknown',
+
+ /**
+ * Holds the design set for known top-level components
+ *
+ * @type {Object}
+ * @property {ICAL.design.designSet} vcard vCard VCARD
+ * @property {ICAL.design.designSet} vevent iCalendar VEVENT
+ * @property {ICAL.design.designSet} vtodo iCalendar VTODO
+ * @property {ICAL.design.designSet} vjournal iCalendar VJOURNAL
+ * @property {ICAL.design.designSet} valarm iCalendar VALARM
+ * @property {ICAL.design.designSet} vtimezone iCalendar VTIMEZONE
+ * @property {ICAL.design.designSet} daylight iCalendar DAYLIGHT
+ * @property {ICAL.design.designSet} standard iCalendar STANDARD
+ *
+ * @example
+ * var propertyName = 'fn';
+ * var componentDesign = ICAL.design.components.vcard;
+ * var propertyDetails = componentDesign.property[propertyName];
+ * if (propertyDetails.defaultType == 'text') {
+ * // Yep, sure is...
+ * }
+ */
+ components: {
+ vcard: vcardSet,
+ vcard3: vcard3Set,
+ vevent: icalSet,
+ vtodo: icalSet,
+ vjournal: icalSet,
+ valarm: icalSet,
+ vtimezone: icalSet,
+ daylight: icalSet,
+ standard: icalSet
+ },
+
+
+ /**
+ * The design set for iCalendar (rfc5545/rfc7265) components.
+ * @type {ICAL.design.designSet}
+ */
+ icalendar: icalSet,
+
+ /**
+ * The design set for vCard (rfc6350/rfc7095) components.
+ * @type {ICAL.design.designSet}
+ */
+ vcard: vcardSet,
+
+ /**
+ * The design set for vCard (rfc2425/rfc2426/rfc7095) components.
+ * @type {ICAL.design.designSet}
+ */
+ vcard3: vcard3Set,
+
+ /**
+ * Gets the design set for the given component name.
+ *
+ * @param {String} componentName The name of the component
+ * @return {ICAL.design.designSet} The design set for the component
+ */
+ getDesignSet: function(componentName) {
+ var isInDesign = componentName && componentName in design.components;
+ return isInDesign ? design.components[componentName] : design.defaultSet;
+ }
+ };
+
+ return design;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * Contains various functions to convert jCal and jCard data back into
+ * iCalendar and vCard.
+ * @namespace
+ */
+ICAL.stringify = (function() {
+ 'use strict';
+
+ var LINE_ENDING = '\r\n';
+ var DEFAULT_VALUE_TYPE = 'unknown';
+
+ var design = ICAL.design;
+ var helpers = ICAL.helpers;
+
+ /**
+ * Convert a full jCal/jCard array into a iCalendar/vCard string.
+ *
+ * @function ICAL.stringify
+ * @variation function
+ * @param {Array} jCal The jCal/jCard document
+ * @return {String} The stringified iCalendar/vCard document
+ */
+ function stringify(jCal) {
+ if (typeof jCal[0] == "string") {
+ // This is a single component
+ jCal = [jCal];
+ }
+
+ var i = 0;
+ var len = jCal.length;
+ var result = '';
+
+ for (; i < len; i++) {
+ result += stringify.component(jCal[i]) + LINE_ENDING;
+ }
+
+ return result;
+ }
+
+ /**
+ * Converts an jCal component array into a ICAL string.
+ * Recursive will resolve sub-components.
+ *
+ * Exact component/property order is not saved all
+ * properties will come before subcomponents.
+ *
+ * @function ICAL.stringify.component
+ * @param {Array} component
+ * jCal/jCard fragment of a component
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this component
+ * @return {String} The iCalendar/vCard string
+ */
+ stringify.component = function(component, designSet) {
+ var name = component[0].toUpperCase();
+ var result = 'BEGIN:' + name + LINE_ENDING;
+
+ var props = component[1];
+ var propIdx = 0;
+ var propLen = props.length;
+
+ var designSetName = component[0];
+ // rfc6350 requires that in vCard 4.0 the first component is the VERSION
+ // component with as value 4.0, note that 3.0 does not have this requirement.
+ if (designSetName === 'vcard' && component[1].length > 0 &&
+ !(component[1][0][0] === "version" && component[1][0][3] === "4.0")) {
+ designSetName = "vcard3";
+ }
+ designSet = designSet || design.getDesignSet(designSetName);
+
+ for (; propIdx < propLen; propIdx++) {
+ result += stringify.property(props[propIdx], designSet) + LINE_ENDING;
+ }
+
+ // Ignore subcomponents if none exist, e.g. in vCard.
+ var comps = component[2] || [];
+ var compIdx = 0;
+ var compLen = comps.length;
+
+ for (; compIdx < compLen; compIdx++) {
+ result += stringify.component(comps[compIdx], designSet) + LINE_ENDING;
+ }
+
+ result += 'END:' + name;
+ return result;
+ };
+
+ /**
+ * Converts a single jCal/jCard property to a iCalendar/vCard string.
+ *
+ * @function ICAL.stringify.property
+ * @param {Array} property
+ * jCal/jCard property array
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this property
+ * @param {Boolean} noFold
+ * If true, the line is not folded
+ * @return {String} The iCalendar/vCard string
+ */
+ stringify.property = function(property, designSet, noFold) {
+ var name = property[0].toUpperCase();
+ var jsName = property[0];
+ var params = property[1];
+
+ if (!designSet) {
+ designSet = design.defaultSet;
+ }
+
+ var groupName = params.group;
+ var line;
+ if (designSet.propertyGroups && groupName) {
+ line = groupName.toUpperCase() + "." + name;
+ } else {
+ line = name;
+ }
+
+ var paramName;
+ for (paramName in params) {
+ if (designSet.propertyGroups && paramName == 'group') {
+ continue;
+ }
+
+ var value = params[paramName];
+ var paramDesign = designSet.param[paramName];
+
+ /* istanbul ignore else */
+ if (params.hasOwnProperty(paramName)) {
+ var multiValue = paramDesign && paramDesign.multiValue;
+ if (multiValue && Array.isArray(value)) {
+ value = value.map(function(val) {
+ val = stringify._rfc6868Unescape(val);
+ val = stringify.paramPropertyValue(val, paramDesign.multiValueSeparateDQuote);
+ return val;
+ });
+ value = stringify.multiValue(value, multiValue, "unknown", null, designSet);
+ } else {
+ value = stringify._rfc6868Unescape(value);
+ value = stringify.paramPropertyValue(value);
+ }
+
+ line += ';' + paramName.toUpperCase() + '=' + value;
+ }
+ }
+
+ if (property.length === 3) {
+ // If there are no values, we must assume a blank value
+ return line + ':';
+ }
+
+ var valueType = property[2];
+
+ var propDetails;
+ var multiValue = false;
+ var structuredValue = false;
+ var isDefault = false;
+
+ if (jsName in designSet.property) {
+ propDetails = designSet.property[jsName];
+
+ if ('multiValue' in propDetails) {
+ multiValue = propDetails.multiValue;
+ }
+
+ if (('structuredValue' in propDetails) && Array.isArray(property[3])) {
+ structuredValue = propDetails.structuredValue;
+ }
+
+ if ('defaultType' in propDetails) {
+ if (valueType === propDetails.defaultType) {
+ isDefault = true;
+ }
+ } else {
+ if (valueType === DEFAULT_VALUE_TYPE) {
+ isDefault = true;
+ }
+ }
+ } else {
+ if (valueType === DEFAULT_VALUE_TYPE) {
+ isDefault = true;
+ }
+ }
+
+ // push the VALUE property if type is not the default
+ // for the current property.
+ if (!isDefault) {
+ // value will never contain ;/:/, so we don't escape it here.
+ line += ';VALUE=' + valueType.toUpperCase();
+ }
+
+ line += ':';
+
+ if (multiValue && structuredValue) {
+ line += stringify.multiValue(
+ property[3], structuredValue, valueType, multiValue, designSet, structuredValue
+ );
+ } else if (multiValue) {
+ line += stringify.multiValue(
+ property.slice(3), multiValue, valueType, null, designSet, false
+ );
+ } else if (structuredValue) {
+ line += stringify.multiValue(
+ property[3], structuredValue, valueType, null, designSet, structuredValue
+ );
+ } else {
+ line += stringify.value(property[3], valueType, designSet, false);
+ }
+
+ return noFold ? line : ICAL.helpers.foldline(line);
+ };
+
+ /**
+ * Handles escaping of property values that may contain:
+ *
+ * COLON (:), SEMICOLON (;), or COMMA (,)
+ *
+ * If any of the above are present the result is wrapped
+ * in double quotes.
+ *
+ * @function ICAL.stringify.paramPropertyValue
+ * @param {String} value Raw property value
+ * @param {boolean} force If value should be escaped even when unnecessary
+ * @return {String} Given or escaped value when needed
+ */
+ stringify.paramPropertyValue = function(value, force) {
+ if (!force &&
+ (helpers.unescapedIndexOf(value, ',') === -1) &&
+ (helpers.unescapedIndexOf(value, ':') === -1) &&
+ (helpers.unescapedIndexOf(value, ';') === -1)) {
+
+ return value;
+ }
+
+ return '"' + value + '"';
+ };
+
+ /**
+ * Converts an array of ical values into a single
+ * string based on a type and a delimiter value (like ",").
+ *
+ * @function ICAL.stringify.multiValue
+ * @param {Array} values List of values to convert
+ * @param {String} delim Used to join the values (",", ";", ":")
+ * @param {String} type Lowecase ical value type
+ * (like boolean, date-time, etc..)
+ * @param {?String} innerMulti If set, each value will again be processed
+ * Used for structured values
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this property
+ *
+ * @return {String} iCalendar/vCard string for value
+ */
+ stringify.multiValue = function(values, delim, type, innerMulti, designSet, structuredValue) {
+ var result = '';
+ var len = values.length;
+ var i = 0;
+
+ for (; i < len; i++) {
+ if (innerMulti && Array.isArray(values[i])) {
+ result += stringify.multiValue(values[i], innerMulti, type, null, designSet, structuredValue);
+ } else {
+ result += stringify.value(values[i], type, designSet, structuredValue);
+ }
+
+ if (i !== (len - 1)) {
+ result += delim;
+ }
+ }
+
+ return result;
+ };
+
+ /**
+ * Processes a single ical value runs the associated "toICAL" method from the
+ * design value type if available to convert the value.
+ *
+ * @function ICAL.stringify.value
+ * @param {String|Number} value A formatted value
+ * @param {String} type Lowercase iCalendar/vCard value type
+ * (like boolean, date-time, etc..)
+ * @return {String} iCalendar/vCard value for single value
+ */
+ stringify.value = function(value, type, designSet, structuredValue) {
+ if (type in designSet.value && 'toICAL' in designSet.value[type]) {
+ return designSet.value[type].toICAL(value, structuredValue);
+ }
+ return value;
+ };
+
+ /**
+ * Internal helper for rfc6868. Exposing this on ICAL.stringify so that
+ * hackers can disable the rfc6868 parsing if the really need to.
+ *
+ * @param {String} val The value to unescape
+ * @return {String} The escaped value
+ */
+ stringify._rfc6868Unescape = function(val) {
+ return val.replace(/[\n^"]/g, function(x) {
+ return RFC6868_REPLACE_MAP[x];
+ });
+ };
+ var RFC6868_REPLACE_MAP = { '"': "^'", "\n": "^n", "^": "^^" };
+
+ return stringify;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * Contains various functions to parse iCalendar and vCard data.
+ * @namespace
+ */
+ICAL.parse = (function() {
+ 'use strict';
+
+ var CHAR = /[^ \t]/;
+ var MULTIVALUE_DELIMITER = ',';
+ var VALUE_DELIMITER = ':';
+ var PARAM_DELIMITER = ';';
+ var PARAM_NAME_DELIMITER = '=';
+ var DEFAULT_VALUE_TYPE = 'unknown';
+ var DEFAULT_PARAM_TYPE = 'text';
+
+ var design = ICAL.design;
+ var helpers = ICAL.helpers;
+
+ /**
+ * An error that occurred during parsing.
+ *
+ * @param {String} message The error message
+ * @memberof ICAL.parse
+ * @extends {Error}
+ * @class
+ */
+ function ParserError(message) {
+ this.message = message;
+ this.name = 'ParserError';
+
+ try {
+ throw new Error();
+ } catch (e) {
+ if (e.stack) {
+ var split = e.stack.split('\n');
+ split.shift();
+ this.stack = split.join('\n');
+ }
+ }
+ }
+
+ ParserError.prototype = Error.prototype;
+
+ /**
+ * Parses iCalendar or vCard data into a raw jCal object. Consult
+ * documentation on the {@tutorial layers|layers of parsing} for more
+ * details.
+ *
+ * @function ICAL.parse
+ * @variation function
+ * @todo Fix the API to be more clear on the return type
+ * @param {String} input The string data to parse
+ * @return {Object|Object[]} A single jCal object, or an array thereof
+ */
+ function parser(input) {
+ var state = {};
+ var root = state.component = [];
+
+ state.stack = [root];
+
+ parser._eachLine(input, function(err, line) {
+ parser._handleContentLine(line, state);
+ });
+
+
+ // when there are still items on the stack
+ // throw a fatal error, a component was not closed
+ // correctly in that case.
+ if (state.stack.length > 1) {
+ throw new ParserError(
+ 'invalid ical body. component began but did not end'
+ );
+ }
+
+ state = null;
+
+ return (root.length == 1 ? root[0] : root);
+ }
+
+ /**
+ * Parse an iCalendar property value into the jCal for a single property
+ *
+ * @function ICAL.parse.property
+ * @param {String} str
+ * The iCalendar property string to parse
+ * @param {ICAL.design.designSet=} designSet
+ * The design data to use for this property
+ * @return {Object}
+ * The jCal Object containing the property
+ */
+ parser.property = function(str, designSet) {
+ var state = {
+ component: [[], []],
+ designSet: designSet || design.defaultSet
+ };
+ parser._handleContentLine(str, state);
+ return state.component[1][0];
+ };
+
+ /**
+ * Convenience method to parse a component. You can use ICAL.parse() directly
+ * instead.
+ *
+ * @function ICAL.parse.component
+ * @see ICAL.parse(function)
+ * @param {String} str The iCalendar component string to parse
+ * @return {Object} The jCal Object containing the component
+ */
+ parser.component = function(str) {
+ return parser(str);
+ };
+
+ // classes & constants
+ parser.ParserError = ParserError;
+
+ /**
+ * The state for parsing content lines from an iCalendar/vCard string.
+ *
+ * @private
+ * @memberof ICAL.parse
+ * @typedef {Object} parserState
+ * @property {ICAL.design.designSet} designSet The design set to use for parsing
+ * @property {ICAL.Component[]} stack The stack of components being processed
+ * @property {ICAL.Component} component The currently active component
+ */
+
+
+ /**
+ * Handles a single line of iCalendar/vCard, updating the state.
+ *
+ * @private
+ * @function ICAL.parse._handleContentLine
+ * @param {String} line The content line to process
+ * @param {ICAL.parse.parserState} The current state of the line parsing
+ */
+ parser._handleContentLine = function(line, state) {
+ // break up the parts of the line
+ var valuePos = line.indexOf(VALUE_DELIMITER);
+ var paramPos = line.indexOf(PARAM_DELIMITER);
+
+ var lastParamIndex;
+ var lastValuePos;
+
+ // name of property or begin/end
+ var name;
+ var value;
+ // params is only overridden if paramPos !== -1.
+ // we can't do params = params || {} later on
+ // because it sacrifices ops.
+ var params = {};
+
+ /**
+ * Different property cases
+ *
+ *
+ * 1. RRULE:FREQ=foo
+ * // FREQ= is not a param but the value
+ *
+ * 2. ATTENDEE;ROLE=REQ-PARTICIPANT;
+ * // ROLE= is a param because : has not happened yet
+ */
+ // when the parameter delimiter is after the
+ // value delimiter then it is not a parameter.
+
+ if ((paramPos !== -1 && valuePos !== -1)) {
+ // when the parameter delimiter is after the
+ // value delimiter then it is not a parameter.
+ if (paramPos > valuePos) {
+ paramPos = -1;
+ }
+ }
+
+ var parsedParams;
+ if (paramPos !== -1) {
+ name = line.substring(0, paramPos).toLowerCase();
+ parsedParams = parser._parseParameters(line.substring(paramPos), 0, state.designSet);
+ if (parsedParams[2] == -1) {
+ throw new ParserError("Invalid parameters in '" + line + "'");
+ }
+ params = parsedParams[0];
+ lastParamIndex = parsedParams[1].length + parsedParams[2] + paramPos;
+ if ((lastValuePos =
+ line.substring(lastParamIndex).indexOf(VALUE_DELIMITER)) !== -1) {
+ value = line.substring(lastParamIndex + lastValuePos + 1);
+ } else {
+ throw new ParserError("Missing parameter value in '" + line + "'");
+ }
+ } else if (valuePos !== -1) {
+ // without parmeters (BEGIN:VCAENDAR, CLASS:PUBLIC)
+ name = line.substring(0, valuePos).toLowerCase();
+ value = line.substring(valuePos + 1);
+
+ if (name === 'begin') {
+ var newComponent = [value.toLowerCase(), [], []];
+ if (state.stack.length === 1) {
+ state.component.push(newComponent);
+ } else {
+ state.component[2].push(newComponent);
+ }
+ state.stack.push(state.component);
+ state.component = newComponent;
+ if (!state.designSet) {
+ state.designSet = design.getDesignSet(state.component[0]);
+ }
+ return;
+ } else if (name === 'end') {
+ state.component = state.stack.pop();
+ return;
+ }
+ // If it is not begin/end, then this is a property with an empty value,
+ // which should be considered valid.
+ } else {
+ /**
+ * Invalid line.
+ * The rational to throw an error is we will
+ * never be certain that the rest of the file
+ * is sane and it is unlikely that we can serialize
+ * the result correctly either.
+ */
+ throw new ParserError(
+ 'invalid line (no token ";" or ":") "' + line + '"'
+ );
+ }
+
+ var valueType;
+ var multiValue = false;
+ var structuredValue = false;
+ var propertyDetails;
+ var splitName;
+ var ungroupedName;
+
+ // fetch the ungrouped part of the name
+ if (state.designSet.propertyGroups && name.indexOf('.') !== -1) {
+ splitName = name.split('.');
+ params.group = splitName[0];
+ ungroupedName = splitName[1];
+ } else {
+ ungroupedName = name;
+ }
+
+ if (ungroupedName in state.designSet.property) {
+ propertyDetails = state.designSet.property[ungroupedName];
+
+ if ('multiValue' in propertyDetails) {
+ multiValue = propertyDetails.multiValue;
+ }
+
+ if ('structuredValue' in propertyDetails) {
+ structuredValue = propertyDetails.structuredValue;
+ }
+
+ if (value && 'detectType' in propertyDetails) {
+ valueType = propertyDetails.detectType(value);
+ }
+ }
+
+ // attempt to determine value
+ if (!valueType) {
+ if (!('value' in params)) {
+ if (propertyDetails) {
+ valueType = propertyDetails.defaultType;
+ } else {
+ valueType = DEFAULT_VALUE_TYPE;
+ }
+ } else {
+ // possible to avoid this?
+ valueType = params.value.toLowerCase();
+ }
+ }
+
+ delete params.value;
+
+ /**
+ * Note on `var result` juggling:
+ *
+ * I observed that building the array in pieces has adverse
+ * effects on performance, so where possible we inline the creation.
+ * It is a little ugly but resulted in ~2000 additional ops/sec.
+ */
+
+ var result;
+ if (multiValue && structuredValue) {
+ value = parser._parseMultiValue(value, structuredValue, valueType, [], multiValue, state.designSet, structuredValue);
+ result = [ungroupedName, params, valueType, value];
+ } else if (multiValue) {
+ result = [ungroupedName, params, valueType];
+ parser._parseMultiValue(value, multiValue, valueType, result, null, state.designSet, false);
+ } else if (structuredValue) {
+ value = parser._parseMultiValue(value, structuredValue, valueType, [], null, state.designSet, structuredValue);
+ result = [ungroupedName, params, valueType, value];
+ } else {
+ value = parser._parseValue(value, valueType, state.designSet, false);
+ result = [ungroupedName, params, valueType, value];
+ }
+ // rfc6350 requires that in vCard 4.0 the first component is the VERSION
+ // component with as value 4.0, note that 3.0 does not have this requirement.
+ if (state.component[0] === 'vcard' && state.component[1].length === 0 &&
+ !(name === 'version' && value === '4.0')) {
+ state.designSet = design.getDesignSet("vcard3");
+ }
+ state.component[1].push(result);
+ };
+
+ /**
+ * Parse a value from the raw value into the jCard/jCal value.
+ *
+ * @private
+ * @function ICAL.parse._parseValue
+ * @param {String} value Original value
+ * @param {String} type Type of value
+ * @param {Object} designSet The design data to use for this value
+ * @return {Object} varies on type
+ */
+ parser._parseValue = function(value, type, designSet, structuredValue) {
+ if (type in designSet.value && 'fromICAL' in designSet.value[type]) {
+ return designSet.value[type].fromICAL(value, structuredValue);
+ }
+ return value;
+ };
+
+ /**
+ * Parse parameters from a string to object.
+ *
+ * @function ICAL.parse._parseParameters
+ * @private
+ * @param {String} line A single unfolded line
+ * @param {Numeric} start Position to start looking for properties
+ * @param {Object} designSet The design data to use for this property
+ * @return {Object} key/value pairs
+ */
+ parser._parseParameters = function(line, start, designSet) {
+ var lastParam = start;
+ var pos = 0;
+ var delim = PARAM_NAME_DELIMITER;
+ var result = {};
+ var name, lcname;
+ var value, valuePos = -1;
+ var type, multiValue, mvdelim;
+
+ // find the next '=' sign
+ // use lastParam and pos to find name
+ // check if " is used if so get value from "->"
+ // then increment pos to find next ;
+
+ while ((pos !== false) &&
+ (pos = helpers.unescapedIndexOf(line, delim, pos + 1)) !== -1) {
+
+ name = line.substr(lastParam + 1, pos - lastParam - 1);
+ if (name.length == 0) {
+ throw new ParserError("Empty parameter name in '" + line + "'");
+ }
+ lcname = name.toLowerCase();
+ mvdelim = false;
+ multiValue = false;
+
+ if (lcname in designSet.param && designSet.param[lcname].valueType) {
+ type = designSet.param[lcname].valueType;
+ } else {
+ type = DEFAULT_PARAM_TYPE;
+ }
+
+ if (lcname in designSet.param) {
+ multiValue = designSet.param[lcname].multiValue;
+ if (designSet.param[lcname].multiValueSeparateDQuote) {
+ mvdelim = parser._rfc6868Escape('"' + multiValue + '"');
+ }
+ }
+
+ var nextChar = line[pos + 1];
+ if (nextChar === '"') {
+ valuePos = pos + 2;
+ pos = helpers.unescapedIndexOf(line, '"', valuePos);
+ if (multiValue && pos != -1) {
+ var extendedValue = true;
+ while (extendedValue) {
+ if (line[pos + 1] == multiValue && line[pos + 2] == '"') {
+ pos = helpers.unescapedIndexOf(line, '"', pos + 3);
+ } else {
+ extendedValue = false;
+ }
+ }
+ }
+ if (pos === -1) {
+ throw new ParserError(
+ 'invalid line (no matching double quote) "' + line + '"'
+ );
+ }
+ value = line.substr(valuePos, pos - valuePos);
+ lastParam = helpers.unescapedIndexOf(line, PARAM_DELIMITER, pos);
+ if (lastParam === -1) {
+ pos = false;
+ }
+ } else {
+ valuePos = pos + 1;
+
+ // move to next ";"
+ var nextPos = helpers.unescapedIndexOf(line, PARAM_DELIMITER, valuePos);
+ var propValuePos = helpers.unescapedIndexOf(line, VALUE_DELIMITER, valuePos);
+ if (propValuePos !== -1 && nextPos > propValuePos) {
+ // this is a delimiter in the property value, let's stop here
+ nextPos = propValuePos;
+ pos = false;
+ } else if (nextPos === -1) {
+ // no ";"
+ if (propValuePos === -1) {
+ nextPos = line.length;
+ } else {
+ nextPos = propValuePos;
+ }
+ pos = false;
+ } else {
+ lastParam = nextPos;
+ pos = nextPos;
+ }
+
+ value = line.substr(valuePos, nextPos - valuePos);
+ }
+
+ value = parser._rfc6868Escape(value);
+ if (multiValue) {
+ var delimiter = mvdelim || multiValue;
+ value = parser._parseMultiValue(value, delimiter, type, [], null, designSet);
+ } else {
+ value = parser._parseValue(value, type, designSet);
+ }
+
+ if (multiValue && (lcname in result)) {
+ if (Array.isArray(result[lcname])) {
+ result[lcname].push(value);
+ } else {
+ result[lcname] = [
+ result[lcname],
+ value
+ ];
+ }
+ } else {
+ result[lcname] = value;
+ }
+ }
+ return [result, value, valuePos];
+ };
+
+ /**
+ * Internal helper for rfc6868. Exposing this on ICAL.parse so that
+ * hackers can disable the rfc6868 parsing if the really need to.
+ *
+ * @function ICAL.parse._rfc6868Escape
+ * @param {String} val The value to escape
+ * @return {String} The escaped value
+ */
+ parser._rfc6868Escape = function(val) {
+ return val.replace(/\^['n^]/g, function(x) {
+ return RFC6868_REPLACE_MAP[x];
+ });
+ };
+ var RFC6868_REPLACE_MAP = { "^'": '"', "^n": "\n", "^^": "^" };
+
+ /**
+ * Parse a multi value string. This function is used either for parsing
+ * actual multi-value property's values, or for handling parameter values. It
+ * can be used for both multi-value properties and structured value properties.
+ *
+ * @private
+ * @function ICAL.parse._parseMultiValue
+ * @param {String} buffer The buffer containing the full value
+ * @param {String} delim The multi-value delimiter
+ * @param {String} type The value type to be parsed
+ * @param {Array.<?>} result The array to append results to, varies on value type
+ * @param {String} innerMulti The inner delimiter to split each value with
+ * @param {ICAL.design.designSet} designSet The design data for this value
+ * @return {?|Array.<?>} Either an array of results, or the first result
+ */
+ parser._parseMultiValue = function(buffer, delim, type, result, innerMulti, designSet, structuredValue) {
+ var pos = 0;
+ var lastPos = 0;
+ var value;
+ if (delim.length === 0) {
+ return buffer;
+ }
+
+ // split each piece
+ while ((pos = helpers.unescapedIndexOf(buffer, delim, lastPos)) !== -1) {
+ value = buffer.substr(lastPos, pos - lastPos);
+ if (innerMulti) {
+ value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue);
+ } else {
+ value = parser._parseValue(value, type, designSet, structuredValue);
+ }
+ result.push(value);
+ lastPos = pos + delim.length;
+ }
+
+ // on the last piece take the rest of string
+ value = buffer.substr(lastPos);
+ if (innerMulti) {
+ value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue);
+ } else {
+ value = parser._parseValue(value, type, designSet, structuredValue);
+ }
+ result.push(value);
+
+ return result.length == 1 ? result[0] : result;
+ };
+
+ /**
+ * Process a complete buffer of iCalendar/vCard data line by line, correctly
+ * unfolding content. Each line will be processed with the given callback
+ *
+ * @private
+ * @function ICAL.parse._eachLine
+ * @param {String} buffer The buffer to process
+ * @param {function(?String, String)} callback The callback for each line
+ */
+ parser._eachLine = function(buffer, callback) {
+ var len = buffer.length;
+ var lastPos = buffer.search(CHAR);
+ var pos = lastPos;
+ var line;
+ var firstChar;
+
+ var newlineOffset;
+
+ do {
+ pos = buffer.indexOf('\n', lastPos) + 1;
+
+ if (pos > 1 && buffer[pos - 2] === '\r') {
+ newlineOffset = 2;
+ } else {
+ newlineOffset = 1;
+ }
+
+ if (pos === 0) {
+ pos = len;
+ newlineOffset = 0;
+ }
+
+ firstChar = buffer[lastPos];
+
+ if (firstChar === ' ' || firstChar === '\t') {
+ // add to line
+ line += buffer.substr(
+ lastPos + 1,
+ pos - lastPos - (newlineOffset + 1)
+ );
+ } else {
+ if (line)
+ callback(null, line);
+ // push line
+ line = buffer.substr(
+ lastPos,
+ pos - lastPos - newlineOffset
+ );
+ }
+
+ lastPos = pos;
+ } while (pos !== len);
+
+ // extra ending line
+ line = line.trim();
+
+ if (line.length)
+ callback(null, line);
+ };
+
+ return parser;
+
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Component = (function() {
+ 'use strict';
+
+ var PROPERTY_INDEX = 1;
+ var COMPONENT_INDEX = 2;
+ var NAME_INDEX = 0;
+
+ /**
+ * @classdesc
+ * Wraps a jCal component, adding convenience methods to add, remove and
+ * update subcomponents and properties.
+ *
+ * @class
+ * @alias ICAL.Component
+ * @param {Array|String} jCal Raw jCal component data OR name of new
+ * component
+ * @param {ICAL.Component} parent Parent component to associate
+ */
+ function Component(jCal, parent) {
+ if (typeof(jCal) === 'string') {
+ // jCal spec (name, properties, components)
+ jCal = [jCal, [], []];
+ }
+
+ // mostly for legacy reasons.
+ this.jCal = jCal;
+
+ this.parent = parent || null;
+
+ if (!this.parent && this.name === 'vcalendar') {
+ this._timezoneCache = new Map();
+ }
+ }
+
+ Component.prototype = {
+ /**
+ * Hydrated properties are inserted into the _properties array at the same
+ * position as in the jCal array, so it is possible that the array contains
+ * undefined values for unhydrdated properties. To avoid iterating the
+ * array when checking if all properties have been hydrated, we save the
+ * count here.
+ *
+ * @type {Number}
+ * @private
+ */
+ _hydratedPropertyCount: 0,
+
+ /**
+ * The same count as for _hydratedPropertyCount, but for subcomponents
+ *
+ * @type {Number}
+ * @private
+ */
+ _hydratedComponentCount: 0,
+
+ /**
+ * A cache of hydrated time zone objects which may be used by consumers, keyed
+ * by time zone ID.
+ *
+ * @type {Map}
+ * @private
+ */
+ _timezoneCache: null,
+
+ /**
+ * The name of this component
+ * @readonly
+ */
+ get name() {
+ return this.jCal[NAME_INDEX];
+ },
+
+ /**
+ * The design set for this component, e.g. icalendar vs vcard
+ *
+ * @type {ICAL.design.designSet}
+ * @private
+ */
+ get _designSet() {
+ var parentDesign = this.parent && this.parent._designSet;
+ return parentDesign || ICAL.design.getDesignSet(this.name);
+ },
+
+ _hydrateComponent: function(index) {
+ if (!this._components) {
+ this._components = [];
+ this._hydratedComponentCount = 0;
+ }
+
+ if (this._components[index]) {
+ return this._components[index];
+ }
+
+ var comp = new Component(
+ this.jCal[COMPONENT_INDEX][index],
+ this
+ );
+
+ this._hydratedComponentCount++;
+ return (this._components[index] = comp);
+ },
+
+ _hydrateProperty: function(index) {
+ if (!this._properties) {
+ this._properties = [];
+ this._hydratedPropertyCount = 0;
+ }
+
+ if (this._properties[index]) {
+ return this._properties[index];
+ }
+
+ var prop = new ICAL.Property(
+ this.jCal[PROPERTY_INDEX][index],
+ this
+ );
+
+ this._hydratedPropertyCount++;
+ return (this._properties[index] = prop);
+ },
+
+ /**
+ * Finds first sub component, optionally filtered by name.
+ *
+ * @param {String=} name Optional name to filter by
+ * @return {?ICAL.Component} The found subcomponent
+ */
+ getFirstSubcomponent: function(name) {
+ if (name) {
+ var i = 0;
+ var comps = this.jCal[COMPONENT_INDEX];
+ var len = comps.length;
+
+ for (; i < len; i++) {
+ if (comps[i][NAME_INDEX] === name) {
+ var result = this._hydrateComponent(i);
+ return result;
+ }
+ }
+ } else {
+ if (this.jCal[COMPONENT_INDEX].length) {
+ return this._hydrateComponent(0);
+ }
+ }
+
+ // ensure we return a value (strict mode)
+ return null;
+ },
+
+ /**
+ * Finds all sub components, optionally filtering by name.
+ *
+ * @param {String=} name Optional name to filter by
+ * @return {ICAL.Component[]} The found sub components
+ */
+ getAllSubcomponents: function(name) {
+ var jCalLen = this.jCal[COMPONENT_INDEX].length;
+ var i = 0;
+
+ if (name) {
+ var comps = this.jCal[COMPONENT_INDEX];
+ var result = [];
+
+ for (; i < jCalLen; i++) {
+ if (name === comps[i][NAME_INDEX]) {
+ result.push(
+ this._hydrateComponent(i)
+ );
+ }
+ }
+ return result;
+ } else {
+ if (!this._components ||
+ (this._hydratedComponentCount !== jCalLen)) {
+ for (; i < jCalLen; i++) {
+ this._hydrateComponent(i);
+ }
+ }
+
+ return this._components || [];
+ }
+ },
+
+ /**
+ * Returns true when a named property exists.
+ *
+ * @param {String} name The property name
+ * @return {Boolean} True, when property is found
+ */
+ hasProperty: function(name) {
+ var props = this.jCal[PROPERTY_INDEX];
+ var len = props.length;
+
+ var i = 0;
+ for (; i < len; i++) {
+ // 0 is property name
+ if (props[i][NAME_INDEX] === name) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Finds the first property, optionally with the given name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {?ICAL.Property} The found property
+ */
+ getFirstProperty: function(name) {
+ if (name) {
+ var i = 0;
+ var props = this.jCal[PROPERTY_INDEX];
+ var len = props.length;
+
+ for (; i < len; i++) {
+ if (props[i][NAME_INDEX] === name) {
+ var result = this._hydrateProperty(i);
+ return result;
+ }
+ }
+ } else {
+ if (this.jCal[PROPERTY_INDEX].length) {
+ return this._hydrateProperty(0);
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Returns first property's value, if available.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {?String} The found property value.
+ */
+ getFirstPropertyValue: function(name) {
+ var prop = this.getFirstProperty(name);
+ if (prop) {
+ return prop.getFirstValue();
+ }
+
+ return null;
+ },
+
+ /**
+ * Get all properties in the component, optionally filtered by name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {ICAL.Property[]} List of properties
+ */
+ getAllProperties: function(name) {
+ var jCalLen = this.jCal[PROPERTY_INDEX].length;
+ var i = 0;
+
+ if (name) {
+ var props = this.jCal[PROPERTY_INDEX];
+ var result = [];
+
+ for (; i < jCalLen; i++) {
+ if (name === props[i][NAME_INDEX]) {
+ result.push(
+ this._hydrateProperty(i)
+ );
+ }
+ }
+ return result;
+ } else {
+ if (!this._properties ||
+ (this._hydratedPropertyCount !== jCalLen)) {
+ for (; i < jCalLen; i++) {
+ this._hydrateProperty(i);
+ }
+ }
+
+ return this._properties || [];
+ }
+ },
+
+ _removeObjectByIndex: function(jCalIndex, cache, index) {
+ cache = cache || [];
+ // remove cached version
+ if (cache[index]) {
+ var obj = cache[index];
+ if ("parent" in obj) {
+ obj.parent = null;
+ }
+ }
+
+ cache.splice(index, 1);
+
+ // remove it from the jCal
+ this.jCal[jCalIndex].splice(index, 1);
+ },
+
+ _removeObject: function(jCalIndex, cache, nameOrObject) {
+ var i = 0;
+ var objects = this.jCal[jCalIndex];
+ var len = objects.length;
+ var cached = this[cache];
+
+ if (typeof(nameOrObject) === 'string') {
+ for (; i < len; i++) {
+ if (objects[i][NAME_INDEX] === nameOrObject) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ return true;
+ }
+ }
+ } else if (cached) {
+ for (; i < len; i++) {
+ if (cached[i] && cached[i] === nameOrObject) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ _removeAllObjects: function(jCalIndex, cache, name) {
+ var cached = this[cache];
+
+ // Unfortunately we have to run through all children to reset their
+ // parent property.
+ var objects = this.jCal[jCalIndex];
+ var i = objects.length - 1;
+
+ // descending search required because splice
+ // is used and will effect the indices.
+ for (; i >= 0; i--) {
+ if (!name || objects[i][NAME_INDEX] === name) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ }
+ }
+ },
+
+ /**
+ * Adds a single sub component.
+ *
+ * @param {ICAL.Component} component The component to add
+ * @return {ICAL.Component} The passed in component
+ */
+ addSubcomponent: function(component) {
+ if (!this._components) {
+ this._components = [];
+ this._hydratedComponentCount = 0;
+ }
+
+ if (component.parent) {
+ component.parent.removeSubcomponent(component);
+ }
+
+ var idx = this.jCal[COMPONENT_INDEX].push(component.jCal);
+ this._components[idx - 1] = component;
+ this._hydratedComponentCount++;
+ component.parent = this;
+ return component;
+ },
+
+ /**
+ * Removes a single component by name or the instance of a specific
+ * component.
+ *
+ * @param {ICAL.Component|String} nameOrComp Name of component, or component
+ * @return {Boolean} True when comp is removed
+ */
+ removeSubcomponent: function(nameOrComp) {
+ var removed = this._removeObject(COMPONENT_INDEX, '_components', nameOrComp);
+ if (removed) {
+ this._hydratedComponentCount--;
+ }
+ return removed;
+ },
+
+ /**
+ * Removes all components or (if given) all components by a particular
+ * name.
+ *
+ * @param {String=} name Lowercase component name
+ */
+ removeAllSubcomponents: function(name) {
+ var removed = this._removeAllObjects(COMPONENT_INDEX, '_components', name);
+ this._hydratedComponentCount = 0;
+ return removed;
+ },
+
+ /**
+ * Adds an {@link ICAL.Property} to the component.
+ *
+ * @param {ICAL.Property} property The property to add
+ * @return {ICAL.Property} The passed in property
+ */
+ addProperty: function(property) {
+ if (!(property instanceof ICAL.Property)) {
+ throw new TypeError('must be instance of ICAL.Property');
+ }
+
+ if (!this._properties) {
+ this._properties = [];
+ this._hydratedPropertyCount = 0;
+ }
+
+ if (property.parent) {
+ property.parent.removeProperty(property);
+ }
+
+ var idx = this.jCal[PROPERTY_INDEX].push(property.jCal);
+ this._properties[idx - 1] = property;
+ this._hydratedPropertyCount++;
+ property.parent = this;
+ return property;
+ },
+
+ /**
+ * Helper method to add a property with a value to the component.
+ *
+ * @param {String} name Property name to add
+ * @param {String|Number|Object} value Property value
+ * @return {ICAL.Property} The created property
+ */
+ addPropertyWithValue: function(name, value) {
+ var prop = new ICAL.Property(name);
+ prop.setValue(value);
+
+ this.addProperty(prop);
+
+ return prop;
+ },
+
+ /**
+ * Helper method that will update or create a property of the given name
+ * and sets its value. If multiple properties with the given name exist,
+ * only the first is updated.
+ *
+ * @param {String} name Property name to update
+ * @param {String|Number|Object} value Property value
+ * @return {ICAL.Property} The created property
+ */
+ updatePropertyWithValue: function(name, value) {
+ var prop = this.getFirstProperty(name);
+
+ if (prop) {
+ prop.setValue(value);
+ } else {
+ prop = this.addPropertyWithValue(name, value);
+ }
+
+ return prop;
+ },
+
+ /**
+ * Removes a single property by name or the instance of the specific
+ * property.
+ *
+ * @param {String|ICAL.Property} nameOrProp Property name or instance to remove
+ * @return {Boolean} True, when deleted
+ */
+ removeProperty: function(nameOrProp) {
+ var removed = this._removeObject(PROPERTY_INDEX, '_properties', nameOrProp);
+ if (removed) {
+ this._hydratedPropertyCount--;
+ }
+ return removed;
+ },
+
+ /**
+ * Removes all properties associated with this component, optionally
+ * filtered by name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {Boolean} True, when deleted
+ */
+ removeAllProperties: function(name) {
+ var removed = this._removeAllObjects(PROPERTY_INDEX, '_properties', name);
+ this._hydratedPropertyCount = 0;
+ return removed;
+ },
+
+ /**
+ * Returns the Object representation of this component. The returned object
+ * is a live jCal object and should be cloned if modified.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return this.jCal;
+ },
+
+ /**
+ * The string representation of this component.
+ * @return {String}
+ */
+ toString: function() {
+ return ICAL.stringify.component(
+ this.jCal, this._designSet
+ );
+ },
+
+ /**
+ * Retrieve a time zone definition from the component tree, if any is present.
+ * If the tree contains no time zone definitions or the TZID cannot be
+ * matched, returns null.
+ *
+ * @param {String} tzid The ID of the time zone to retrieve
+ * @return {ICAL.Timezone} The time zone corresponding to the ID, or null
+ */
+ getTimeZoneByID: function(tzid) {
+ // VTIMEZONE components can only appear as a child of the VCALENDAR
+ // component; walk the tree if we're not the root.
+ if (this.parent) {
+ return this.parent.getTimeZoneByID(tzid);
+ }
+
+ // If there is no time zone cache, we are probably parsing an incomplete
+ // file and will have no time zone definitions.
+ if (!this._timezoneCache) {
+ return null;
+ }
+
+ if (this._timezoneCache.has(tzid)) {
+ return this._timezoneCache.get(tzid);
+ }
+
+ // If the time zone is not already cached, hydrate it from the
+ // subcomponents.
+ var zones = this.getAllSubcomponents('vtimezone');
+ for (var i = 0; i < zones.length; i++) {
+ var zone = zones[i];
+ if (zone.getFirstProperty('tzid').getFirstValue() === tzid) {
+ var hydratedZone = new ICAL.Timezone({
+ component: zone,
+ tzid: tzid,
+ });
+
+ this._timezoneCache.set(tzid, hydratedZone);
+
+ return hydratedZone;
+ }
+ }
+
+ // Per the standard, we should always have a time zone defined in a file
+ // for any referenced TZID, but don't blow up if the file is invalid.
+ return null;
+ }
+ };
+
+ /**
+ * Create an {@link ICAL.Component} by parsing the passed iCalendar string.
+ *
+ * @param {String} str The iCalendar string to parse
+ */
+ Component.fromString = function(str) {
+ return new Component(ICAL.parse.component(str));
+ };
+
+ return Component;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Property = (function() {
+ 'use strict';
+
+ var NAME_INDEX = 0;
+ var PROP_INDEX = 1;
+ var TYPE_INDEX = 2;
+ var VALUE_INDEX = 3;
+
+ var design = ICAL.design;
+
+ /**
+ * @classdesc
+ * Provides a layer on top of the raw jCal object for manipulating a single
+ * property, with its parameters and value.
+ *
+ * @description
+ * It is important to note that mutations done in the wrapper
+ * directly mutate the jCal object used to initialize.
+ *
+ * Can also be used to create new properties by passing
+ * the name of the property (as a String).
+ *
+ * @class
+ * @alias ICAL.Property
+ * @param {Array|String} jCal Raw jCal representation OR
+ * the new name of the property
+ *
+ * @param {ICAL.Component=} parent Parent component
+ */
+ function Property(jCal, parent) {
+ this._parent = parent || null;
+
+ if (typeof(jCal) === 'string') {
+ // We are creating the property by name and need to detect the type
+ this.jCal = [jCal, {}, design.defaultType];
+ this.jCal[TYPE_INDEX] = this.getDefaultType();
+ } else {
+ this.jCal = jCal;
+ }
+ this._updateType();
+ }
+
+ Property.prototype = {
+
+ /**
+ * The value type for this property
+ * @readonly
+ * @type {String}
+ */
+ get type() {
+ return this.jCal[TYPE_INDEX];
+ },
+
+ /**
+ * The name of this property, in lowercase.
+ * @readonly
+ * @type {String}
+ */
+ get name() {
+ return this.jCal[NAME_INDEX];
+ },
+
+ /**
+ * The parent component for this property.
+ * @type {ICAL.Component}
+ */
+ get parent() {
+ return this._parent;
+ },
+
+ set parent(p) {
+ // Before setting the parent, check if the design set has changed. If it
+ // has, we later need to update the type if it was unknown before.
+ var designSetChanged = !this._parent || (p && p._designSet != this._parent._designSet);
+
+ this._parent = p;
+
+ if (this.type == design.defaultType && designSetChanged) {
+ this.jCal[TYPE_INDEX] = this.getDefaultType();
+ this._updateType();
+ }
+
+ return p;
+ },
+
+ /**
+ * The design set for this property, e.g. icalendar vs vcard
+ *
+ * @type {ICAL.design.designSet}
+ * @private
+ */
+ get _designSet() {
+ return this.parent ? this.parent._designSet : design.defaultSet;
+ },
+
+ /**
+ * Updates the type metadata from the current jCal type and design set.
+ *
+ * @private
+ */
+ _updateType: function() {
+ var designSet = this._designSet;
+
+ if (this.type in designSet.value) {
+ var designType = designSet.value[this.type];
+
+ if ('decorate' in designSet.value[this.type]) {
+ this.isDecorated = true;
+ } else {
+ this.isDecorated = false;
+ }
+
+ if (this.name in designSet.property) {
+ this.isMultiValue = ('multiValue' in designSet.property[this.name]);
+ this.isStructuredValue = ('structuredValue' in designSet.property[this.name]);
+ }
+ }
+ },
+
+ /**
+ * Hydrate a single value. The act of hydrating means turning the raw jCal
+ * value into a potentially wrapped object, for example {@link ICAL.Time}.
+ *
+ * @private
+ * @param {Number} index The index of the value to hydrate
+ * @return {Object} The decorated value.
+ */
+ _hydrateValue: function(index) {
+ if (this._values && this._values[index]) {
+ return this._values[index];
+ }
+
+ // for the case where there is no value.
+ if (this.jCal.length <= (VALUE_INDEX + index)) {
+ return null;
+ }
+
+ if (this.isDecorated) {
+ if (!this._values) {
+ this._values = [];
+ }
+ return (this._values[index] = this._decorate(
+ this.jCal[VALUE_INDEX + index]
+ ));
+ } else {
+ return this.jCal[VALUE_INDEX + index];
+ }
+ },
+
+ /**
+ * Decorate a single value, returning its wrapped object. This is used by
+ * the hydrate function to actually wrap the value.
+ *
+ * @private
+ * @param {?} value The value to decorate
+ * @return {Object} The decorated value
+ */
+ _decorate: function(value) {
+ return this._designSet.value[this.type].decorate(value, this);
+ },
+
+ /**
+ * Undecorate a single value, returning its raw jCal data.
+ *
+ * @private
+ * @param {Object} value The value to undecorate
+ * @return {?} The undecorated value
+ */
+ _undecorate: function(value) {
+ return this._designSet.value[this.type].undecorate(value, this);
+ },
+
+ /**
+ * Sets the value at the given index while also hydrating it. The passed
+ * value can either be a decorated or undecorated value.
+ *
+ * @private
+ * @param {?} value The value to set
+ * @param {Number} index The index to set it at
+ */
+ _setDecoratedValue: function(value, index) {
+ if (!this._values) {
+ this._values = [];
+ }
+
+ if (typeof(value) === 'object' && 'icaltype' in value) {
+ // decorated value
+ this.jCal[VALUE_INDEX + index] = this._undecorate(value);
+ this._values[index] = value;
+ } else {
+ // undecorated value
+ this.jCal[VALUE_INDEX + index] = value;
+ this._values[index] = this._decorate(value);
+ }
+ },
+
+ /**
+ * Gets a parameter on the property.
+ *
+ * @param {String} name Parameter name (lowercase)
+ * @return {Array|String} Parameter value
+ */
+ getParameter: function(name) {
+ if (name in this.jCal[PROP_INDEX]) {
+ return this.jCal[PROP_INDEX][name];
+ } else {
+ return undefined;
+ }
+ },
+
+ /**
+ * Gets first parameter on the property.
+ *
+ * @param {String} name Parameter name (lowercase)
+ * @return {String} Parameter value
+ */
+ getFirstParameter: function(name) {
+ var parameters = this.getParameter(name);
+
+ if (Array.isArray(parameters)) {
+ return parameters[0];
+ }
+
+ return parameters;
+ },
+
+ /**
+ * Sets a parameter on the property.
+ *
+ * @param {String} name The parameter name
+ * @param {Array|String} value The parameter value
+ */
+ setParameter: function(name, value) {
+ var lcname = name.toLowerCase();
+ if (typeof value === "string" &&
+ lcname in this._designSet.param &&
+ 'multiValue' in this._designSet.param[lcname]) {
+ value = [value];
+ }
+ this.jCal[PROP_INDEX][name] = value;
+ },
+
+ /**
+ * Removes a parameter
+ *
+ * @param {String} name The parameter name
+ */
+ removeParameter: function(name) {
+ delete this.jCal[PROP_INDEX][name];
+ },
+
+ /**
+ * Get the default type based on this property's name.
+ *
+ * @return {String} The default type for this property
+ */
+ getDefaultType: function() {
+ var name = this.jCal[NAME_INDEX];
+ var designSet = this._designSet;
+
+ if (name in designSet.property) {
+ var details = designSet.property[name];
+ if ('defaultType' in details) {
+ return details.defaultType;
+ }
+ }
+ return design.defaultType;
+ },
+
+ /**
+ * Sets type of property and clears out any existing values of the current
+ * type.
+ *
+ * @param {String} type New iCAL type (see design.*.values)
+ */
+ resetType: function(type) {
+ this.removeAllValues();
+ this.jCal[TYPE_INDEX] = type;
+ this._updateType();
+ },
+
+ /**
+ * Finds the first property value.
+ *
+ * @return {String} First property value
+ */
+ getFirstValue: function() {
+ return this._hydrateValue(0);
+ },
+
+ /**
+ * Gets all values on the property.
+ *
+ * NOTE: this creates an array during each call.
+ *
+ * @return {Array} List of values
+ */
+ getValues: function() {
+ var len = this.jCal.length - VALUE_INDEX;
+
+ if (len < 1) {
+ // it is possible for a property to have no value.
+ return [];
+ }
+
+ var i = 0;
+ var result = [];
+
+ for (; i < len; i++) {
+ result[i] = this._hydrateValue(i);
+ }
+
+ return result;
+ },
+
+ /**
+ * Removes all values from this property
+ */
+ removeAllValues: function() {
+ if (this._values) {
+ this._values.length = 0;
+ }
+ this.jCal.length = 3;
+ },
+
+ /**
+ * Sets the values of the property. Will overwrite the existing values.
+ * This can only be used for multi-value properties.
+ *
+ * @param {Array} values An array of values
+ */
+ setValues: function(values) {
+ if (!this.isMultiValue) {
+ throw new Error(
+ this.name + ': does not not support mulitValue.\n' +
+ 'override isMultiValue'
+ );
+ }
+
+ var len = values.length;
+ var i = 0;
+ this.removeAllValues();
+
+ if (len > 0 &&
+ typeof(values[0]) === 'object' &&
+ 'icaltype' in values[0]) {
+ this.resetType(values[0].icaltype);
+ }
+
+ if (this.isDecorated) {
+ for (; i < len; i++) {
+ this._setDecoratedValue(values[i], i);
+ }
+ } else {
+ for (; i < len; i++) {
+ this.jCal[VALUE_INDEX + i] = values[i];
+ }
+ }
+ },
+
+ /**
+ * Sets the current value of the property. If this is a multi-value
+ * property, all other values will be removed.
+ *
+ * @param {String|Object} value New property value.
+ */
+ setValue: function(value) {
+ this.removeAllValues();
+ if (typeof(value) === 'object' && 'icaltype' in value) {
+ this.resetType(value.icaltype);
+ }
+
+ if (this.isDecorated) {
+ this._setDecoratedValue(value, 0);
+ } else {
+ this.jCal[VALUE_INDEX] = value;
+ }
+ },
+
+ /**
+ * Returns the Object representation of this component. The returned object
+ * is a live jCal object and should be cloned if modified.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return this.jCal;
+ },
+
+ /**
+ * The string representation of this component.
+ * @return {String}
+ */
+ toICALString: function() {
+ return ICAL.stringify.property(
+ this.jCal, this._designSet, true
+ );
+ }
+ };
+
+ /**
+ * Create an {@link ICAL.Property} by parsing the passed iCalendar string.
+ *
+ * @param {String} str The iCalendar string to parse
+ * @param {ICAL.design.designSet=} designSet The design data to use for this property
+ * @return {ICAL.Property} The created iCalendar property
+ */
+ Property.fromString = function(str, designSet) {
+ return new Property(ICAL.parse.property(str, designSet));
+ };
+
+ return Property;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.UtcOffset = (function() {
+
+ /**
+ * @classdesc
+ * This class represents the "duration" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.UtcOffset
+ * @param {Object} aData An object with members of the utc offset
+ * @param {Number=} aData.hours The hours for the utc offset
+ * @param {Number=} aData.minutes The minutes in the utc offset
+ * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1
+ */
+ function UtcOffset(aData) {
+ this.fromData(aData);
+ }
+
+ UtcOffset.prototype = {
+
+ /**
+ * The hours in the utc-offset
+ * @type {Number}
+ */
+ hours: 0,
+
+ /**
+ * The minutes in the utc-offset
+ * @type {Number}
+ */
+ minutes: 0,
+
+ /**
+ * The sign of the utc offset, 1 for positive offset, -1 for negative
+ * offsets.
+ * @type {Number}
+ */
+ factor: 1,
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "utc-offset"
+ */
+ icaltype: "utc-offset",
+
+ /**
+ * Returns a clone of the utc offset object.
+ *
+ * @return {ICAL.UtcOffset} The cloned object
+ */
+ clone: function() {
+ return ICAL.UtcOffset.fromSeconds(this.toSeconds());
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData An object with members of the utc offset
+ * @param {Number=} aData.hours The hours for the utc offset
+ * @param {Number=} aData.minutes The minutes in the utc offset
+ * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1
+ */
+ fromData: function(aData) {
+ if (aData) {
+ for (var key in aData) {
+ /* istanbul ignore else */
+ if (aData.hasOwnProperty(key)) {
+ this[key] = aData[key];
+ }
+ }
+ }
+ this._normalize();
+ },
+
+ /**
+ * Sets up the current instance from the given seconds value. The seconds
+ * value is truncated to the minute. Offsets are wrapped when the world
+ * ends, the hour after UTC+14:00 is UTC-12:00.
+ *
+ * @param {Number} aSeconds The seconds to convert into an offset
+ */
+ fromSeconds: function(aSeconds) {
+ var secs = Math.abs(aSeconds);
+
+ this.factor = aSeconds < 0 ? -1 : 1;
+ this.hours = ICAL.helpers.trunc(secs / 3600);
+
+ secs -= (this.hours * 3600);
+ this.minutes = ICAL.helpers.trunc(secs / 60);
+ return this;
+ },
+
+ /**
+ * Convert the current offset to a value in seconds
+ *
+ * @return {Number} The offset in seconds
+ */
+ toSeconds: function() {
+ return this.factor * (60 * this.minutes + 3600 * this.hours);
+ },
+
+ /**
+ * Compare this utc offset with another one.
+ *
+ * @param {ICAL.UtcOffset} other The other offset to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function icaltime_compare(other) {
+ var a = this.toSeconds();
+ var b = other.toSeconds();
+ return (a > b) - (b > a);
+ },
+
+ _normalize: function() {
+ // Range: 97200 seconds (with 1 hour inbetween)
+ var secs = this.toSeconds();
+ var factor = this.factor;
+ while (secs < -43200) { // = UTC-12:00
+ secs += 97200;
+ }
+ while (secs > 50400) { // = UTC+14:00
+ secs -= 97200;
+ }
+
+ this.fromSeconds(secs);
+
+ // Avoid changing the factor when on zero seconds
+ if (secs == 0) {
+ this.factor = factor;
+ }
+ },
+
+ /**
+ * The iCalendar string representation of this utc-offset.
+ * @return {String}
+ */
+ toICALString: function() {
+ return ICAL.design.icalendar.value['utc-offset'].toICAL(this.toString());
+ },
+
+ /**
+ * The string representation of this utc-offset.
+ * @return {String}
+ */
+ toString: function toString() {
+ return (this.factor == 1 ? "+" : "-") +
+ ICAL.helpers.pad2(this.hours) + ':' +
+ ICAL.helpers.pad2(this.minutes);
+ }
+ };
+
+ /**
+ * Creates a new {@link ICAL.UtcOffset} instance from the passed string.
+ *
+ * @param {String} aString The string to parse
+ * @return {ICAL.Duration} The created utc-offset instance
+ */
+ UtcOffset.fromString = function(aString) {
+ // -05:00
+ var options = {};
+ //TODO: support seconds per rfc5545 ?
+ options.factor = (aString[0] === '+') ? 1 : -1;
+ options.hours = ICAL.helpers.strictParseInt(aString.substr(1, 2));
+ options.minutes = ICAL.helpers.strictParseInt(aString.substr(4, 2));
+
+ return new ICAL.UtcOffset(options);
+ };
+
+ /**
+ * Creates a new {@link ICAL.UtcOffset} instance from the passed seconds
+ * value.
+ *
+ * @param {Number} aSeconds The number of seconds to convert
+ */
+ UtcOffset.fromSeconds = function(aSeconds) {
+ var instance = new UtcOffset();
+ instance.fromSeconds(aSeconds);
+ return instance;
+ };
+
+ return UtcOffset;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Binary = (function() {
+
+ /**
+ * @classdesc
+ * Represents the BINARY value type, which contains extra methods for
+ * encoding and decoding.
+ *
+ * @class
+ * @alias ICAL.Binary
+ * @param {String} aValue The binary data for this value
+ */
+ function Binary(aValue) {
+ this.value = aValue;
+ }
+
+ Binary.prototype = {
+ /**
+ * The type name, to be used in the jCal object.
+ * @default "binary"
+ * @constant
+ */
+ icaltype: "binary",
+
+ /**
+ * Base64 decode the current value
+ *
+ * @return {String} The base64-decoded value
+ */
+ decodeValue: function decodeValue() {
+ return this._b64_decode(this.value);
+ },
+
+ /**
+ * Encodes the passed parameter with base64 and sets the internal
+ * value to the result.
+ *
+ * @param {String} aValue The raw binary value to encode
+ */
+ setEncodedValue: function setEncodedValue(aValue) {
+ this.value = this._b64_encode(aValue);
+ },
+
+ _b64_encode: function base64_encode(data) {
+ // http://kevin.vanzonneveld.net
+ // + original by: Tyler Akins (http://rumkin.com)
+ // + improved by: Bayron Guevara
+ // + improved by: Thunder.m
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + bugfixed by: Pellentesque Malesuada
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + improved by: RafaΕ‚ Kukawski (http://kukawski.pl)
+ // * example 1: base64_encode('Kevin van Zonneveld');
+ // * returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA=='
+ // mozilla has this native
+ // - but breaks in 2.0.0.12!
+ //if (typeof this.window['atob'] == 'function') {
+ // return atob(data);
+ //}
+ var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
+ "abcdefghijklmnopqrstuvwxyz0123456789+/=";
+ var o1, o2, o3, h1, h2, h3, h4, bits, i = 0,
+ ac = 0,
+ enc = "",
+ tmp_arr = [];
+
+ if (!data) {
+ return data;
+ }
+
+ do { // pack three octets into four hexets
+ o1 = data.charCodeAt(i++);
+ o2 = data.charCodeAt(i++);
+ o3 = data.charCodeAt(i++);
+
+ bits = o1 << 16 | o2 << 8 | o3;
+
+ h1 = bits >> 18 & 0x3f;
+ h2 = bits >> 12 & 0x3f;
+ h3 = bits >> 6 & 0x3f;
+ h4 = bits & 0x3f;
+
+ // use hexets to index into b64, and append result to encoded string
+ tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4);
+ } while (i < data.length);
+
+ enc = tmp_arr.join('');
+
+ var r = data.length % 3;
+
+ return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
+
+ },
+
+ _b64_decode: function base64_decode(data) {
+ // http://kevin.vanzonneveld.net
+ // + original by: Tyler Akins (http://rumkin.com)
+ // + improved by: Thunder.m
+ // + input by: Aman Gupta
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + bugfixed by: Onno Marsman
+ // + bugfixed by: Pellentesque Malesuada
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + input by: Brett Zamir (http://brett-zamir.me)
+ // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // * example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA==');
+ // * returns 1: 'Kevin van Zonneveld'
+ // mozilla has this native
+ // - but breaks in 2.0.0.12!
+ //if (typeof this.window['btoa'] == 'function') {
+ // return btoa(data);
+ //}
+ var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
+ "abcdefghijklmnopqrstuvwxyz0123456789+/=";
+ var o1, o2, o3, h1, h2, h3, h4, bits, i = 0,
+ ac = 0,
+ dec = "",
+ tmp_arr = [];
+
+ if (!data) {
+ return data;
+ }
+
+ data += '';
+
+ do { // unpack four hexets into three octets using index points in b64
+ h1 = b64.indexOf(data.charAt(i++));
+ h2 = b64.indexOf(data.charAt(i++));
+ h3 = b64.indexOf(data.charAt(i++));
+ h4 = b64.indexOf(data.charAt(i++));
+
+ bits = h1 << 18 | h2 << 12 | h3 << 6 | h4;
+
+ o1 = bits >> 16 & 0xff;
+ o2 = bits >> 8 & 0xff;
+ o3 = bits & 0xff;
+
+ if (h3 == 64) {
+ tmp_arr[ac++] = String.fromCharCode(o1);
+ } else if (h4 == 64) {
+ tmp_arr[ac++] = String.fromCharCode(o1, o2);
+ } else {
+ tmp_arr[ac++] = String.fromCharCode(o1, o2, o3);
+ }
+ } while (i < data.length);
+
+ dec = tmp_arr.join('');
+
+ return dec;
+ },
+
+ /**
+ * The string representation of this value
+ * @return {String}
+ */
+ toString: function() {
+ return this.value;
+ }
+ };
+
+ /**
+ * Creates a binary value from the given string.
+ *
+ * @param {String} aString The binary value string
+ * @return {ICAL.Binary} The binary value instance
+ */
+ Binary.fromString = function(aString) {
+ return new Binary(aString);
+ };
+
+ return Binary;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+
+(function() {
+ /**
+ * @classdesc
+ * This class represents the "period" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @description
+ * The passed data object cannot contain both and end date and a duration.
+ *
+ * @class
+ * @param {Object} aData An object with members of the period
+ * @param {ICAL.Time=} aData.start The start of the period
+ * @param {ICAL.Time=} aData.end The end of the period
+ * @param {ICAL.Duration=} aData.duration The duration of the period
+ */
+ ICAL.Period = function icalperiod(aData) {
+ this.wrappedJSObject = this;
+
+ if (aData && 'start' in aData) {
+ if (aData.start && !(aData.start instanceof ICAL.Time)) {
+ throw new TypeError('.start must be an instance of ICAL.Time');
+ }
+ this.start = aData.start;
+ }
+
+ if (aData && aData.end && aData.duration) {
+ throw new Error('cannot accept both end and duration');
+ }
+
+ if (aData && 'end' in aData) {
+ if (aData.end && !(aData.end instanceof ICAL.Time)) {
+ throw new TypeError('.end must be an instance of ICAL.Time');
+ }
+ this.end = aData.end;
+ }
+
+ if (aData && 'duration' in aData) {
+ if (aData.duration && !(aData.duration instanceof ICAL.Duration)) {
+ throw new TypeError('.duration must be an instance of ICAL.Duration');
+ }
+ this.duration = aData.duration;
+ }
+ };
+
+ ICAL.Period.prototype = {
+
+ /**
+ * The start of the period
+ * @type {ICAL.Time}
+ */
+ start: null,
+
+ /**
+ * The end of the period
+ * @type {ICAL.Time}
+ */
+ end: null,
+
+ /**
+ * The duration of the period
+ * @type {ICAL.Duration}
+ */
+ duration: null,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalperiod"
+ */
+ icalclass: "icalperiod",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "period"
+ */
+ icaltype: "period",
+
+ /**
+ * Returns a clone of the duration object.
+ *
+ * @return {ICAL.Period} The cloned object
+ */
+ clone: function() {
+ return ICAL.Period.fromData({
+ start: this.start ? this.start.clone() : null,
+ end: this.end ? this.end.clone() : null,
+ duration: this.duration ? this.duration.clone() : null
+ });
+ },
+
+ /**
+ * Calculates the duration of the period, either directly or by subtracting
+ * start from end date.
+ *
+ * @return {ICAL.Duration} The calculated duration
+ */
+ getDuration: function duration() {
+ if (this.duration) {
+ return this.duration;
+ } else {
+ return this.end.subtractDate(this.start);
+ }
+ },
+
+ /**
+ * Calculates the end date of the period, either directly or by adding
+ * duration to start date.
+ *
+ * @return {ICAL.Time} The calculated end date
+ */
+ getEnd: function() {
+ if (this.end) {
+ return this.end;
+ } else {
+ var end = this.start.clone();
+ end.addDuration(this.duration);
+ return end;
+ }
+ },
+
+ /**
+ * The string representation of this period.
+ * @return {String}
+ */
+ toString: function toString() {
+ return this.start + "/" + (this.end || this.duration);
+ },
+
+ /**
+ * The jCal representation of this period type.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return [this.start.toString(), (this.end || this.duration).toString()];
+ },
+
+ /**
+ * The iCalendar string representation of this period.
+ * @return {String}
+ */
+ toICALString: function() {
+ return this.start.toICALString() + "/" +
+ (this.end || this.duration).toICALString();
+ }
+ };
+
+ /**
+ * Creates a new {@link ICAL.Period} instance from the passed string.
+ *
+ * @param {String} str The string to parse
+ * @param {ICAL.Property} prop The property this period will be on
+ * @return {ICAL.Period} The created period instance
+ */
+ ICAL.Period.fromString = function fromString(str, prop) {
+ var parts = str.split('/');
+
+ if (parts.length !== 2) {
+ throw new Error(
+ 'Invalid string value: "' + str + '" must contain a "/" char.'
+ );
+ }
+
+ var options = {
+ start: ICAL.Time.fromDateTimeString(parts[0], prop)
+ };
+
+ var end = parts[1];
+
+ if (ICAL.Duration.isValueString(end)) {
+ options.duration = ICAL.Duration.fromString(end);
+ } else {
+ options.end = ICAL.Time.fromDateTimeString(end, prop);
+ }
+
+ return new ICAL.Period(options);
+ };
+
+ /**
+ * Creates a new {@link ICAL.Period} instance from the given data object.
+ * The passed data object cannot contain both and end date and a duration.
+ *
+ * @param {Object} aData An object with members of the period
+ * @param {ICAL.Time=} aData.start The start of the period
+ * @param {ICAL.Time=} aData.end The end of the period
+ * @param {ICAL.Duration=} aData.duration The duration of the period
+ * @return {ICAL.Period} The period instance
+ */
+ ICAL.Period.fromData = function fromData(aData) {
+ return new ICAL.Period(aData);
+ };
+
+ /**
+ * Returns a new period instance from the given jCal data array. The first
+ * member is always the start date string, the second member is either a
+ * duration or end date string.
+ *
+ * @param {Array<String,String>} aData The jCal data array
+ * @param {ICAL.Property} aProp The property this jCal data is on
+ * @param {Boolean} aLenient If true, data value can be both date and date-time
+ * @return {ICAL.Period} The period instance
+ */
+ ICAL.Period.fromJSON = function(aData, aProp, aLenient) {
+ function fromDateOrDateTimeString(aValue, aProp) {
+ if (aLenient) {
+ return ICAL.Time.fromString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromDateTimeString(aValue, aProp);
+ }
+ }
+
+ if (ICAL.Duration.isValueString(aData[1])) {
+ return ICAL.Period.fromData({
+ start: fromDateOrDateTimeString(aData[0], aProp),
+ duration: ICAL.Duration.fromString(aData[1])
+ });
+ } else {
+ return ICAL.Period.fromData({
+ start: fromDateOrDateTimeString(aData[0], aProp),
+ end: fromDateOrDateTimeString(aData[1], aProp)
+ });
+ }
+ };
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+
+(function() {
+ var DURATION_LETTERS = /([PDWHMTS]{1,1})/;
+
+ /**
+ * @classdesc
+ * This class represents the "duration" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.Duration
+ * @param {Object} data An object with members of the duration
+ * @param {Number} data.weeks Duration in weeks
+ * @param {Number} data.days Duration in days
+ * @param {Number} data.hours Duration in hours
+ * @param {Number} data.minutes Duration in minutes
+ * @param {Number} data.seconds Duration in seconds
+ * @param {Boolean} data.isNegative If true, the duration is negative
+ */
+ ICAL.Duration = function icalduration(data) {
+ this.wrappedJSObject = this;
+ this.fromData(data);
+ };
+
+ ICAL.Duration.prototype = {
+ /**
+ * The weeks in this duration
+ * @type {Number}
+ * @default 0
+ */
+ weeks: 0,
+
+ /**
+ * The days in this duration
+ * @type {Number}
+ * @default 0
+ */
+ days: 0,
+
+ /**
+ * The days in this duration
+ * @type {Number}
+ * @default 0
+ */
+ hours: 0,
+
+ /**
+ * The minutes in this duration
+ * @type {Number}
+ * @default 0
+ */
+ minutes: 0,
+
+ /**
+ * The seconds in this duration
+ * @type {Number}
+ * @default 0
+ */
+ seconds: 0,
+
+ /**
+ * The seconds in this duration
+ * @type {Boolean}
+ * @default false
+ */
+ isNegative: false,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalduration"
+ */
+ icalclass: "icalduration",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "duration"
+ */
+ icaltype: "duration",
+
+ /**
+ * Returns a clone of the duration object.
+ *
+ * @return {ICAL.Duration} The cloned object
+ */
+ clone: function clone() {
+ return ICAL.Duration.fromData(this);
+ },
+
+ /**
+ * The duration value expressed as a number of seconds.
+ *
+ * @return {Number} The duration value in seconds
+ */
+ toSeconds: function toSeconds() {
+ var seconds = this.seconds + 60 * this.minutes + 3600 * this.hours +
+ 86400 * this.days + 7 * 86400 * this.weeks;
+ return (this.isNegative ? -seconds : seconds);
+ },
+
+ /**
+ * Reads the passed seconds value into this duration object. Afterwards,
+ * members like {@link ICAL.Duration#days days} and {@link ICAL.Duration#weeks weeks} will be set up
+ * accordingly.
+ *
+ * @param {Number} aSeconds The duration value in seconds
+ * @return {ICAL.Duration} Returns this instance
+ */
+ fromSeconds: function fromSeconds(aSeconds) {
+ var secs = Math.abs(aSeconds);
+
+ this.isNegative = (aSeconds < 0);
+ this.days = ICAL.helpers.trunc(secs / 86400);
+
+ // If we have a flat number of weeks, use them.
+ if (this.days % 7 == 0) {
+ this.weeks = this.days / 7;
+ this.days = 0;
+ } else {
+ this.weeks = 0;
+ }
+
+ secs -= (this.days + 7 * this.weeks) * 86400;
+
+ this.hours = ICAL.helpers.trunc(secs / 3600);
+ secs -= this.hours * 3600;
+
+ this.minutes = ICAL.helpers.trunc(secs / 60);
+ secs -= this.minutes * 60;
+
+ this.seconds = secs;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData An object with members of the duration
+ * @param {Number} aData.weeks Duration in weeks
+ * @param {Number} aData.days Duration in days
+ * @param {Number} aData.hours Duration in hours
+ * @param {Number} aData.minutes Duration in minutes
+ * @param {Number} aData.seconds Duration in seconds
+ * @param {Boolean} aData.isNegative If true, the duration is negative
+ */
+ fromData: function fromData(aData) {
+ var propsToCopy = ["weeks", "days", "hours",
+ "minutes", "seconds", "isNegative"];
+ for (var key in propsToCopy) {
+ /* istanbul ignore if */
+ if (!propsToCopy.hasOwnProperty(key)) {
+ continue;
+ }
+ var prop = propsToCopy[key];
+ if (aData && prop in aData) {
+ this[prop] = aData[prop];
+ } else {
+ this[prop] = 0;
+ }
+ }
+ },
+
+ /**
+ * Resets the duration instance to the default values, i.e. PT0S
+ */
+ reset: function reset() {
+ this.isNegative = false;
+ this.weeks = 0;
+ this.days = 0;
+ this.hours = 0;
+ this.minutes = 0;
+ this.seconds = 0;
+ },
+
+ /**
+ * Compares the duration instance with another one.
+ *
+ * @param {ICAL.Duration} aOther The instance to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function compare(aOther) {
+ var thisSeconds = this.toSeconds();
+ var otherSeconds = aOther.toSeconds();
+ return (thisSeconds > otherSeconds) - (thisSeconds < otherSeconds);
+ },
+
+ /**
+ * Normalizes the duration instance. For example, a duration with a value
+ * of 61 seconds will be normalized to 1 minute and 1 second.
+ */
+ normalize: function normalize() {
+ this.fromSeconds(this.toSeconds());
+ },
+
+ /**
+ * The string representation of this duration.
+ * @return {String}
+ */
+ toString: function toString() {
+ if (this.toSeconds() == 0) {
+ return "PT0S";
+ } else {
+ var str = "";
+ if (this.isNegative) str += "-";
+ str += "P";
+ if (this.weeks) str += this.weeks + "W";
+ if (this.days) str += this.days + "D";
+
+ if (this.hours || this.minutes || this.seconds) {
+ str += "T";
+ if (this.hours) str += this.hours + "H";
+ if (this.minutes) str += this.minutes + "M";
+ if (this.seconds) str += this.seconds + "S";
+ }
+ return str;
+ }
+ },
+
+ /**
+ * The iCalendar string representation of this duration.
+ * @return {String}
+ */
+ toICALString: function() {
+ return this.toString();
+ }
+ };
+
+ /**
+ * Returns a new ICAL.Duration instance from the passed seconds value.
+ *
+ * @param {Number} aSeconds The seconds to create the instance from
+ * @return {ICAL.Duration} The newly created duration instance
+ */
+ ICAL.Duration.fromSeconds = function icalduration_from_seconds(aSeconds) {
+ return (new ICAL.Duration()).fromSeconds(aSeconds);
+ };
+
+ /**
+ * Internal helper function to handle a chunk of a duration.
+ *
+ * @param {String} letter type of duration chunk
+ * @param {String} number numeric value or -/+
+ * @param {Object} dict target to assign values to
+ */
+ function parseDurationChunk(letter, number, object) {
+ var type;
+ switch (letter) {
+ case 'P':
+ if (number && number === '-') {
+ object.isNegative = true;
+ } else {
+ object.isNegative = false;
+ }
+ // period
+ break;
+ case 'D':
+ type = 'days';
+ break;
+ case 'W':
+ type = 'weeks';
+ break;
+ case 'H':
+ type = 'hours';
+ break;
+ case 'M':
+ type = 'minutes';
+ break;
+ case 'S':
+ type = 'seconds';
+ break;
+ default:
+ // Not a valid chunk
+ return 0;
+ }
+
+ if (type) {
+ if (!number && number !== 0) {
+ throw new Error(
+ 'invalid duration value: Missing number before "' + letter + '"'
+ );
+ }
+ var num = parseInt(number, 10);
+ if (ICAL.helpers.isStrictlyNaN(num)) {
+ throw new Error(
+ 'invalid duration value: Invalid number "' + number + '" before "' + letter + '"'
+ );
+ }
+ object[type] = num;
+ }
+
+ return 1;
+ }
+
+ /**
+ * Checks if the given string is an iCalendar duration value.
+ *
+ * @param {String} value The raw ical value
+ * @return {Boolean} True, if the given value is of the
+ * duration ical type
+ */
+ ICAL.Duration.isValueString = function(string) {
+ return (string[0] === 'P' || string[1] === 'P');
+ };
+
+ /**
+ * Creates a new {@link ICAL.Duration} instance from the passed string.
+ *
+ * @param {String} aStr The string to parse
+ * @return {ICAL.Duration} The created duration instance
+ */
+ ICAL.Duration.fromString = function icalduration_from_string(aStr) {
+ var pos = 0;
+ var dict = Object.create(null);
+ var chunks = 0;
+
+ while ((pos = aStr.search(DURATION_LETTERS)) !== -1) {
+ var type = aStr[pos];
+ var numeric = aStr.substr(0, pos);
+ aStr = aStr.substr(pos + 1);
+
+ chunks += parseDurationChunk(type, numeric, dict);
+ }
+
+ if (chunks < 2) {
+ // There must be at least a chunk with "P" and some unit chunk
+ throw new Error(
+ 'invalid duration value: Not enough duration components in "' + aStr + '"'
+ );
+ }
+
+ return new ICAL.Duration(dict);
+ };
+
+ /**
+ * Creates a new ICAL.Duration instance from the given data object.
+ *
+ * @param {Object} aData An object with members of the duration
+ * @param {Number} aData.weeks Duration in weeks
+ * @param {Number} aData.days Duration in days
+ * @param {Number} aData.hours Duration in hours
+ * @param {Number} aData.minutes Duration in minutes
+ * @param {Number} aData.seconds Duration in seconds
+ * @param {Boolean} aData.isNegative If true, the duration is negative
+ * @return {ICAL.Duration} The createad duration instance
+ */
+ ICAL.Duration.fromData = function icalduration_from_data(aData) {
+ return new ICAL.Duration(aData);
+ };
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2012 */
+
+
+
+(function() {
+ var OPTIONS = ["tzid", "location", "tznames",
+ "latitude", "longitude"];
+
+ /**
+ * @classdesc
+ * Timezone representation, created by passing in a tzid and component.
+ *
+ * @example
+ * var vcalendar;
+ * var timezoneComp = vcalendar.getFirstSubcomponent('vtimezone');
+ * var tzid = timezoneComp.getFirstPropertyValue('tzid');
+ *
+ * var timezone = new ICAL.Timezone({
+ * component: timezoneComp,
+ * tzid
+ * });
+ *
+ * @class
+ * @param {ICAL.Component|Object} data options for class
+ * @param {String|ICAL.Component} data.component
+ * If data is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} data.tzid The timezone identifier
+ * @param {String} data.location The timezone locationw
+ * @param {String} data.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} data.latitude The latitude of the timezone
+ * @param {Number} data.longitude The longitude of the timezone
+ */
+ ICAL.Timezone = function icaltimezone(data) {
+ this.wrappedJSObject = this;
+ this.fromData(data);
+ };
+
+ ICAL.Timezone.prototype = {
+
+ /**
+ * Timezone identifier
+ * @type {String}
+ */
+ tzid: "",
+
+ /**
+ * Timezone location
+ * @type {String}
+ */
+ location: "",
+
+ /**
+ * Alternative timezone name, for the string representation
+ * @type {String}
+ */
+ tznames: "",
+
+ /**
+ * The primary latitude for the timezone.
+ * @type {Number}
+ */
+ latitude: 0.0,
+
+ /**
+ * The primary longitude for the timezone.
+ * @type {Number}
+ */
+ longitude: 0.0,
+
+ /**
+ * The vtimezone component for this timezone.
+ * @type {ICAL.Component}
+ */
+ component: null,
+
+ /**
+ * The year this timezone has been expanded to. All timezone transition
+ * dates until this year are known and can be used for calculation
+ *
+ * @private
+ * @type {Number}
+ */
+ expandedUntilYear: 0,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icaltimezone"
+ */
+ icalclass: "icaltimezone",
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {ICAL.Component|Object} aData options for class
+ * @param {String|ICAL.Component} aData.component
+ * If aData is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} aData.tzid The timezone identifier
+ * @param {String} aData.location The timezone locationw
+ * @param {String} aData.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} aData.latitude The latitude of the timezone
+ * @param {Number} aData.longitude The longitude of the timezone
+ */
+ fromData: function fromData(aData) {
+ this.expandedUntilYear = 0;
+ this.changes = [];
+
+ if (aData instanceof ICAL.Component) {
+ // Either a component is passed directly
+ this.component = aData;
+ } else {
+ // Otherwise the component may be in the data object
+ if (aData && "component" in aData) {
+ if (typeof aData.component == "string") {
+ // If a string was passed, parse it as a component
+ var jCal = ICAL.parse(aData.component);
+ this.component = new ICAL.Component(jCal);
+ } else if (aData.component instanceof ICAL.Component) {
+ // If it was a component already, then just set it
+ this.component = aData.component;
+ } else {
+ // Otherwise just null out the component
+ this.component = null;
+ }
+ }
+
+ // Copy remaining passed properties
+ for (var key in OPTIONS) {
+ /* istanbul ignore else */
+ if (OPTIONS.hasOwnProperty(key)) {
+ var prop = OPTIONS[key];
+ if (aData && prop in aData) {
+ this[prop] = aData[prop];
+ }
+ }
+ }
+ }
+
+ // If we have a component but no TZID, attempt to get it from the
+ // component's properties.
+ if (this.component instanceof ICAL.Component && !this.tzid) {
+ this.tzid = this.component.getFirstPropertyValue('tzid');
+ }
+
+ return this;
+ },
+
+ /**
+ * Finds the utcOffset the given time would occur in this timezone.
+ *
+ * @param {ICAL.Time} tt The time to check for
+ * @return {Number} utc offset in seconds
+ */
+ utcOffset: function utcOffset(tt) {
+ if (this == ICAL.Timezone.utcTimezone || this == ICAL.Timezone.localTimezone) {
+ return 0;
+ }
+
+ this._ensureCoverage(tt.year);
+
+ if (!this.changes.length) {
+ return 0;
+ }
+
+ var tt_change = {
+ year: tt.year,
+ month: tt.month,
+ day: tt.day,
+ hour: tt.hour,
+ minute: tt.minute,
+ second: tt.second
+ };
+
+ var change_num = this._findNearbyChange(tt_change);
+ var change_num_to_use = -1;
+ var step = 1;
+
+ // TODO: replace with bin search?
+ for (;;) {
+ var change = ICAL.helpers.clone(this.changes[change_num], true);
+ if (change.utcOffset < change.prevUtcOffset) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0, change.utcOffset);
+ } else {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ change.prevUtcOffset);
+ }
+
+ var cmp = ICAL.Timezone._compare_change_fn(tt_change, change);
+
+ if (cmp >= 0) {
+ change_num_to_use = change_num;
+ } else {
+ step = -1;
+ }
+
+ if (step == -1 && change_num_to_use != -1) {
+ break;
+ }
+
+ change_num += step;
+
+ if (change_num < 0) {
+ return 0;
+ }
+
+ if (change_num >= this.changes.length) {
+ break;
+ }
+ }
+
+ var zone_change = this.changes[change_num_to_use];
+ var utcOffset_change = zone_change.utcOffset - zone_change.prevUtcOffset;
+
+ if (utcOffset_change < 0 && change_num_to_use > 0) {
+ var tmp_change = ICAL.helpers.clone(zone_change, true);
+ ICAL.Timezone.adjust_change(tmp_change, 0, 0, 0,
+ tmp_change.prevUtcOffset);
+
+ if (ICAL.Timezone._compare_change_fn(tt_change, tmp_change) < 0) {
+ var prev_zone_change = this.changes[change_num_to_use - 1];
+
+ var want_daylight = false; // TODO
+
+ if (zone_change.is_daylight != want_daylight &&
+ prev_zone_change.is_daylight == want_daylight) {
+ zone_change = prev_zone_change;
+ }
+ }
+ }
+
+ // TODO return is_daylight?
+ return zone_change.utcOffset;
+ },
+
+ _findNearbyChange: function icaltimezone_find_nearby_change(change) {
+ // find the closest match
+ var idx = ICAL.helpers.binsearchInsert(
+ this.changes,
+ change,
+ ICAL.Timezone._compare_change_fn
+ );
+
+ if (idx >= this.changes.length) {
+ return this.changes.length - 1;
+ }
+
+ return idx;
+ },
+
+ _ensureCoverage: function(aYear) {
+ if (ICAL.Timezone._minimumExpansionYear == -1) {
+ var today = ICAL.Time.now();
+ ICAL.Timezone._minimumExpansionYear = today.year;
+ }
+
+ var changesEndYear = aYear;
+ if (changesEndYear < ICAL.Timezone._minimumExpansionYear) {
+ changesEndYear = ICAL.Timezone._minimumExpansionYear;
+ }
+
+ changesEndYear += ICAL.Timezone.EXTRA_COVERAGE;
+
+ if (!this.changes.length || this.expandedUntilYear < aYear) {
+ var subcomps = this.component.getAllSubcomponents();
+ var compLen = subcomps.length;
+ var compIdx = 0;
+
+ for (; compIdx < compLen; compIdx++) {
+ this._expandComponent(
+ subcomps[compIdx], changesEndYear, this.changes
+ );
+ }
+
+ this.changes.sort(ICAL.Timezone._compare_change_fn);
+ this.expandedUntilYear = changesEndYear;
+ }
+ },
+
+ _expandComponent: function(aComponent, aYear, changes) {
+ if (!aComponent.hasProperty("dtstart") ||
+ !aComponent.hasProperty("tzoffsetto") ||
+ !aComponent.hasProperty("tzoffsetfrom")) {
+ return null;
+ }
+
+ var dtstart = aComponent.getFirstProperty("dtstart").getFirstValue();
+ var change;
+
+ function convert_tzoffset(offset) {
+ return offset.factor * (offset.hours * 3600 + offset.minutes * 60);
+ }
+
+ function init_changes() {
+ var changebase = {};
+ changebase.is_daylight = (aComponent.name == "daylight");
+ changebase.utcOffset = convert_tzoffset(
+ aComponent.getFirstProperty("tzoffsetto").getFirstValue()
+ );
+
+ changebase.prevUtcOffset = convert_tzoffset(
+ aComponent.getFirstProperty("tzoffsetfrom").getFirstValue()
+ );
+
+ return changebase;
+ }
+
+ if (!aComponent.hasProperty("rrule") && !aComponent.hasProperty("rdate")) {
+ change = init_changes();
+ change.year = dtstart.year;
+ change.month = dtstart.month;
+ change.day = dtstart.day;
+ change.hour = dtstart.hour;
+ change.minute = dtstart.minute;
+ change.second = dtstart.second;
+
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ changes.push(change);
+ } else {
+ var props = aComponent.getAllProperties("rdate");
+ for (var rdatekey in props) {
+ /* istanbul ignore if */
+ if (!props.hasOwnProperty(rdatekey)) {
+ continue;
+ }
+ var rdate = props[rdatekey];
+ var time = rdate.getFirstValue();
+ change = init_changes();
+
+ change.year = time.year;
+ change.month = time.month;
+ change.day = time.day;
+
+ if (time.isDate) {
+ change.hour = dtstart.hour;
+ change.minute = dtstart.minute;
+ change.second = dtstart.second;
+
+ if (dtstart.zone != ICAL.Timezone.utcTimezone) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ }
+ } else {
+ change.hour = time.hour;
+ change.minute = time.minute;
+ change.second = time.second;
+
+ if (time.zone != ICAL.Timezone.utcTimezone) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ }
+ }
+
+ changes.push(change);
+ }
+
+ var rrule = aComponent.getFirstProperty("rrule");
+
+ if (rrule) {
+ rrule = rrule.getFirstValue();
+ change = init_changes();
+
+ if (rrule.until && rrule.until.zone == ICAL.Timezone.utcTimezone) {
+ rrule.until.adjust(0, 0, 0, change.prevUtcOffset);
+ rrule.until.zone = ICAL.Timezone.localTimezone;
+ }
+
+ var iterator = rrule.iterator(dtstart);
+
+ var occ;
+ while ((occ = iterator.next())) {
+ change = init_changes();
+ if (occ.year > aYear || !occ) {
+ break;
+ }
+
+ change.year = occ.year;
+ change.month = occ.month;
+ change.day = occ.day;
+ change.hour = occ.hour;
+ change.minute = occ.minute;
+ change.second = occ.second;
+ change.isDate = occ.isDate;
+
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ changes.push(change);
+ }
+ }
+ }
+
+ return changes;
+ },
+
+ /**
+ * The string representation of this timezone.
+ * @return {String}
+ */
+ toString: function toString() {
+ return (this.tznames ? this.tznames : this.tzid);
+ }
+ };
+
+ ICAL.Timezone._compare_change_fn = function icaltimezone_compare_change_fn(a, b) {
+ if (a.year < b.year) return -1;
+ else if (a.year > b.year) return 1;
+
+ if (a.month < b.month) return -1;
+ else if (a.month > b.month) return 1;
+
+ if (a.day < b.day) return -1;
+ else if (a.day > b.day) return 1;
+
+ if (a.hour < b.hour) return -1;
+ else if (a.hour > b.hour) return 1;
+
+ if (a.minute < b.minute) return -1;
+ else if (a.minute > b.minute) return 1;
+
+ if (a.second < b.second) return -1;
+ else if (a.second > b.second) return 1;
+
+ return 0;
+ };
+
+ /**
+ * Convert the date/time from one zone to the next.
+ *
+ * @param {ICAL.Time} tt The time to convert
+ * @param {ICAL.Timezone} from_zone The source zone to convert from
+ * @param {ICAL.Timezone} to_zone The target zone to convert to
+ * @return {ICAL.Time} The converted date/time object
+ */
+ ICAL.Timezone.convert_time = function icaltimezone_convert_time(tt, from_zone, to_zone) {
+ if (tt.isDate ||
+ from_zone.tzid == to_zone.tzid ||
+ from_zone == ICAL.Timezone.localTimezone ||
+ to_zone == ICAL.Timezone.localTimezone) {
+ tt.zone = to_zone;
+ return tt;
+ }
+
+ var utcOffset = from_zone.utcOffset(tt);
+ tt.adjust(0, 0, 0, - utcOffset);
+
+ utcOffset = to_zone.utcOffset(tt);
+ tt.adjust(0, 0, 0, utcOffset);
+
+ return null;
+ };
+
+ /**
+ * Creates a new ICAL.Timezone instance from the passed data object.
+ *
+ * @param {ICAL.Component|Object} aData options for class
+ * @param {String|ICAL.Component} aData.component
+ * If aData is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} aData.tzid The timezone identifier
+ * @param {String} aData.location The timezone locationw
+ * @param {String} aData.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} aData.latitude The latitude of the timezone
+ * @param {Number} aData.longitude The longitude of the timezone
+ */
+ ICAL.Timezone.fromData = function icaltimezone_fromData(aData) {
+ var tt = new ICAL.Timezone();
+ return tt.fromData(aData);
+ };
+
+ /**
+ * The instance describing the UTC timezone
+ * @type {ICAL.Timezone}
+ * @constant
+ * @instance
+ */
+ ICAL.Timezone.utcTimezone = ICAL.Timezone.fromData({
+ tzid: "UTC"
+ });
+
+ /**
+ * The instance describing the local timezone
+ * @type {ICAL.Timezone}
+ * @constant
+ * @instance
+ */
+ ICAL.Timezone.localTimezone = ICAL.Timezone.fromData({
+ tzid: "floating"
+ });
+
+ /**
+ * Adjust a timezone change object.
+ * @private
+ * @param {Object} change The timezone change object
+ * @param {Number} days The extra amount of days
+ * @param {Number} hours The extra amount of hours
+ * @param {Number} minutes The extra amount of minutes
+ * @param {Number} seconds The extra amount of seconds
+ */
+ ICAL.Timezone.adjust_change = function icaltimezone_adjust_change(change, days, hours, minutes, seconds) {
+ return ICAL.Time.prototype.adjust.call(
+ change,
+ days,
+ hours,
+ minutes,
+ seconds,
+ change
+ );
+ };
+
+ ICAL.Timezone._minimumExpansionYear = -1;
+ ICAL.Timezone.EXTRA_COVERAGE = 5;
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.TimezoneService = (function() {
+ var zones;
+
+ /**
+ * @classdesc
+ * Singleton class to contain timezones. Right now it is all manual registry in
+ * the future we may use this class to download timezone information or handle
+ * loading pre-expanded timezones.
+ *
+ * @namespace
+ * @alias ICAL.TimezoneService
+ */
+ var TimezoneService = {
+ get count() {
+ return Object.keys(zones).length;
+ },
+
+ reset: function() {
+ zones = Object.create(null);
+ var utc = ICAL.Timezone.utcTimezone;
+
+ zones.Z = utc;
+ zones.UTC = utc;
+ zones.GMT = utc;
+ },
+
+ /**
+ * Checks if timezone id has been registered.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {Boolean} False, when not present
+ */
+ has: function(tzid) {
+ return !!zones[tzid];
+ },
+
+ /**
+ * Returns a timezone by its tzid if present.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {?ICAL.Timezone} The timezone, or null if not found
+ */
+ get: function(tzid) {
+ return zones[tzid];
+ },
+
+ /**
+ * Registers a timezone object or component.
+ *
+ * @param {String=} name
+ * The name of the timezone. Defaults to the component's TZID if not
+ * passed.
+ * @param {ICAL.Component|ICAL.Timezone} zone
+ * The initialized zone or vtimezone.
+ */
+ register: function(name, timezone) {
+ if (name instanceof ICAL.Component) {
+ if (name.name === 'vtimezone') {
+ timezone = new ICAL.Timezone(name);
+ name = timezone.tzid;
+ }
+ }
+
+ if (timezone instanceof ICAL.Timezone) {
+ zones[name] = timezone;
+ } else {
+ throw new TypeError('timezone must be ICAL.Timezone or ICAL.Component');
+ }
+ },
+
+ /**
+ * Removes a timezone by its tzid from the list.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {?ICAL.Timezone} The removed timezone, or null if not registered
+ */
+ remove: function(tzid) {
+ return (delete zones[tzid]);
+ }
+ };
+
+ // initialize defaults
+ TimezoneService.reset();
+
+ return TimezoneService;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+
+(function() {
+
+ /**
+ * @classdesc
+ * iCalendar Time representation (similar to JS Date object). Fully
+ * independent of system (OS) timezone / time. Unlike JS Date, the month
+ * January is 1, not zero.
+ *
+ * @example
+ * var time = new ICAL.Time({
+ * year: 2012,
+ * month: 10,
+ * day: 11
+ * minute: 0,
+ * second: 0,
+ * isDate: false
+ * });
+ *
+ *
+ * @alias ICAL.Time
+ * @class
+ * @param {Object} data Time initialization
+ * @param {Number=} data.year The year for this date
+ * @param {Number=} data.month The month for this date
+ * @param {Number=} data.day The day for this date
+ * @param {Number=} data.hour The hour for this date
+ * @param {Number=} data.minute The minute for this date
+ * @param {Number=} data.second The second for this date
+ * @param {Boolean=} data.isDate If true, the instance represents a date (as
+ * opposed to a date-time)
+ * @param {ICAL.Timezone} zone timezone this position occurs in
+ */
+ ICAL.Time = function icaltime(data, zone) {
+ this.wrappedJSObject = this;
+ var time = this._time = Object.create(null);
+
+ /* time defaults */
+ time.year = 0;
+ time.month = 1;
+ time.day = 1;
+ time.hour = 0;
+ time.minute = 0;
+ time.second = 0;
+ time.isDate = false;
+
+ this.fromData(data, zone);
+ };
+
+ ICAL.Time._dowCache = {};
+ ICAL.Time._wnCache = {};
+
+ ICAL.Time.prototype = {
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icaltime"
+ */
+ icalclass: "icaltime",
+ _cachedUnixTime: null,
+
+ /**
+ * The type name, to be used in the jCal object. This value may change and
+ * is strictly defined by the {@link ICAL.Time#isDate isDate} member.
+ * @readonly
+ * @type {String}
+ * @default "date-time"
+ */
+ get icaltype() {
+ return this.isDate ? 'date' : 'date-time';
+ },
+
+ /**
+ * The timezone for this time.
+ * @type {ICAL.Timezone}
+ */
+ zone: null,
+
+ /**
+ * Internal uses to indicate that a change has been made and the next read
+ * operation must attempt to normalize the value (for example changing the
+ * day to 33).
+ *
+ * @type {Boolean}
+ * @private
+ */
+ _pendingNormalization: false,
+
+ /**
+ * Returns a clone of the time object.
+ *
+ * @return {ICAL.Time} The cloned object
+ */
+ clone: function() {
+ return new ICAL.Time(this._time, this.zone);
+ },
+
+ /**
+ * Reset the time instance to epoch time
+ */
+ reset: function icaltime_reset() {
+ this.fromData(ICAL.Time.epochTime);
+ this.zone = ICAL.Timezone.utcTimezone;
+ },
+
+ /**
+ * Reset the time instance to the given date/time values.
+ *
+ * @param {Number} year The year to set
+ * @param {Number} month The month to set
+ * @param {Number} day The day to set
+ * @param {Number} hour The hour to set
+ * @param {Number} minute The minute to set
+ * @param {Number} second The second to set
+ * @param {ICAL.Timezone} timezone The timezone to set
+ */
+ resetTo: function icaltime_resetTo(year, month, day,
+ hour, minute, second, timezone) {
+ this.fromData({
+ year: year,
+ month: month,
+ day: day,
+ hour: hour,
+ minute: minute,
+ second: second,
+ zone: timezone
+ });
+ },
+
+ /**
+ * Set up the current instance from the Javascript date value.
+ *
+ * @param {?Date} aDate The Javascript Date to read, or null to reset
+ * @param {Boolean} useUTC If true, the UTC values of the date will be used
+ */
+ fromJSDate: function icaltime_fromJSDate(aDate, useUTC) {
+ if (!aDate) {
+ this.reset();
+ } else {
+ if (useUTC) {
+ this.zone = ICAL.Timezone.utcTimezone;
+ this.year = aDate.getUTCFullYear();
+ this.month = aDate.getUTCMonth() + 1;
+ this.day = aDate.getUTCDate();
+ this.hour = aDate.getUTCHours();
+ this.minute = aDate.getUTCMinutes();
+ this.second = aDate.getUTCSeconds();
+ } else {
+ this.zone = ICAL.Timezone.localTimezone;
+ this.year = aDate.getFullYear();
+ this.month = aDate.getMonth() + 1;
+ this.day = aDate.getDate();
+ this.hour = aDate.getHours();
+ this.minute = aDate.getMinutes();
+ this.second = aDate.getSeconds();
+ }
+ }
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData Time initialization
+ * @param {Number=} aData.year The year for this date
+ * @param {Number=} aData.month The month for this date
+ * @param {Number=} aData.day The day for this date
+ * @param {Number=} aData.hour The hour for this date
+ * @param {Number=} aData.minute The minute for this date
+ * @param {Number=} aData.second The second for this date
+ * @param {Boolean=} aData.isDate If true, the instance represents a date
+ * (as opposed to a date-time)
+ * @param {ICAL.Timezone=} aZone Timezone this position occurs in
+ */
+ fromData: function fromData(aData, aZone) {
+ if (aData) {
+ for (var key in aData) {
+ /* istanbul ignore else */
+ if (Object.prototype.hasOwnProperty.call(aData, key)) {
+ // ical type cannot be set
+ if (key === 'icaltype') continue;
+ this[key] = aData[key];
+ }
+ }
+ }
+
+ if (aZone) {
+ this.zone = aZone;
+ }
+
+ if (aData && !("isDate" in aData)) {
+ this.isDate = !("hour" in aData);
+ } else if (aData && ("isDate" in aData)) {
+ this.isDate = aData.isDate;
+ }
+
+ if (aData && "timezone" in aData) {
+ var zone = ICAL.TimezoneService.get(
+ aData.timezone
+ );
+
+ this.zone = zone || ICAL.Timezone.localTimezone;
+ }
+
+ if (aData && "zone" in aData) {
+ this.zone = aData.zone;
+ }
+
+ if (!this.zone) {
+ this.zone = ICAL.Timezone.localTimezone;
+ }
+
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Calculate the day of week.
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time.weekDay}
+ */
+ dayOfWeek: function icaltime_dayOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var dowCacheKey = (this.year << 12) + (this.month << 8) + (this.day << 3) + firstDow;
+ if (dowCacheKey in ICAL.Time._dowCache) {
+ return ICAL.Time._dowCache[dowCacheKey];
+ }
+
+ // Using Zeller's algorithm
+ var q = this.day;
+ var m = this.month + (this.month < 3 ? 12 : 0);
+ var Y = this.year - (this.month < 3 ? 1 : 0);
+
+ var h = (q + Y + ICAL.helpers.trunc(((m + 1) * 26) / 10) + ICAL.helpers.trunc(Y / 4));
+ /* istanbul ignore else */
+ if (true /* gregorian */) {
+ h += ICAL.helpers.trunc(Y / 100) * 6 + ICAL.helpers.trunc(Y / 400);
+ } else {
+ h += 5;
+ }
+
+ // Normalize to 1 = wkst
+ h = ((h + 7 - firstDow) % 7) + 1;
+ ICAL.Time._dowCache[dowCacheKey] = h;
+ return h;
+ },
+
+ /**
+ * Calculate the day of year.
+ * @return {Number}
+ */
+ dayOfYear: function dayOfYear() {
+ var is_leap = (ICAL.Time.isLeapYear(this.year) ? 1 : 0);
+ var diypm = ICAL.Time.daysInYearPassedMonth;
+ return diypm[is_leap][this.month - 1] + this.day;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * week. The resulting ICAL.Time instance is of icaltype date, even if this
+ * is a date-time.
+ *
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time} The start of the week (cloned)
+ */
+ startOfWeek: function startOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var result = this.clone();
+ result.day -= ((this.dayOfWeek() + 7 - firstDow) % 7);
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the week.
+ * The resulting ICAL.Time instance is of icaltype date, even if this is a
+ * date-time.
+ *
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time} The end of the week (cloned)
+ */
+ endOfWeek: function endOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var result = this.clone();
+ result.day += (7 - this.dayOfWeek() + firstDow - ICAL.Time.SUNDAY) % 7;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * month. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The start of the month (cloned)
+ */
+ startOfMonth: function startOfMonth() {
+ var result = this.clone();
+ result.day = 1;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the
+ * month. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The end of the month (cloned)
+ */
+ endOfMonth: function endOfMonth() {
+ var result = this.clone();
+ result.day = ICAL.Time.daysInMonth(result.month, result.year);
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * year. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The start of the year (cloned)
+ */
+ startOfYear: function startOfYear() {
+ var result = this.clone();
+ result.day = 1;
+ result.month = 1;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the
+ * year. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The end of the year (cloned)
+ */
+ endOfYear: function endOfYear() {
+ var result = this.clone();
+ result.day = 31;
+ result.month = 12;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * First calculates the start of the week, then returns the day of year for
+ * this date. If the day falls into the previous year, the day is zero or negative.
+ *
+ * @param {ICAL.Time.weekDay=} aFirstDayOfWeek
+ * The week start weekday, defaults to SUNDAY
+ * @return {Number} The calculated day of year
+ */
+ startDoyWeek: function startDoyWeek(aFirstDayOfWeek) {
+ var firstDow = aFirstDayOfWeek || ICAL.Time.SUNDAY;
+ var delta = this.dayOfWeek() - firstDow;
+ if (delta < 0) delta += 7;
+ return this.dayOfYear() - delta;
+ },
+
+ /**
+ * Get the dominical letter for the current year. Letters range from A - G
+ * for common years, and AG to GF for leap years.
+ *
+ * @param {Number} yr The year to retrieve the letter for
+ * @return {String} The dominical letter.
+ */
+ getDominicalLetter: function() {
+ return ICAL.Time.getDominicalLetter(this.year);
+ },
+
+ /**
+ * Finds the nthWeekDay relative to the current month (not day). The
+ * returned value is a day relative the month that this month belongs to so
+ * 1 would indicate the first of the month and 40 would indicate a day in
+ * the following month.
+ *
+ * @param {Number} aDayOfWeek Day of the week see the day name constants
+ * @param {Number} aPos Nth occurrence of a given week day values
+ * of 1 and 0 both indicate the first weekday of that type. aPos may
+ * be either positive or negative
+ *
+ * @return {Number} numeric value indicating a day relative
+ * to the current month of this time object
+ */
+ nthWeekDay: function icaltime_nthWeekDay(aDayOfWeek, aPos) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.month, this.year);
+ var weekday;
+ var pos = aPos;
+
+ var start = 0;
+
+ var otherDay = this.clone();
+
+ if (pos >= 0) {
+ otherDay.day = 1;
+
+ // because 0 means no position has been given
+ // 1 and 0 indicate the same day.
+ if (pos != 0) {
+ // remove the extra numeric value
+ pos--;
+ }
+
+ // set current start offset to current day.
+ start = otherDay.day;
+
+ // find the current day of week
+ var startDow = otherDay.dayOfWeek();
+
+ // calculate the difference between current
+ // day of the week and desired day of the week
+ var offset = aDayOfWeek - startDow;
+
+
+ // if the offset goes into the past
+ // week we add 7 so it goes into the next
+ // week. We only want to go forward in time here.
+ if (offset < 0)
+ // this is really important otherwise we would
+ // end up with dates from in the past.
+ offset += 7;
+
+ // add offset to start so start is the same
+ // day of the week as the desired day of week.
+ start += offset;
+
+ // because we are going to add (and multiply)
+ // the numeric value of the day we subtract it
+ // from the start position so not to add it twice.
+ start -= aDayOfWeek;
+
+ // set week day
+ weekday = aDayOfWeek;
+ } else {
+
+ // then we set it to the last day in the current month
+ otherDay.day = daysInMonth;
+
+ // find the ends weekday
+ var endDow = otherDay.dayOfWeek();
+
+ pos++;
+
+ weekday = (endDow - aDayOfWeek);
+
+ if (weekday < 0) {
+ weekday += 7;
+ }
+
+ weekday = daysInMonth - weekday;
+ }
+
+ weekday += pos * 7;
+
+ return start + weekday;
+ },
+
+ /**
+ * Checks if current time is the nth weekday, relative to the current
+ * month. Will always return false when rule resolves outside of current
+ * month.
+ *
+ * @param {ICAL.Time.weekDay} aDayOfWeek Day of week to check
+ * @param {Number} aPos Relative position
+ * @return {Boolean} True, if it is the nth weekday
+ */
+ isNthWeekDay: function(aDayOfWeek, aPos) {
+ var dow = this.dayOfWeek();
+
+ if (aPos === 0 && dow === aDayOfWeek) {
+ return true;
+ }
+
+ // get pos
+ var day = this.nthWeekDay(aDayOfWeek, aPos);
+
+ if (day === this.day) {
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Calculates the ISO 8601 week number. The first week of a year is the
+ * week that contains the first Thursday. The year can have 53 weeks, if
+ * January 1st is a Friday.
+ *
+ * Note there are regions where the first week of the year is the one that
+ * starts on January 1st, which may offset the week number. Also, if a
+ * different week start is specified, this will also affect the week
+ * number.
+ *
+ * @see ICAL.Time.weekOneStarts
+ * @param {ICAL.Time.weekDay} aWeekStart The weekday the week starts with
+ * @return {Number} The ISO week number
+ */
+ weekNumber: function weekNumber(aWeekStart) {
+ var wnCacheKey = (this.year << 12) + (this.month << 8) + (this.day << 3) + aWeekStart;
+ if (wnCacheKey in ICAL.Time._wnCache) {
+ return ICAL.Time._wnCache[wnCacheKey];
+ }
+ // This function courtesty of Julian Bucknall, published under the MIT license
+ // http://www.boyet.com/articles/publishedarticles/calculatingtheisoweeknumb.html
+ // plus some fixes to be able to use different week starts.
+ var week1;
+
+ var dt = this.clone();
+ dt.isDate = true;
+ var isoyear = this.year;
+
+ if (dt.month == 12 && dt.day > 25) {
+ week1 = ICAL.Time.weekOneStarts(isoyear + 1, aWeekStart);
+ if (dt.compare(week1) < 0) {
+ week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart);
+ } else {
+ isoyear++;
+ }
+ } else {
+ week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart);
+ if (dt.compare(week1) < 0) {
+ week1 = ICAL.Time.weekOneStarts(--isoyear, aWeekStart);
+ }
+ }
+
+ var daysBetween = (dt.subtractDate(week1).toSeconds() / 86400);
+ var answer = ICAL.helpers.trunc(daysBetween / 7) + 1;
+ ICAL.Time._wnCache[wnCacheKey] = answer;
+ return answer;
+ },
+
+ /**
+ * Adds the duration to the current time. The instance is modified in
+ * place.
+ *
+ * @param {ICAL.Duration} aDuration The duration to add
+ */
+ addDuration: function icaltime_add(aDuration) {
+ var mult = (aDuration.isNegative ? -1 : 1);
+
+ // because of the duration optimizations it is much
+ // more efficient to grab all the values up front
+ // then set them directly (which will avoid a normalization call).
+ // So we don't actually normalize until we need it.
+ var second = this.second;
+ var minute = this.minute;
+ var hour = this.hour;
+ var day = this.day;
+
+ second += mult * aDuration.seconds;
+ minute += mult * aDuration.minutes;
+ hour += mult * aDuration.hours;
+ day += mult * aDuration.days;
+ day += mult * 7 * aDuration.weeks;
+
+ this.second = second;
+ this.minute = minute;
+ this.hour = hour;
+ this.day = day;
+
+ this._cachedUnixTime = null;
+ },
+
+ /**
+ * Subtract the date details (_excluding_ timezone). Useful for finding
+ * the relative difference between two time objects excluding their
+ * timezone differences.
+ *
+ * @param {ICAL.Time} aDate The date to subtract
+ * @return {ICAL.Duration} The difference as a duration
+ */
+ subtractDate: function icaltime_subtract(aDate) {
+ var unixTime = this.toUnixTime() + this.utcOffset();
+ var other = aDate.toUnixTime() + aDate.utcOffset();
+ return ICAL.Duration.fromSeconds(unixTime - other);
+ },
+
+ /**
+ * Subtract the date details, taking timezones into account.
+ *
+ * @param {ICAL.Time} aDate The date to subtract
+ * @return {ICAL.Duration} The difference in duration
+ */
+ subtractDateTz: function icaltime_subtract_abs(aDate) {
+ var unixTime = this.toUnixTime();
+ var other = aDate.toUnixTime();
+ return ICAL.Duration.fromSeconds(unixTime - other);
+ },
+
+ /**
+ * Compares the ICAL.Time instance with another one.
+ *
+ * @param {ICAL.Duration} aOther The instance to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function icaltime_compare(other) {
+ var a = this.toUnixTime();
+ var b = other.toUnixTime();
+
+ if (a > b) return 1;
+ if (b > a) return -1;
+ return 0;
+ },
+
+ /**
+ * Compares only the date part of this instance with another one.
+ *
+ * @param {ICAL.Duration} other The instance to compare with
+ * @param {ICAL.Timezone} tz The timezone to compare in
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compareDateOnlyTz: function icaltime_compareDateOnlyTz(other, tz) {
+ function cmp(attr) {
+ return ICAL.Time._cmp_attr(a, b, attr);
+ }
+ var a = this.convertToZone(tz);
+ var b = other.convertToZone(tz);
+ var rc = 0;
+
+ if ((rc = cmp("year")) != 0) return rc;
+ if ((rc = cmp("month")) != 0) return rc;
+ if ((rc = cmp("day")) != 0) return rc;
+
+ return rc;
+ },
+
+ /**
+ * Convert the instance into another timezone. The returned ICAL.Time
+ * instance is always a copy.
+ *
+ * @param {ICAL.Timezone} zone The zone to convert to
+ * @return {ICAL.Time} The copy, converted to the zone
+ */
+ convertToZone: function convertToZone(zone) {
+ var copy = this.clone();
+ var zone_equals = (this.zone.tzid == zone.tzid);
+
+ if (!this.isDate && !zone_equals) {
+ ICAL.Timezone.convert_time(copy, this.zone, zone);
+ }
+
+ copy.zone = zone;
+ return copy;
+ },
+
+ /**
+ * Calculates the UTC offset of the current date/time in the timezone it is
+ * in.
+ *
+ * @return {Number} UTC offset in seconds
+ */
+ utcOffset: function utc_offset() {
+ if (this.zone == ICAL.Timezone.localTimezone ||
+ this.zone == ICAL.Timezone.utcTimezone) {
+ return 0;
+ } else {
+ return this.zone.utcOffset(this);
+ }
+ },
+
+ /**
+ * Returns an RFC 5545 compliant ical representation of this object.
+ *
+ * @return {String} ical date/date-time
+ */
+ toICALString: function() {
+ var string = this.toString();
+
+ if (string.length > 10) {
+ return ICAL.design.icalendar.value['date-time'].toICAL(string);
+ } else {
+ return ICAL.design.icalendar.value.date.toICAL(string);
+ }
+ },
+
+ /**
+ * The string representation of this date/time, in jCal form
+ * (including : and - separators).
+ * @return {String}
+ */
+ toString: function toString() {
+ var result = this.year + '-' +
+ ICAL.helpers.pad2(this.month) + '-' +
+ ICAL.helpers.pad2(this.day);
+
+ if (!this.isDate) {
+ result += 'T' + ICAL.helpers.pad2(this.hour) + ':' +
+ ICAL.helpers.pad2(this.minute) + ':' +
+ ICAL.helpers.pad2(this.second);
+
+ if (this.zone === ICAL.Timezone.utcTimezone) {
+ result += 'Z';
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Converts the current instance to a Javascript date
+ * @return {Date}
+ */
+ toJSDate: function toJSDate() {
+ if (this.zone == ICAL.Timezone.localTimezone) {
+ if (this.isDate) {
+ return new Date(this.year, this.month - 1, this.day);
+ } else {
+ return new Date(this.year, this.month - 1, this.day,
+ this.hour, this.minute, this.second, 0);
+ }
+ } else {
+ return new Date(this.toUnixTime() * 1000);
+ }
+ },
+
+ _normalize: function icaltime_normalize() {
+ var isDate = this._time.isDate;
+ if (this._time.isDate) {
+ this._time.hour = 0;
+ this._time.minute = 0;
+ this._time.second = 0;
+ }
+ this.adjust(0, 0, 0, 0);
+
+ return this;
+ },
+
+ /**
+ * Adjust the date/time by the given offset
+ *
+ * @param {Number} aExtraDays The extra amount of days
+ * @param {Number} aExtraHours The extra amount of hours
+ * @param {Number} aExtraMinutes The extra amount of minutes
+ * @param {Number} aExtraSeconds The extra amount of seconds
+ * @param {Number=} aTime The time to adjust, defaults to the
+ * current instance.
+ */
+ adjust: function icaltime_adjust(aExtraDays, aExtraHours,
+ aExtraMinutes, aExtraSeconds, aTime) {
+
+ var minutesOverflow, hoursOverflow,
+ daysOverflow = 0, yearsOverflow = 0;
+
+ var second, minute, hour, day;
+ var daysInMonth;
+
+ var time = aTime || this._time;
+
+ if (!time.isDate) {
+ second = time.second + aExtraSeconds;
+ time.second = second % 60;
+ minutesOverflow = ICAL.helpers.trunc(second / 60);
+ if (time.second < 0) {
+ time.second += 60;
+ minutesOverflow--;
+ }
+
+ minute = time.minute + aExtraMinutes + minutesOverflow;
+ time.minute = minute % 60;
+ hoursOverflow = ICAL.helpers.trunc(minute / 60);
+ if (time.minute < 0) {
+ time.minute += 60;
+ hoursOverflow--;
+ }
+
+ hour = time.hour + aExtraHours + hoursOverflow;
+
+ time.hour = hour % 24;
+ daysOverflow = ICAL.helpers.trunc(hour / 24);
+ if (time.hour < 0) {
+ time.hour += 24;
+ daysOverflow--;
+ }
+ }
+
+
+ // Adjust month and year first, because we need to know what month the day
+ // is in before adjusting it.
+ if (time.month > 12) {
+ yearsOverflow = ICAL.helpers.trunc((time.month - 1) / 12);
+ } else if (time.month < 1) {
+ yearsOverflow = ICAL.helpers.trunc(time.month / 12) - 1;
+ }
+
+ time.year += yearsOverflow;
+ time.month -= 12 * yearsOverflow;
+
+ // Now take care of the days (and adjust month if needed)
+ day = time.day + aExtraDays + daysOverflow;
+
+ if (day > 0) {
+ for (;;) {
+ daysInMonth = ICAL.Time.daysInMonth(time.month, time.year);
+ if (day <= daysInMonth) {
+ break;
+ }
+
+ time.month++;
+ if (time.month > 12) {
+ time.year++;
+ time.month = 1;
+ }
+
+ day -= daysInMonth;
+ }
+ } else {
+ while (day <= 0) {
+ if (time.month == 1) {
+ time.year--;
+ time.month = 12;
+ } else {
+ time.month--;
+ }
+
+ day += ICAL.Time.daysInMonth(time.month, time.year);
+ }
+ }
+
+ time.day = day;
+
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance from unix time, the number of seconds since
+ * January 1st, 1970.
+ *
+ * @param {Number} seconds The seconds to set up with
+ */
+ fromUnixTime: function fromUnixTime(seconds) {
+ this.zone = ICAL.Timezone.utcTimezone;
+ // We could use `fromJSDate` here, but this is about twice as fast.
+ // We could also clone `epochTime` and use `adjust` for a more
+ // ical.js-centric approach, but this is about 100 times as fast.
+ var date = new Date(seconds * 1000);
+ this.year = date.getUTCFullYear();
+ this.month = date.getUTCMonth() + 1;
+ this.day = date.getUTCDate();
+ if (this._time.isDate) {
+ this.hour = 0;
+ this.minute = 0;
+ this.second = 0;
+ } else {
+ this.hour = date.getUTCHours();
+ this.minute = date.getUTCMinutes();
+ this.second = date.getUTCSeconds();
+ }
+
+ this._cachedUnixTime = null;
+ },
+
+ /**
+ * Converts the current instance to seconds since January 1st 1970.
+ *
+ * @return {Number} Seconds since 1970
+ */
+ toUnixTime: function toUnixTime() {
+ if (this._cachedUnixTime !== null) {
+ return this._cachedUnixTime;
+ }
+ var offset = this.utcOffset();
+
+ // we use the offset trick to ensure
+ // that we are getting the actual UTC time
+ var ms = Date.UTC(
+ this.year,
+ this.month - 1,
+ this.day,
+ this.hour,
+ this.minute,
+ this.second - offset
+ );
+
+ // seconds
+ this._cachedUnixTime = ms / 1000;
+ return this._cachedUnixTime;
+ },
+
+ /**
+ * Converts time to into Object which can be serialized then re-created
+ * using the constructor.
+ *
+ * @example
+ * // toJSON will automatically be called
+ * var json = JSON.stringify(mytime);
+ *
+ * var deserialized = JSON.parse(json);
+ *
+ * var time = new ICAL.Time(deserialized);
+ *
+ * @return {Object}
+ */
+ toJSON: function() {
+ var copy = [
+ 'year',
+ 'month',
+ 'day',
+ 'hour',
+ 'minute',
+ 'second',
+ 'isDate'
+ ];
+
+ var result = Object.create(null);
+
+ var i = 0;
+ var len = copy.length;
+ var prop;
+
+ for (; i < len; i++) {
+ prop = copy[i];
+ result[prop] = this[prop];
+ }
+
+ if (this.zone) {
+ result.timezone = this.zone.tzid;
+ }
+
+ return result;
+ }
+
+ };
+
+ (function setupNormalizeAttributes() {
+ // This needs to run before any instances are created!
+ function defineAttr(attr) {
+ Object.defineProperty(ICAL.Time.prototype, attr, {
+ get: function getTimeAttr() {
+ if (this._pendingNormalization) {
+ this._normalize();
+ this._pendingNormalization = false;
+ }
+
+ return this._time[attr];
+ },
+ set: function setTimeAttr(val) {
+ // Check if isDate will be set and if was not set to normalize date.
+ // This avoids losing days when seconds, minutes and hours are zeroed
+ // what normalize will do when time is a date.
+ if (attr === "isDate" && val && !this._time.isDate) {
+ this.adjust(0, 0, 0, 0);
+ }
+ this._cachedUnixTime = null;
+ this._pendingNormalization = true;
+ this._time[attr] = val;
+
+ return val;
+ }
+ });
+
+ }
+
+ /* istanbul ignore else */
+ if ("defineProperty" in Object) {
+ defineAttr("year");
+ defineAttr("month");
+ defineAttr("day");
+ defineAttr("hour");
+ defineAttr("minute");
+ defineAttr("second");
+ defineAttr("isDate");
+ }
+ })();
+
+ /**
+ * Returns the days in the given month
+ *
+ * @param {Number} month The month to check
+ * @param {Number} year The year to check
+ * @return {Number} The number of days in the month
+ */
+ ICAL.Time.daysInMonth = function icaltime_daysInMonth(month, year) {
+ var _daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ var days = 30;
+
+ if (month < 1 || month > 12) return days;
+
+ days = _daysInMonth[month];
+
+ if (month == 2) {
+ days += ICAL.Time.isLeapYear(year);
+ }
+
+ return days;
+ };
+
+ /**
+ * Checks if the year is a leap year
+ *
+ * @param {Number} year The year to check
+ * @return {Boolean} True, if the year is a leap year
+ */
+ ICAL.Time.isLeapYear = function isLeapYear(year) {
+ if (year <= 1752) {
+ return ((year % 4) == 0);
+ } else {
+ return (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0));
+ }
+ };
+
+ /**
+ * Create a new ICAL.Time from the day of year and year. The date is returned
+ * in floating timezone.
+ *
+ * @param {Number} aDayOfYear The day of year
+ * @param {Number} aYear The year to create the instance in
+ * @return {ICAL.Time} The created instance with the calculated date
+ */
+ ICAL.Time.fromDayOfYear = function icaltime_fromDayOfYear(aDayOfYear, aYear) {
+ var year = aYear;
+ var doy = aDayOfYear;
+ var tt = new ICAL.Time();
+ tt.auto_normalize = false;
+ var is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+
+ if (doy < 1) {
+ year--;
+ is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+ doy += ICAL.Time.daysInYearPassedMonth[is_leap][12];
+ return ICAL.Time.fromDayOfYear(doy, year);
+ } else if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][12]) {
+ is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+ doy -= ICAL.Time.daysInYearPassedMonth[is_leap][12];
+ year++;
+ return ICAL.Time.fromDayOfYear(doy, year);
+ }
+
+ tt.year = year;
+ tt.isDate = true;
+
+ for (var month = 11; month >= 0; month--) {
+ if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][month]) {
+ tt.month = month + 1;
+ tt.day = doy - ICAL.Time.daysInYearPassedMonth[is_leap][month];
+ break;
+ }
+ }
+
+ tt.auto_normalize = true;
+ return tt;
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02.
+ *
+ * @deprecated Use {@link ICAL.Time.fromDateString} instead
+ * @param {String} str The string to create from
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromStringv2 = function fromString(str) {
+ return new ICAL.Time({
+ year: parseInt(str.substr(0, 4), 10),
+ month: parseInt(str.substr(5, 2), 10),
+ day: parseInt(str.substr(8, 2), 10),
+ isDate: true
+ });
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02.
+ *
+ * @param {String} aValue The string to create from
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromDateString = function(aValue) {
+ // Dates should have no timezone.
+ // Google likes to sometimes specify Z on dates
+ // we specifically ignore that to avoid issues.
+
+ // YYYY-MM-DD
+ // 2012-10-10
+ return new ICAL.Time({
+ year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)),
+ month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)),
+ day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)),
+ isDate: true
+ });
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date-time string, e.g
+ * 2015-01-02T03:04:05. If a property is specified, the timezone is set up
+ * from the property's TZID parameter.
+ *
+ * @param {String} aValue The string to create from
+ * @param {ICAL.Property=} prop The property the date belongs to
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromDateTimeString = function(aValue, prop) {
+ if (aValue.length < 19) {
+ throw new Error(
+ 'invalid date-time value: "' + aValue + '"'
+ );
+ }
+
+ var zone;
+ var zoneId;
+
+ if (aValue[19] && aValue[19] === 'Z') {
+ zone = ICAL.Timezone.utcTimezone;
+ } else if (prop) {
+ zoneId = prop.getParameter('tzid');
+
+ if (prop.parent) {
+ if (prop.parent.name === 'standard' || prop.parent.name === 'daylight') {
+ // Per RFC 5545 3.8.2.4 and 3.8.2.2, start/end date-times within
+ // these components MUST be specified in local time.
+ zone = ICAL.Timezone.floating;
+ } else if (zoneId) {
+ // If the desired time zone is defined within the component tree,
+ // fetch its definition and prefer that.
+ zone = prop.parent.getTimeZoneByID(zoneId);
+ }
+ }
+ }
+
+ var timeData = {
+ year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)),
+ month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)),
+ day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)),
+ hour: ICAL.helpers.strictParseInt(aValue.substr(11, 2)),
+ minute: ICAL.helpers.strictParseInt(aValue.substr(14, 2)),
+ second: ICAL.helpers.strictParseInt(aValue.substr(17, 2)),
+ };
+
+ // Although RFC 5545 requires that all TZIDs used within a file have a
+ // corresponding time zone definition, we may not be parsing the full file
+ // or we may be dealing with a non-compliant file; in either case, we can
+ // check our own time zone service for the TZID in a last-ditch effort.
+ if (zoneId && !zone) {
+ timeData.timezone = zoneId;
+ }
+
+ // 2012-10-10T10:10:10(Z)?
+ return new ICAL.Time(timeData, zone);
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date or date-time string,
+ *
+ * @param {String} aValue The string to create from
+ * @param {ICAL.Property=} prop The property the date belongs to
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromString = function fromString(aValue, aProperty) {
+ if (aValue.length > 10) {
+ return ICAL.Time.fromDateTimeString(aValue, aProperty);
+ } else {
+ return ICAL.Time.fromDateString(aValue);
+ }
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the given Javascript Date.
+ *
+ * @param {?Date} aDate The Javascript Date to read, or null to reset
+ * @param {Boolean} useUTC If true, the UTC values of the date will be used
+ */
+ ICAL.Time.fromJSDate = function fromJSDate(aDate, useUTC) {
+ var tt = new ICAL.Time();
+ return tt.fromJSDate(aDate, useUTC);
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the the passed data object.
+ *
+ * @param {Object} aData Time initialization
+ * @param {Number=} aData.year The year for this date
+ * @param {Number=} aData.month The month for this date
+ * @param {Number=} aData.day The day for this date
+ * @param {Number=} aData.hour The hour for this date
+ * @param {Number=} aData.minute The minute for this date
+ * @param {Number=} aData.second The second for this date
+ * @param {Boolean=} aData.isDate If true, the instance represents a date
+ * (as opposed to a date-time)
+ * @param {ICAL.Timezone=} aZone Timezone this position occurs in
+ */
+ ICAL.Time.fromData = function fromData(aData, aZone) {
+ var t = new ICAL.Time();
+ return t.fromData(aData, aZone);
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the current moment.
+ * The instance is β€œfloating” - has no timezone relation.
+ * To create an instance considering the time zone, call
+ * ICAL.Time.fromJSDate(new Date(), true)
+ * @return {ICAL.Time}
+ */
+ ICAL.Time.now = function icaltime_now() {
+ return ICAL.Time.fromJSDate(new Date(), false);
+ };
+
+ /**
+ * Returns the date on which ISO week number 1 starts.
+ *
+ * @see ICAL.Time#weekNumber
+ * @param {Number} aYear The year to search in
+ * @param {ICAL.Time.weekDay=} aWeekStart The week start weekday, used for calculation.
+ * @return {ICAL.Time} The date on which week number 1 starts
+ */
+ ICAL.Time.weekOneStarts = function weekOneStarts(aYear, aWeekStart) {
+ var t = ICAL.Time.fromData({
+ year: aYear,
+ month: 1,
+ day: 1,
+ isDate: true
+ });
+
+ var dow = t.dayOfWeek();
+ var wkst = aWeekStart || ICAL.Time.DEFAULT_WEEK_START;
+ if (dow > ICAL.Time.THURSDAY) {
+ t.day += 7;
+ }
+ if (wkst > ICAL.Time.THURSDAY) {
+ t.day -= 7;
+ }
+
+ t.day -= dow - wkst;
+
+ return t;
+ };
+
+ /**
+ * Get the dominical letter for the given year. Letters range from A - G for
+ * common years, and AG to GF for leap years.
+ *
+ * @param {Number} yr The year to retrieve the letter for
+ * @return {String} The dominical letter.
+ */
+ ICAL.Time.getDominicalLetter = function(yr) {
+ var LTRS = "GFEDCBA";
+ var dom = (yr + (yr / 4 | 0) + (yr / 400 | 0) - (yr / 100 | 0) - 1) % 7;
+ var isLeap = ICAL.Time.isLeapYear(yr);
+ if (isLeap) {
+ return LTRS[(dom + 6) % 7] + LTRS[dom];
+ } else {
+ return LTRS[dom];
+ }
+ };
+
+ /**
+ * January 1st, 1970 as an ICAL.Time.
+ * @type {ICAL.Time}
+ * @constant
+ * @instance
+ */
+ ICAL.Time.epochTime = ICAL.Time.fromData({
+ year: 1970,
+ month: 1,
+ day: 1,
+ hour: 0,
+ minute: 0,
+ second: 0,
+ isDate: false,
+ timezone: "Z"
+ });
+
+ ICAL.Time._cmp_attr = function _cmp_attr(a, b, attr) {
+ if (a[attr] > b[attr]) return 1;
+ if (a[attr] < b[attr]) return -1;
+ return 0;
+ };
+
+ /**
+ * The days that have passed in the year after a given month. The array has
+ * two members, one being an array of passed days for non-leap years, the
+ * other analog for leap years.
+ * @example
+ * var isLeapYear = ICAL.Time.isLeapYear(year);
+ * var passedDays = ICAL.Time.daysInYearPassedMonth[isLeapYear][month];
+ * @type {Array.<Array.<Number>>}
+ */
+ ICAL.Time.daysInYearPassedMonth = [
+ [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365],
+ [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]
+ ];
+
+ /**
+ * The weekday, 1 = SUNDAY, 7 = SATURDAY. Access via
+ * ICAL.Time.MONDAY, ICAL.Time.TUESDAY, ...
+ *
+ * @typedef {Number} weekDay
+ * @memberof ICAL.Time
+ */
+
+ ICAL.Time.SUNDAY = 1;
+ ICAL.Time.MONDAY = 2;
+ ICAL.Time.TUESDAY = 3;
+ ICAL.Time.WEDNESDAY = 4;
+ ICAL.Time.THURSDAY = 5;
+ ICAL.Time.FRIDAY = 6;
+ ICAL.Time.SATURDAY = 7;
+
+ /**
+ * The default weekday for the WKST part.
+ * @constant
+ * @default ICAL.Time.MONDAY
+ */
+ ICAL.Time.DEFAULT_WEEK_START = ICAL.Time.MONDAY;
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2015 */
+
+
+
+(function() {
+
+ /**
+ * Describes a vCard time, which has slight differences to the ICAL.Time.
+ * Properties can be null if not specified, for example for dates with
+ * reduced accuracy or truncation.
+ *
+ * Note that currently not all methods are correctly re-implemented for
+ * VCardTime. For example, comparison will have undefined results when some
+ * members are null.
+ *
+ * Also, normalization is not yet implemented for this class!
+ *
+ * @alias ICAL.VCardTime
+ * @class
+ * @extends {ICAL.Time}
+ * @param {Object} data The data for the time instance
+ * @param {Number=} data.year The year for this date
+ * @param {Number=} data.month The month for this date
+ * @param {Number=} data.day The day for this date
+ * @param {Number=} data.hour The hour for this date
+ * @param {Number=} data.minute The minute for this date
+ * @param {Number=} data.second The second for this date
+ * @param {ICAL.Timezone|ICAL.UtcOffset} zone The timezone to use
+ * @param {String} icaltype The type for this date/time object
+ */
+ ICAL.VCardTime = function(data, zone, icaltype) {
+ this.wrappedJSObject = this;
+ var time = this._time = Object.create(null);
+
+ time.year = null;
+ time.month = null;
+ time.day = null;
+ time.hour = null;
+ time.minute = null;
+ time.second = null;
+
+ this.icaltype = icaltype || "date-and-or-time";
+
+ this.fromData(data, zone);
+ };
+ ICAL.helpers.inherits(ICAL.Time, ICAL.VCardTime, /** @lends ICAL.VCardTime */ {
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "vcardtime"
+ */
+ icalclass: "vcardtime",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @type {String}
+ * @default "date-and-or-time"
+ */
+ icaltype: "date-and-or-time",
+
+ /**
+ * The timezone. This can either be floating, UTC, or an instance of
+ * ICAL.UtcOffset.
+ * @type {ICAL.Timezone|ICAL.UtcOFfset}
+ */
+ zone: null,
+
+ /**
+ * Returns a clone of the vcard date/time object.
+ *
+ * @return {ICAL.VCardTime} The cloned object
+ */
+ clone: function() {
+ return new ICAL.VCardTime(this._time, this.zone, this.icaltype);
+ },
+
+ _normalize: function() {
+ return this;
+ },
+
+ /**
+ * @inheritdoc
+ */
+ utcOffset: function() {
+ if (this.zone instanceof ICAL.UtcOffset) {
+ return this.zone.toSeconds();
+ } else {
+ return ICAL.Time.prototype.utcOffset.apply(this, arguments);
+ }
+ },
+
+ /**
+ * Returns an RFC 6350 compliant representation of this object.
+ *
+ * @return {String} vcard date/time string
+ */
+ toICALString: function() {
+ return ICAL.design.vcard.value[this.icaltype].toICAL(this.toString());
+ },
+
+ /**
+ * The string representation of this date/time, in jCard form
+ * (including : and - separators).
+ * @return {String}
+ */
+ toString: function toString() {
+ var p2 = ICAL.helpers.pad2;
+ var y = this.year, m = this.month, d = this.day;
+ var h = this.hour, mm = this.minute, s = this.second;
+
+ var hasYear = y !== null, hasMonth = m !== null, hasDay = d !== null;
+ var hasHour = h !== null, hasMinute = mm !== null, hasSecond = s !== null;
+
+ var datepart = (hasYear ? p2(y) + (hasMonth || hasDay ? '-' : '') : (hasMonth || hasDay ? '--' : '')) +
+ (hasMonth ? p2(m) : '') +
+ (hasDay ? '-' + p2(d) : '');
+ var timepart = (hasHour ? p2(h) : '-') + (hasHour && hasMinute ? ':' : '') +
+ (hasMinute ? p2(mm) : '') + (!hasHour && !hasMinute ? '-' : '') +
+ (hasMinute && hasSecond ? ':' : '') +
+ (hasSecond ? p2(s) : '');
+
+ var zone;
+ if (this.zone === ICAL.Timezone.utcTimezone) {
+ zone = 'Z';
+ } else if (this.zone instanceof ICAL.UtcOffset) {
+ zone = this.zone.toString();
+ } else if (this.zone === ICAL.Timezone.localTimezone) {
+ zone = '';
+ } else if (this.zone instanceof ICAL.Timezone) {
+ var offset = ICAL.UtcOffset.fromSeconds(this.zone.utcOffset(this));
+ zone = offset.toString();
+ } else {
+ zone = '';
+ }
+
+ switch (this.icaltype) {
+ case "time":
+ return timepart + zone;
+ case "date-and-or-time":
+ case "date-time":
+ return datepart + (timepart == '--' ? '' : 'T' + timepart + zone);
+ case "date":
+ return datepart;
+ }
+ return null;
+ }
+ });
+
+ /**
+ * Returns a new ICAL.VCardTime instance from a date and/or time string.
+ *
+ * @param {String} aValue The string to create from
+ * @param {String} aIcalType The type for this instance, e.g. date-and-or-time
+ * @return {ICAL.VCardTime} The date/time instance
+ */
+ ICAL.VCardTime.fromDateAndOrTimeString = function(aValue, aIcalType) {
+ function part(v, s, e) {
+ return v ? ICAL.helpers.strictParseInt(v.substr(s, e)) : null;
+ }
+ var parts = aValue.split('T');
+ var dt = parts[0], tmz = parts[1];
+ var splitzone = tmz ? ICAL.design.vcard.value.time._splitZone(tmz) : [];
+ var zone = splitzone[0], tm = splitzone[1];
+
+ var stoi = ICAL.helpers.strictParseInt;
+ var dtlen = dt ? dt.length : 0;
+ var tmlen = tm ? tm.length : 0;
+
+ var hasDashDate = dt && dt[0] == '-' && dt[1] == '-';
+ var hasDashTime = tm && tm[0] == '-';
+
+ var o = {
+ year: hasDashDate ? null : part(dt, 0, 4),
+ month: hasDashDate && (dtlen == 4 || dtlen == 7) ? part(dt, 2, 2) : dtlen == 7 ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 5, 2) : null,
+ day: dtlen == 5 ? part(dt, 3, 2) : dtlen == 7 && hasDashDate ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 8, 2) : null,
+
+ hour: hasDashTime ? null : part(tm, 0, 2),
+ minute: hasDashTime && tmlen == 3 ? part(tm, 1, 2) : tmlen > 4 ? hasDashTime ? part(tm, 1, 2) : part(tm, 3, 2) : null,
+ second: tmlen == 4 ? part(tm, 2, 2) : tmlen == 6 ? part(tm, 4, 2) : tmlen == 8 ? part(tm, 6, 2) : null
+ };
+
+ if (zone == 'Z') {
+ zone = ICAL.Timezone.utcTimezone;
+ } else if (zone && zone[3] == ':') {
+ zone = ICAL.UtcOffset.fromString(zone);
+ } else {
+ zone = null;
+ }
+
+ return new ICAL.VCardTime(o, zone, aIcalType);
+ };
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+
+(function() {
+ var DOW_MAP = {
+ SU: ICAL.Time.SUNDAY,
+ MO: ICAL.Time.MONDAY,
+ TU: ICAL.Time.TUESDAY,
+ WE: ICAL.Time.WEDNESDAY,
+ TH: ICAL.Time.THURSDAY,
+ FR: ICAL.Time.FRIDAY,
+ SA: ICAL.Time.SATURDAY
+ };
+
+ var REVERSE_DOW_MAP = {};
+ for (var key in DOW_MAP) {
+ /* istanbul ignore else */
+ if (DOW_MAP.hasOwnProperty(key)) {
+ REVERSE_DOW_MAP[DOW_MAP[key]] = key;
+ }
+ }
+
+ var COPY_PARTS = ["BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY",
+ "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO",
+ "BYMONTH", "BYSETPOS"];
+
+ /**
+ * @classdesc
+ * This class represents the "recur" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.Recur
+ * @param {Object} data An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} data.freq The frequency value
+ * @param {Number=} data.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} data.wkst The week start value
+ * @param {ICAL.Time=} data.until The end of the recurrence set
+ * @param {Number=} data.count The number of occurrences
+ * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} data.byday The BYDAY values
+ * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
+ */
+ ICAL.Recur = function icalrecur(data) {
+ this.wrappedJSObject = this;
+ this.parts = {};
+
+ if (data && typeof(data) === 'object') {
+ this.fromData(data);
+ }
+ };
+
+ ICAL.Recur.prototype = {
+ /**
+ * An object holding the BY-parts of the recurrence rule
+ * @type {Object}
+ */
+ parts: null,
+
+ /**
+ * The interval value for the recurrence rule.
+ * @type {Number}
+ */
+ interval: 1,
+
+ /**
+ * The week start day
+ *
+ * @type {ICAL.Time.weekDay}
+ * @default ICAL.Time.MONDAY
+ */
+ wkst: ICAL.Time.MONDAY,
+
+ /**
+ * The end of the recurrence
+ * @type {?ICAL.Time}
+ */
+ until: null,
+
+ /**
+ * The maximum number of occurrences
+ * @type {?Number}
+ */
+ count: null,
+
+ /**
+ * The frequency value.
+ * @type {ICAL.Recur.frequencyValues}
+ */
+ freq: null,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalrecur"
+ */
+ icalclass: "icalrecur",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "recur"
+ */
+ icaltype: "recur",
+
+ /**
+ * Create a new iterator for this recurrence rule. The passed start date
+ * must be the start date of the event, not the start of the range to
+ * search in.
+ *
+ * @example
+ * var recur = comp.getFirstPropertyValue('rrule');
+ * var dtstart = comp.getFirstPropertyValue('dtstart');
+ * var iter = recur.iterator(dtstart);
+ * for (var next = iter.next(); next; next = iter.next()) {
+ * if (next.compare(rangeStart) < 0) {
+ * continue;
+ * }
+ * console.log(next.toString());
+ * }
+ *
+ * @param {ICAL.Time} aStart The item's start date
+ * @return {ICAL.RecurIterator} The recurrence iterator
+ */
+ iterator: function(aStart) {
+ return new ICAL.RecurIterator({
+ rule: this,
+ dtstart: aStart
+ });
+ },
+
+ /**
+ * Returns a clone of the recurrence object.
+ *
+ * @return {ICAL.Recur} The cloned object
+ */
+ clone: function clone() {
+ return new ICAL.Recur(this.toJSON());
+ },
+
+ /**
+ * Checks if the current rule is finite, i.e. has a count or until part.
+ *
+ * @return {Boolean} True, if the rule is finite
+ */
+ isFinite: function isfinite() {
+ return !!(this.count || this.until);
+ },
+
+ /**
+ * Checks if the current rule has a count part, and not limited by an until
+ * part.
+ *
+ * @return {Boolean} True, if the rule is by count
+ */
+ isByCount: function isbycount() {
+ return !!(this.count && !this.until);
+ },
+
+ /**
+ * Adds a component (part) to the recurrence rule. This is not a component
+ * in the sense of {@link ICAL.Component}, but a part of the recurrence
+ * rule, i.e. BYMONTH.
+ *
+ * @param {String} aType The name of the component part
+ * @param {Array|String} aValue The component value
+ */
+ addComponent: function addPart(aType, aValue) {
+ var ucname = aType.toUpperCase();
+ if (ucname in this.parts) {
+ this.parts[ucname].push(aValue);
+ } else {
+ this.parts[ucname] = [aValue];
+ }
+ },
+
+ /**
+ * Sets the component value for the given by-part.
+ *
+ * @param {String} aType The component part name
+ * @param {Array} aValues The component values
+ */
+ setComponent: function setComponent(aType, aValues) {
+ this.parts[aType.toUpperCase()] = aValues.slice();
+ },
+
+ /**
+ * Gets (a copy) of the requested component value.
+ *
+ * @param {String} aType The component part name
+ * @return {Array} The component part value
+ */
+ getComponent: function getComponent(aType) {
+ var ucname = aType.toUpperCase();
+ return (ucname in this.parts ? this.parts[ucname].slice() : []);
+ },
+
+ /**
+ * Retrieves the next occurrence after the given recurrence id. See the
+ * guide on {@tutorial terminology} for more details.
+ *
+ * NOTE: Currently, this method iterates all occurrences from the start
+ * date. It should not be called in a loop for performance reasons. If you
+ * would like to get more than one occurrence, you can iterate the
+ * occurrences manually, see the example on the
+ * {@link ICAL.Recur#iterator iterator} method.
+ *
+ * @param {ICAL.Time} aStartTime The start of the event series
+ * @param {ICAL.Time} aRecurrenceId The date of the last occurrence
+ * @return {ICAL.Time} The next occurrence after
+ */
+ getNextOccurrence: function getNextOccurrence(aStartTime, aRecurrenceId) {
+ var iter = this.iterator(aStartTime);
+ var next, cdt;
+
+ do {
+ next = iter.next();
+ } while (next && next.compare(aRecurrenceId) <= 0);
+
+ if (next && aRecurrenceId.zone) {
+ next.zone = aRecurrenceId.zone;
+ }
+
+ return next;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} data An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} data.freq The frequency value
+ * @param {Number=} data.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} data.wkst The week start value
+ * @param {ICAL.Time=} data.until The end of the recurrence set
+ * @param {Number=} data.count The number of occurrences
+ * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} data.byday The BYDAY values
+ * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
+ */
+ fromData: function(data) {
+ for (var key in data) {
+ var uckey = key.toUpperCase();
+
+ if (uckey in partDesign) {
+ if (Array.isArray(data[key])) {
+ this.parts[uckey] = data[key];
+ } else {
+ this.parts[uckey] = [data[key]];
+ }
+ } else {
+ this[key] = data[key];
+ }
+ }
+
+ if (this.interval && typeof this.interval != "number") {
+ optionDesign.INTERVAL(this.interval, this);
+ }
+
+ if (this.wkst && typeof this.wkst != "number") {
+ this.wkst = ICAL.Recur.icalDayToNumericDay(this.wkst);
+ }
+
+ if (this.until && !(this.until instanceof ICAL.Time)) {
+ this.until = ICAL.Time.fromString(this.until);
+ }
+ },
+
+ /**
+ * The jCal representation of this recurrence type.
+ * @return {Object}
+ */
+ toJSON: function() {
+ var res = Object.create(null);
+ res.freq = this.freq;
+
+ if (this.count) {
+ res.count = this.count;
+ }
+
+ if (this.interval > 1) {
+ res.interval = this.interval;
+ }
+
+ for (var k in this.parts) {
+ /* istanbul ignore if */
+ if (!this.parts.hasOwnProperty(k)) {
+ continue;
+ }
+ var kparts = this.parts[k];
+ if (Array.isArray(kparts) && kparts.length == 1) {
+ res[k.toLowerCase()] = kparts[0];
+ } else {
+ res[k.toLowerCase()] = ICAL.helpers.clone(this.parts[k]);
+ }
+ }
+
+ if (this.until) {
+ res.until = this.until.toString();
+ }
+ if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) {
+ res.wkst = ICAL.Recur.numericDayToIcalDay(this.wkst);
+ }
+ return res;
+ },
+
+ /**
+ * The string representation of this recurrence rule.
+ * @return {String}
+ */
+ toString: function icalrecur_toString() {
+ // TODO retain order
+ var str = "FREQ=" + this.freq;
+ if (this.count) {
+ str += ";COUNT=" + this.count;
+ }
+ if (this.interval > 1) {
+ str += ";INTERVAL=" + this.interval;
+ }
+ for (var k in this.parts) {
+ /* istanbul ignore else */
+ if (this.parts.hasOwnProperty(k)) {
+ str += ";" + k + "=" + this.parts[k];
+ }
+ }
+ if (this.until) {
+ str += ';UNTIL=' + this.until.toICALString();
+ }
+ if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) {
+ str += ';WKST=' + ICAL.Recur.numericDayToIcalDay(this.wkst);
+ }
+ return str;
+ }
+ };
+
+ function parseNumericValue(type, min, max, value) {
+ var result = value;
+
+ if (value[0] === '+') {
+ result = value.substr(1);
+ }
+
+ result = ICAL.helpers.strictParseInt(result);
+
+ if (min !== undefined && value < min) {
+ throw new Error(
+ type + ': invalid value "' + value + '" must be > ' + min
+ );
+ }
+
+ if (max !== undefined && value > max) {
+ throw new Error(
+ type + ': invalid value "' + value + '" must be < ' + min
+ );
+ }
+
+ return result;
+ }
+
+ /**
+ * Convert an ical representation of a day (SU, MO, etc..)
+ * into a numeric value of that day.
+ *
+ * @param {String} string The iCalendar day name
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {Number} Numeric value of given day
+ */
+ ICAL.Recur.icalDayToNumericDay = function toNumericDay(string, aWeekStart) {
+ //XXX: this is here so we can deal
+ // with possibly invalid string values.
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ return ((DOW_MAP[string] - firstDow + 7) % 7) + 1;
+ };
+
+ /**
+ * Convert a numeric day value into its ical representation (SU, MO, etc..)
+ *
+ * @param {Number} num Numeric value of given day
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {String} The ICAL day value, e.g SU,MO,...
+ */
+ ICAL.Recur.numericDayToIcalDay = function toIcalDay(num, aWeekStart) {
+ //XXX: this is here so we can deal with possibly invalid number values.
+ // Also, this allows consistent mapping between day numbers and day
+ // names for external users.
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var dow = (num + firstDow - ICAL.Time.SUNDAY);
+ if (dow > 7) {
+ dow -= 7;
+ }
+ return REVERSE_DOW_MAP[dow];
+ };
+
+ var VALID_DAY_NAMES = /^(SU|MO|TU|WE|TH|FR|SA)$/;
+ var VALID_BYDAY_PART = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/;
+
+ /**
+ * Possible frequency values for the FREQ part
+ * (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY)
+ *
+ * @typedef {String} frequencyValues
+ * @memberof ICAL.Recur
+ */
+
+ var ALLOWED_FREQ = ['SECONDLY', 'MINUTELY', 'HOURLY',
+ 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'];
+
+ var optionDesign = {
+ FREQ: function(value, dict, fmtIcal) {
+ // yes this is actually equal or faster then regex.
+ // upside here is we can enumerate the valid values.
+ if (ALLOWED_FREQ.indexOf(value) !== -1) {
+ dict.freq = value;
+ } else {
+ throw new Error(
+ 'invalid frequency "' + value + '" expected: "' +
+ ALLOWED_FREQ.join(', ') + '"'
+ );
+ }
+ },
+
+ COUNT: function(value, dict, fmtIcal) {
+ dict.count = ICAL.helpers.strictParseInt(value);
+ },
+
+ INTERVAL: function(value, dict, fmtIcal) {
+ dict.interval = ICAL.helpers.strictParseInt(value);
+ if (dict.interval < 1) {
+ // 0 or negative values are not allowed, some engines seem to generate
+ // it though. Assume 1 instead.
+ dict.interval = 1;
+ }
+ },
+
+ UNTIL: function(value, dict, fmtIcal) {
+ if (value.length > 10) {
+ dict.until = ICAL.design.icalendar.value['date-time'].fromICAL(value);
+ } else {
+ dict.until = ICAL.design.icalendar.value.date.fromICAL(value);
+ }
+ if (!fmtIcal) {
+ dict.until = ICAL.Time.fromString(dict.until);
+ }
+ },
+
+ WKST: function(value, dict, fmtIcal) {
+ if (VALID_DAY_NAMES.test(value)) {
+ dict.wkst = ICAL.Recur.icalDayToNumericDay(value);
+ } else {
+ throw new Error('invalid WKST value "' + value + '"');
+ }
+ }
+ };
+
+ var partDesign = {
+ BYSECOND: parseNumericValue.bind(this, 'BYSECOND', 0, 60),
+ BYMINUTE: parseNumericValue.bind(this, 'BYMINUTE', 0, 59),
+ BYHOUR: parseNumericValue.bind(this, 'BYHOUR', 0, 23),
+ BYDAY: function(value) {
+ if (VALID_BYDAY_PART.test(value)) {
+ return value;
+ } else {
+ throw new Error('invalid BYDAY value "' + value + '"');
+ }
+ },
+ BYMONTHDAY: parseNumericValue.bind(this, 'BYMONTHDAY', -31, 31),
+ BYYEARDAY: parseNumericValue.bind(this, 'BYYEARDAY', -366, 366),
+ BYWEEKNO: parseNumericValue.bind(this, 'BYWEEKNO', -53, 53),
+ BYMONTH: parseNumericValue.bind(this, 'BYMONTH', 1, 12),
+ BYSETPOS: parseNumericValue.bind(this, 'BYSETPOS', -366, 366)
+ };
+
+
+ /**
+ * Creates a new {@link ICAL.Recur} instance from the passed string.
+ *
+ * @param {String} string The string to parse
+ * @return {ICAL.Recur} The created recurrence instance
+ */
+ ICAL.Recur.fromString = function(string) {
+ var data = ICAL.Recur._stringToData(string, false);
+ return new ICAL.Recur(data);
+ };
+
+ /**
+ * Creates a new {@link ICAL.Recur} instance using members from the passed
+ * data object.
+ *
+ * @param {Object} aData An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} aData.freq The frequency value
+ * @param {Number=} aData.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} aData.wkst The week start value
+ * @param {ICAL.Time=} aData.until The end of the recurrence set
+ * @param {Number=} aData.count The number of occurrences
+ * @param {Array.<Number>=} aData.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} aData.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} aData.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} aData.byday The BYDAY values
+ * @param {Array.<Number>=} aData.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} aData.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} aData.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} aData.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} aData.bysetpos The positionals for the BYSETPOS part
+ */
+ ICAL.Recur.fromData = function(aData) {
+ return new ICAL.Recur(aData);
+ };
+
+ /**
+ * Converts a recurrence string to a data object, suitable for the fromData
+ * method.
+ *
+ * @param {String} string The string to parse
+ * @param {Boolean} fmtIcal If true, the string is considered to be an
+ * iCalendar string
+ * @return {ICAL.Recur} The recurrence instance
+ */
+ ICAL.Recur._stringToData = function(string, fmtIcal) {
+ var dict = Object.create(null);
+
+ // split is slower in FF but fast enough.
+ // v8 however this is faster then manual split?
+ var values = string.split(';');
+ var len = values.length;
+
+ for (var i = 0; i < len; i++) {
+ var parts = values[i].split('=');
+ var ucname = parts[0].toUpperCase();
+ var lcname = parts[0].toLowerCase();
+ var name = (fmtIcal ? lcname : ucname);
+ var value = parts[1];
+
+ if (ucname in partDesign) {
+ var partArr = value.split(',');
+ var partArrIdx = 0;
+ var partArrLen = partArr.length;
+
+ for (; partArrIdx < partArrLen; partArrIdx++) {
+ partArr[partArrIdx] = partDesign[ucname](partArr[partArrIdx]);
+ }
+ dict[name] = (partArr.length == 1 ? partArr[0] : partArr);
+ } else if (ucname in optionDesign) {
+ optionDesign[ucname](value, dict, fmtIcal);
+ } else {
+ // Don't swallow unknown values. Just set them as they are.
+ dict[lcname] = value;
+ }
+ }
+
+ return dict;
+ };
+})();
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.RecurIterator = (function() {
+
+ /**
+ * @classdesc
+ * An iterator for a single recurrence rule. This class usually doesn't have
+ * to be instantiated directly, the convenience method
+ * {@link ICAL.Recur#iterator} can be used.
+ *
+ * @description
+ * The options object may contain additional members when resuming iteration from a previous run
+ *
+ * @description
+ * The options object may contain additional members when resuming iteration
+ * from a previous run.
+ *
+ * @class
+ * @alias ICAL.RecurIterator
+ * @param {Object} options The iterator options
+ * @param {ICAL.Recur} options.rule The rule to iterate.
+ * @param {ICAL.Time} options.dtstart The start date of the event.
+ * @param {Boolean=} options.initialized When true, assume that options are
+ * from a previously constructed iterator. Initialization will not be
+ * repeated.
+ */
+ function icalrecur_iterator(options) {
+ this.fromData(options);
+ }
+
+ icalrecur_iterator.prototype = {
+
+ /**
+ * True when iteration is finished.
+ * @type {Boolean}
+ */
+ completed: false,
+
+ /**
+ * The rule that is being iterated
+ * @type {ICAL.Recur}
+ */
+ rule: null,
+
+ /**
+ * The start date of the event being iterated.
+ * @type {ICAL.Time}
+ */
+ dtstart: null,
+
+ /**
+ * The last occurrence that was returned from the
+ * {@link ICAL.RecurIterator#next} method.
+ * @type {ICAL.Time}
+ */
+ last: null,
+
+ /**
+ * The sequence number from the occurrence
+ * @type {Number}
+ */
+ occurrence_number: 0,
+
+ /**
+ * The indices used for the {@link ICAL.RecurIterator#by_data} object.
+ * @type {Object}
+ * @private
+ */
+ by_indices: null,
+
+ /**
+ * If true, the iterator has already been initialized
+ * @type {Boolean}
+ * @private
+ */
+ initialized: false,
+
+ /**
+ * The initializd by-data.
+ * @type {Object}
+ * @private
+ */
+ by_data: null,
+
+ /**
+ * The expanded yeardays
+ * @type {Array}
+ * @private
+ */
+ days: null,
+
+ /**
+ * The index in the {@link ICAL.RecurIterator#days} array.
+ * @type {Number}
+ * @private
+ */
+ days_index: 0,
+
+ /**
+ * Initialize the recurrence iterator from the passed data object. This
+ * method is usually not called directly, you can initialize the iterator
+ * through the constructor.
+ *
+ * @param {Object} options The iterator options
+ * @param {ICAL.Recur} options.rule The rule to iterate.
+ * @param {ICAL.Time} options.dtstart The start date of the event.
+ * @param {Boolean=} options.initialized When true, assume that options are
+ * from a previously constructed iterator. Initialization will not be
+ * repeated.
+ */
+ fromData: function(options) {
+ this.rule = ICAL.helpers.formatClassType(options.rule, ICAL.Recur);
+
+ if (!this.rule) {
+ throw new Error('iterator requires a (ICAL.Recur) rule');
+ }
+
+ this.dtstart = ICAL.helpers.formatClassType(options.dtstart, ICAL.Time);
+
+ if (!this.dtstart) {
+ throw new Error('iterator requires a (ICAL.Time) dtstart');
+ }
+
+ if (options.by_data) {
+ this.by_data = options.by_data;
+ } else {
+ this.by_data = ICAL.helpers.clone(this.rule.parts, true);
+ }
+
+ if (options.occurrence_number)
+ this.occurrence_number = options.occurrence_number;
+
+ this.days = options.days || [];
+ if (options.last) {
+ this.last = ICAL.helpers.formatClassType(options.last, ICAL.Time);
+ }
+
+ this.by_indices = options.by_indices;
+
+ if (!this.by_indices) {
+ this.by_indices = {
+ "BYSECOND": 0,
+ "BYMINUTE": 0,
+ "BYHOUR": 0,
+ "BYDAY": 0,
+ "BYMONTH": 0,
+ "BYWEEKNO": 0,
+ "BYMONTHDAY": 0
+ };
+ }
+
+ this.initialized = options.initialized || false;
+
+ if (!this.initialized) {
+ try {
+ this.init();
+ } catch (e) {
+ // Init may error if there are no possible recurrence instances from
+ // the rule, but we don't want to bubble this error up. Instead, we
+ // create an empty iterator.
+ this.completed = true;
+ }
+ }
+ },
+
+ /**
+ * Initialize the iterator
+ * @private
+ */
+ init: function icalrecur_iterator_init() {
+ this.initialized = true;
+ this.last = this.dtstart.clone();
+ var parts = this.by_data;
+
+ if ("BYDAY" in parts) {
+ // libical does this earlier when the rule is loaded, but we postpone to
+ // now so we can preserve the original order.
+ this.sort_byday_rules(parts.BYDAY);
+ }
+
+ // If the BYYEARDAY appares, no other date rule part may appear
+ if ("BYYEARDAY" in parts) {
+ if ("BYMONTH" in parts || "BYWEEKNO" in parts ||
+ "BYMONTHDAY" in parts || "BYDAY" in parts) {
+ throw new Error("Invalid BYYEARDAY rule");
+ }
+ }
+
+ // BYWEEKNO and BYMONTHDAY rule parts may not both appear
+ if ("BYWEEKNO" in parts && "BYMONTHDAY" in parts) {
+ throw new Error("BYWEEKNO does not fit to BYMONTHDAY");
+ }
+
+ // For MONTHLY recurrences (FREQ=MONTHLY) neither BYYEARDAY nor
+ // BYWEEKNO may appear.
+ if (this.rule.freq == "MONTHLY" &&
+ ("BYYEARDAY" in parts || "BYWEEKNO" in parts)) {
+ throw new Error("For MONTHLY recurrences neither BYYEARDAY nor BYWEEKNO may appear");
+ }
+
+ // For WEEKLY recurrences (FREQ=WEEKLY) neither BYMONTHDAY nor
+ // BYYEARDAY may appear.
+ if (this.rule.freq == "WEEKLY" &&
+ ("BYYEARDAY" in parts || "BYMONTHDAY" in parts)) {
+ throw new Error("For WEEKLY recurrences neither BYMONTHDAY nor BYYEARDAY may appear");
+ }
+
+ // BYYEARDAY may only appear in YEARLY rules
+ if (this.rule.freq != "YEARLY" && "BYYEARDAY" in parts) {
+ throw new Error("BYYEARDAY may only appear in YEARLY rules");
+ }
+
+ this.last.second = this.setup_defaults("BYSECOND", "SECONDLY", this.dtstart.second);
+ this.last.minute = this.setup_defaults("BYMINUTE", "MINUTELY", this.dtstart.minute);
+ this.last.hour = this.setup_defaults("BYHOUR", "HOURLY", this.dtstart.hour);
+ var dayOffset = this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day);
+ this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month);
+
+ if (this.rule.freq == "WEEKLY") {
+ if ("BYDAY" in parts) {
+ var bydayParts = this.ruleDayOfWeek(parts.BYDAY[0], this.rule.wkst);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var wkdy = dow - this.last.dayOfWeek(this.rule.wkst);
+ if ((this.last.dayOfWeek(this.rule.wkst) < dow && wkdy >= 0) || wkdy < 0) {
+ // Initial time is after first day of BYDAY data
+ this.last.day += wkdy;
+ }
+ } else {
+ var dayName = ICAL.Recur.numericDayToIcalDay(this.dtstart.dayOfWeek());
+ parts.BYDAY = [dayName];
+ }
+ }
+
+ if (this.rule.freq == "YEARLY") {
+ // Some yearly recurrence rules may be specific enough to not actually
+ // occur on a yearly basis, e.g. the 29th day of February or the fifth
+ // Monday of a given month. The standard isn't clear on the intended
+ // behavior in these cases, but `libical` at least will iterate until it
+ // finds a matching year.
+ // CAREFUL: Some rules may specify an occurrence that can never happen,
+ // e.g. the first Monday of April so long as it falls on the 15th
+ // through the 21st. Detecting these is non-trivial, so ensure that we
+ // stop iterating at some point.
+ var untilYear = this.rule.until ? this.rule.until.year : 20000;
+ while (this.last.year <= untilYear) {
+ this.expand_year_days(this.last.year);
+ if (this.days.length > 0) {
+ break;
+ }
+ this.increment_year(this.rule.interval);
+ }
+
+ if (this.days.length == 0) {
+ throw new Error("No possible occurrences");
+ }
+
+ this._nextByYearDay();
+ }
+
+ if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) {
+ var tempLast = null;
+ var initLast = this.last.clone();
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+
+ // Check every weekday in BYDAY with relative dow and pos.
+ for (var i in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(i)) {
+ continue;
+ }
+ this.last = initLast.clone();
+ var bydayParts = this.ruleDayOfWeek(this.by_data.BYDAY[i]);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var dayOfMonth = this.last.nthWeekDay(dow, pos);
+
+ // If |pos| >= 6, the byday is invalid for a monthly rule.
+ if (pos >= 6 || pos <= -6) {
+ throw new Error("Malformed values in BYDAY part");
+ }
+
+ // If a Byday with pos=+/-5 is not in the current month it
+ // must be searched in the next months.
+ if (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
+ // Skip if we have already found a "last" in this month.
+ if (tempLast && tempLast.month == initLast.month) {
+ continue;
+ }
+ while (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
+ this.increment_month();
+ daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ dayOfMonth = this.last.nthWeekDay(dow, pos);
+ }
+ }
+
+ this.last.day = dayOfMonth;
+ if (!tempLast || this.last.compare(tempLast) < 0) {
+ tempLast = this.last.clone();
+ }
+ }
+ this.last = tempLast.clone();
+
+ //XXX: This feels like a hack, but we need to initialize
+ // the BYMONTHDAY case correctly and byDayAndMonthDay handles
+ // this case. It accepts a special flag which will avoid incrementing
+ // the initial value without the flag days that match the start time
+ // would be missed.
+ if (this.has_by_data('BYMONTHDAY')) {
+ this._byDayAndMonthDay(true);
+ }
+
+ if (this.last.day > daysInMonth || this.last.day == 0) {
+ throw new Error("Malformed values in BYDAY part");
+ }
+
+ } else if (this.has_by_data("BYMONTHDAY")) {
+ // Attempting to access `this.last.day` will cause the date to be normalised.
+ // So it will never be a negative value or more than the number of days in the month.
+ // We keep the value in a separate variable instead.
+
+ // Change the day value so that normalisation won't change the month.
+ this.last.day = 1;
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+
+ if (dayOffset < 0) {
+ // A negative value represents days before the end of the month.
+ this.last.day = daysInMonth + dayOffset + 1;
+ } else if (this.by_data.BYMONTHDAY[0] > daysInMonth) {
+ // There's no occurrence in this month, find the next valid month.
+ // The longest possible sequence of skipped months is February-April-June,
+ // so we might need to call next_month up to three times.
+ if (!this.next_month() && !this.next_month() && !this.next_month()) {
+ throw new Error("No possible occurrences");
+ }
+ } else {
+ // Otherwise, reset the day.
+ this.last.day = dayOffset;
+ }
+ }
+ },
+
+ /**
+ * Retrieve the next occurrence from the iterator.
+ * @return {ICAL.Time}
+ */
+ next: function icalrecur_iterator_next() {
+ if ((this.rule.count && this.occurrence_number >= this.rule.count) ||
+ (this.rule.until && this.last.compare(this.rule.until) > 0)) {
+ this.completed = true;
+ }
+
+ if (this.completed) {
+ return null;
+ }
+
+ if (this.occurrence_number == 0 && this.last.compare(this.dtstart) >= 0) {
+ // First of all, give the instance that was initialized
+ this.occurrence_number++;
+ return this.last;
+ }
+
+ var valid;
+ do {
+ valid = 1;
+
+ switch (this.rule.freq) {
+ case "SECONDLY":
+ this.next_second();
+ break;
+ case "MINUTELY":
+ this.next_minute();
+ break;
+ case "HOURLY":
+ this.next_hour();
+ break;
+ case "DAILY":
+ this.next_day();
+ break;
+ case "WEEKLY":
+ this.next_week();
+ break;
+ case "MONTHLY":
+ valid = this.next_month();
+ break;
+ case "YEARLY":
+ this.next_year();
+ break;
+
+ default:
+ return null;
+ }
+ } while (!this.check_contracting_rules() ||
+ this.last.compare(this.dtstart) < 0 ||
+ !valid);
+
+ if (this.rule.until && this.last.compare(this.rule.until) > 0) {
+ this.completed = true;
+ return null;
+ } else {
+ this.occurrence_number++;
+ return this.last;
+ }
+ },
+
+ next_second: function next_second() {
+ return this.next_generic("BYSECOND", "SECONDLY", "second", "minute");
+ },
+
+ increment_second: function increment_second(inc) {
+ return this.increment_generic(inc, "second", 60, "minute");
+ },
+
+ next_minute: function next_minute() {
+ return this.next_generic("BYMINUTE", "MINUTELY",
+ "minute", "hour", "next_second");
+ },
+
+ increment_minute: function increment_minute(inc) {
+ return this.increment_generic(inc, "minute", 60, "hour");
+ },
+
+ next_hour: function next_hour() {
+ return this.next_generic("BYHOUR", "HOURLY", "hour",
+ "monthday", "next_minute");
+ },
+
+ increment_hour: function increment_hour(inc) {
+ this.increment_generic(inc, "hour", 24, "monthday");
+ },
+
+ next_day: function next_day() {
+ var has_by_day = ("BYDAY" in this.by_data);
+ var this_freq = (this.rule.freq == "DAILY");
+
+ if (this.next_hour() == 0) {
+ return 0;
+ }
+
+ if (this_freq) {
+ this.increment_monthday(this.rule.interval);
+ } else {
+ this.increment_monthday(1);
+ }
+
+ return 0;
+ },
+
+ next_week: function next_week() {
+ var end_of_data = 0;
+
+ if (this.next_weekday_by_week() == 0) {
+ return end_of_data;
+ }
+
+ if (this.has_by_data("BYWEEKNO")) {
+ var idx = ++this.by_indices.BYWEEKNO;
+
+ if (this.by_indices.BYWEEKNO == this.by_data.BYWEEKNO.length) {
+ this.by_indices.BYWEEKNO = 0;
+ end_of_data = 1;
+ }
+
+ // HACK should be first month of the year
+ this.last.month = 1;
+ this.last.day = 1;
+
+ var week_no = this.by_data.BYWEEKNO[this.by_indices.BYWEEKNO];
+
+ this.last.day += 7 * week_no;
+
+ if (end_of_data) {
+ this.increment_year(1);
+ }
+ } else {
+ // Jump to the next week
+ this.increment_monthday(7 * this.rule.interval);
+ }
+
+ return end_of_data;
+ },
+
+ /**
+ * Normalize each by day rule for a given year/month.
+ * Takes into account ordering and negative rules
+ *
+ * @private
+ * @param {Number} year Current year.
+ * @param {Number} month Current month.
+ * @param {Array} rules Array of rules.
+ *
+ * @return {Array} sorted and normalized rules.
+ * Negative rules will be expanded to their
+ * correct positive values for easier processing.
+ */
+ normalizeByMonthDayRules: function(year, month, rules) {
+ var daysInMonth = ICAL.Time.daysInMonth(month, year);
+
+ // XXX: This is probably bad for performance to allocate
+ // a new array for each month we scan, if possible
+ // we should try to optimize this...
+ var newRules = [];
+
+ var ruleIdx = 0;
+ var len = rules.length;
+ var rule;
+
+ for (; ruleIdx < len; ruleIdx++) {
+ rule = rules[ruleIdx];
+
+ // if this rule falls outside of given
+ // month discard it.
+ if (Math.abs(rule) > daysInMonth) {
+ continue;
+ }
+
+ // negative case
+ if (rule < 0) {
+ // we add (not subtract it is a negative number)
+ // one from the rule because 1 === last day of month
+ rule = daysInMonth + (rule + 1);
+ } else if (rule === 0) {
+ // skip zero: it is invalid.
+ continue;
+ }
+
+ // only add unique items...
+ if (newRules.indexOf(rule) === -1) {
+ newRules.push(rule);
+ }
+
+ }
+
+ // unique and sort
+ return newRules.sort(function(a, b) { return a - b; });
+ },
+
+ /**
+ * NOTES:
+ * We are given a list of dates in the month (BYMONTHDAY) (23, etc..)
+ * Also we are given a list of days (BYDAY) (MO, 2SU, etc..) when
+ * both conditions match a given date (this.last.day) iteration stops.
+ *
+ * @private
+ * @param {Boolean=} isInit When given true will not increment the
+ * current day (this.last).
+ */
+ _byDayAndMonthDay: function(isInit) {
+ var byMonthDay; // setup in initMonth
+ var byDay = this.by_data.BYDAY;
+
+ var date;
+ var dateIdx = 0;
+ var dateLen; // setup in initMonth
+ var dayLen = byDay.length;
+
+ // we are not valid by default
+ var dataIsValid = 0;
+
+ var daysInMonth;
+ var self = this;
+ // we need a copy of this, because a DateTime gets normalized
+ // automatically if the day is out of range. At some points we
+ // set the last day to 0 to start counting.
+ var lastDay = this.last.day;
+
+ function initMonth() {
+ daysInMonth = ICAL.Time.daysInMonth(
+ self.last.month, self.last.year
+ );
+
+ byMonthDay = self.normalizeByMonthDayRules(
+ self.last.year,
+ self.last.month,
+ self.by_data.BYMONTHDAY
+ );
+
+ dateLen = byMonthDay.length;
+
+ // For the case of more than one occurrence in one month
+ // we have to be sure to start searching after the last
+ // found date or at the last BYMONTHDAY, unless we are
+ // initializing the iterator because in this case we have
+ // to consider the last found date too.
+ while (byMonthDay[dateIdx] <= lastDay &&
+ !(isInit && byMonthDay[dateIdx] == lastDay) &&
+ dateIdx < dateLen - 1) {
+ dateIdx++;
+ }
+ }
+
+ function nextMonth() {
+ // since the day is incremented at the start
+ // of the loop below, we need to start at 0
+ lastDay = 0;
+ self.increment_month();
+ dateIdx = 0;
+ initMonth();
+ }
+
+ initMonth();
+
+ // should come after initMonth
+ if (isInit) {
+ lastDay -= 1;
+ }
+
+ // Use a counter to avoid an infinite loop with malformed rules.
+ // Stop checking after 4 years so we consider also a leap year.
+ var monthsCounter = 48;
+
+ while (!dataIsValid && monthsCounter) {
+ monthsCounter--;
+ // increment the current date. This is really
+ // important otherwise we may fall into the infinite
+ // loop trap. The initial date takes care of the case
+ // where the current date is the date we are looking
+ // for.
+ date = lastDay + 1;
+
+ if (date > daysInMonth) {
+ nextMonth();
+ continue;
+ }
+
+ // find next date
+ var next = byMonthDay[dateIdx++];
+
+ // this logic is dependent on the BYMONTHDAYS
+ // being in order (which is done by #normalizeByMonthDayRules)
+ if (next >= date) {
+ // if the next month day is in the future jump to it.
+ lastDay = next;
+ } else {
+ // in this case the 'next' monthday has past
+ // we must move to the month.
+ nextMonth();
+ continue;
+ }
+
+ // Now we can loop through the day rules to see
+ // if one matches the current month date.
+ for (var dayIdx = 0; dayIdx < dayLen; dayIdx++) {
+ var parts = this.ruleDayOfWeek(byDay[dayIdx]);
+ var pos = parts[0];
+ var dow = parts[1];
+
+ this.last.day = lastDay;
+ if (this.last.isNthWeekDay(dow, pos)) {
+ // when we find the valid one we can mark
+ // the conditions as met and break the loop.
+ // (Because we have this condition above
+ // it will also break the parent loop).
+ dataIsValid = 1;
+ break;
+ }
+ }
+
+ // It is completely possible that the combination
+ // cannot be matched in the current month.
+ // When we reach the end of possible combinations
+ // in the current month we iterate to the next one.
+ // since dateIdx is incremented right after getting
+ // "next", we don't need dateLen -1 here.
+ if (!dataIsValid && dateIdx === dateLen) {
+ nextMonth();
+ continue;
+ }
+ }
+
+ if (monthsCounter <= 0) {
+ // Checked 4 years without finding a Byday that matches
+ // a Bymonthday. Maybe the rule is not correct.
+ throw new Error("Malformed values in BYDAY combined with BYMONTHDAY parts");
+ }
+
+
+ return dataIsValid;
+ },
+
+ next_month: function next_month() {
+ var this_freq = (this.rule.freq == "MONTHLY");
+ var data_valid = 1;
+
+ if (this.next_hour() == 0) {
+ return data_valid;
+ }
+
+ if (this.has_by_data("BYDAY") && this.has_by_data("BYMONTHDAY")) {
+ data_valid = this._byDayAndMonthDay();
+ } else if (this.has_by_data("BYDAY")) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ var setpos = 0;
+ var setpos_total = 0;
+
+ if (this.has_by_data("BYSETPOS")) {
+ var last_day = this.last.day;
+ for (var day = 1; day <= daysInMonth; day++) {
+ this.last.day = day;
+ if (this.is_day_in_byday(this.last)) {
+ setpos_total++;
+ if (day <= last_day) {
+ setpos++;
+ }
+ }
+ }
+ this.last.day = last_day;
+ }
+
+ data_valid = 0;
+ for (var day = this.last.day + 1; day <= daysInMonth; day++) {
+ this.last.day = day;
+
+ if (this.is_day_in_byday(this.last)) {
+ if (!this.has_by_data("BYSETPOS") ||
+ this.check_set_position(++setpos) ||
+ this.check_set_position(setpos - setpos_total - 1)) {
+
+ data_valid = 1;
+ break;
+ }
+ }
+ }
+
+ if (day > daysInMonth) {
+ this.last.day = 1;
+ this.increment_month();
+
+ if (this.is_day_in_byday(this.last)) {
+ if (!this.has_by_data("BYSETPOS") || this.check_set_position(1)) {
+ data_valid = 1;
+ }
+ } else {
+ data_valid = 0;
+ }
+ }
+ } else if (this.has_by_data("BYMONTHDAY")) {
+ this.by_indices.BYMONTHDAY++;
+
+ if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
+ this.by_indices.BYMONTHDAY = 0;
+ this.increment_month();
+ }
+
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ var day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY];
+
+ if (day < 0) {
+ day = daysInMonth + day + 1;
+ }
+
+ if (day > daysInMonth) {
+ this.last.day = 1;
+ data_valid = this.is_day_in_byday(this.last);
+ } else {
+ this.last.day = day;
+ }
+
+ } else {
+ this.increment_month();
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ if (this.by_data.BYMONTHDAY[0] > daysInMonth) {
+ data_valid = 0;
+ } else {
+ this.last.day = this.by_data.BYMONTHDAY[0];
+ }
+ }
+
+ return data_valid;
+ },
+
+ next_weekday_by_week: function next_weekday_by_week() {
+ var end_of_data = 0;
+
+ if (this.next_hour() == 0) {
+ return end_of_data;
+ }
+
+ if (!this.has_by_data("BYDAY")) {
+ return 1;
+ }
+
+ for (;;) {
+ var tt = new ICAL.Time();
+ this.by_indices.BYDAY++;
+
+ if (this.by_indices.BYDAY == Object.keys(this.by_data.BYDAY).length) {
+ this.by_indices.BYDAY = 0;
+ end_of_data = 1;
+ }
+
+ var coded_day = this.by_data.BYDAY[this.by_indices.BYDAY];
+ var parts = this.ruleDayOfWeek(coded_day);
+ var dow = parts[1];
+
+ dow -= this.rule.wkst;
+
+ if (dow < 0) {
+ dow += 7;
+ }
+
+ tt.year = this.last.year;
+ tt.month = this.last.month;
+ tt.day = this.last.day;
+
+ var startOfWeek = tt.startDoyWeek(this.rule.wkst);
+
+ if (dow + startOfWeek < 1) {
+ // The selected date is in the previous year
+ if (!end_of_data) {
+ continue;
+ }
+ }
+
+ var next = ICAL.Time.fromDayOfYear(startOfWeek + dow,
+ this.last.year);
+
+ /**
+ * The normalization horrors below are due to
+ * the fact that when the year/month/day changes
+ * it can effect the other operations that come after.
+ */
+ this.last.year = next.year;
+ this.last.month = next.month;
+ this.last.day = next.day;
+
+ return end_of_data;
+ }
+ },
+
+ next_year: function next_year() {
+
+ if (this.next_hour() == 0) {
+ return 0;
+ }
+
+ if (++this.days_index == this.days.length) {
+ this.days_index = 0;
+ do {
+ this.increment_year(this.rule.interval);
+ this.expand_year_days(this.last.year);
+ } while (this.days.length == 0);
+ }
+
+ this._nextByYearDay();
+
+ return 1;
+ },
+
+ _nextByYearDay: function _nextByYearDay() {
+ var doy = this.days[this.days_index];
+ var year = this.last.year;
+ if (doy < 1) {
+ // Time.fromDayOfYear(doy, year) indexes relative to the
+ // start of the given year. That is different from the
+ // semantics of BYYEARDAY where negative indexes are an
+ // offset from the end of the given year.
+ doy += 1;
+ year += 1;
+ }
+ var next = ICAL.Time.fromDayOfYear(doy, year);
+ this.last.day = next.day;
+ this.last.month = next.month;
+ },
+
+ /**
+ * @param dow (eg: '1TU', '-1MO')
+ * @param {ICAL.Time.weekDay=} aWeekStart The week start weekday
+ * @return [pos, numericDow] (eg: [1, 3]) numericDow is relative to aWeekStart
+ */
+ ruleDayOfWeek: function ruleDayOfWeek(dow, aWeekStart) {
+ var matches = dow.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/);
+ if (matches) {
+ var pos = parseInt(matches[1] || 0, 10);
+ dow = ICAL.Recur.icalDayToNumericDay(matches[2], aWeekStart);
+ return [pos, dow];
+ } else {
+ return [0, 0];
+ }
+ },
+
+ next_generic: function next_generic(aRuleType, aInterval, aDateAttr,
+ aFollowingAttr, aPreviousIncr) {
+ var has_by_rule = (aRuleType in this.by_data);
+ var this_freq = (this.rule.freq == aInterval);
+ var end_of_data = 0;
+
+ if (aPreviousIncr && this[aPreviousIncr]() == 0) {
+ return end_of_data;
+ }
+
+ if (has_by_rule) {
+ this.by_indices[aRuleType]++;
+ var idx = this.by_indices[aRuleType];
+ var dta = this.by_data[aRuleType];
+
+ if (this.by_indices[aRuleType] == dta.length) {
+ this.by_indices[aRuleType] = 0;
+ end_of_data = 1;
+ }
+ this.last[aDateAttr] = dta[this.by_indices[aRuleType]];
+ } else if (this_freq) {
+ this["increment_" + aDateAttr](this.rule.interval);
+ }
+
+ if (has_by_rule && end_of_data && this_freq) {
+ this["increment_" + aFollowingAttr](1);
+ }
+
+ return end_of_data;
+ },
+
+ increment_monthday: function increment_monthday(inc) {
+ for (var i = 0; i < inc; i++) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ this.last.day++;
+
+ if (this.last.day > daysInMonth) {
+ this.last.day -= daysInMonth;
+ this.increment_month();
+ }
+ }
+ },
+
+ increment_month: function increment_month() {
+ this.last.day = 1;
+ if (this.has_by_data("BYMONTH")) {
+ this.by_indices.BYMONTH++;
+
+ if (this.by_indices.BYMONTH == this.by_data.BYMONTH.length) {
+ this.by_indices.BYMONTH = 0;
+ this.increment_year(1);
+ }
+
+ this.last.month = this.by_data.BYMONTH[this.by_indices.BYMONTH];
+ } else {
+ if (this.rule.freq == "MONTHLY") {
+ this.last.month += this.rule.interval;
+ } else {
+ this.last.month++;
+ }
+
+ this.last.month--;
+ var years = ICAL.helpers.trunc(this.last.month / 12);
+ this.last.month %= 12;
+ this.last.month++;
+
+ if (years != 0) {
+ this.increment_year(years);
+ }
+ }
+ },
+
+ increment_year: function increment_year(inc) {
+ this.last.year += inc;
+ },
+
+ increment_generic: function increment_generic(inc, aDateAttr,
+ aFactor, aNextIncrement) {
+ this.last[aDateAttr] += inc;
+ var nextunit = ICAL.helpers.trunc(this.last[aDateAttr] / aFactor);
+ this.last[aDateAttr] %= aFactor;
+ if (nextunit != 0) {
+ this["increment_" + aNextIncrement](nextunit);
+ }
+ },
+
+ has_by_data: function has_by_data(aRuleType) {
+ return (aRuleType in this.rule.parts);
+ },
+
+ expand_year_days: function expand_year_days(aYear) {
+ var t = new ICAL.Time();
+ this.days = [];
+
+ // We need our own copy with a few keys set
+ var parts = {};
+ var rules = ["BYDAY", "BYWEEKNO", "BYMONTHDAY", "BYMONTH", "BYYEARDAY"];
+ for (var p in rules) {
+ /* istanbul ignore else */
+ if (rules.hasOwnProperty(p)) {
+ var part = rules[p];
+ if (part in this.rule.parts) {
+ parts[part] = this.rule.parts[part];
+ }
+ }
+ }
+
+ if ("BYMONTH" in parts && "BYWEEKNO" in parts) {
+ var valid = 1;
+ var validWeeks = {};
+ t.year = aYear;
+ t.isDate = true;
+
+ for (var monthIdx = 0; monthIdx < this.by_data.BYMONTH.length; monthIdx++) {
+ var month = this.by_data.BYMONTH[monthIdx];
+ t.month = month;
+ t.day = 1;
+ var first_week = t.weekNumber(this.rule.wkst);
+ t.day = ICAL.Time.daysInMonth(month, aYear);
+ var last_week = t.weekNumber(this.rule.wkst);
+ for (monthIdx = first_week; monthIdx < last_week; monthIdx++) {
+ validWeeks[monthIdx] = 1;
+ }
+ }
+
+ for (var weekIdx = 0; weekIdx < this.by_data.BYWEEKNO.length && valid; weekIdx++) {
+ var weekno = this.by_data.BYWEEKNO[weekIdx];
+ if (weekno < 52) {
+ valid &= validWeeks[weekIdx];
+ } else {
+ valid = 0;
+ }
+ }
+
+ if (valid) {
+ delete parts.BYMONTH;
+ } else {
+ delete parts.BYWEEKNO;
+ }
+ }
+
+ var partCount = Object.keys(parts).length;
+
+ if (partCount == 0) {
+ var t1 = this.dtstart.clone();
+ t1.year = this.last.year;
+ this.days.push(t1.dayOfYear());
+ } else if (partCount == 1 && "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var t2 = this.dtstart.clone();
+ t2.year = aYear;
+ t2.month = this.by_data.BYMONTH[monthkey];
+ t2.isDate = true;
+ this.days.push(t2.dayOfYear());
+ }
+ } else if (partCount == 1 && "BYMONTHDAY" in parts) {
+ for (var monthdaykey in this.by_data.BYMONTHDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) {
+ continue;
+ }
+ var t3 = this.dtstart.clone();
+ var day_ = this.by_data.BYMONTHDAY[monthdaykey];
+ if (day_ < 0) {
+ var daysInMonth = ICAL.Time.daysInMonth(t3.month, aYear);
+ day_ = day_ + daysInMonth + 1;
+ }
+ t3.day = day_;
+ t3.year = aYear;
+ t3.isDate = true;
+ this.days.push(t3.dayOfYear());
+ }
+ } else if (partCount == 2 &&
+ "BYMONTHDAY" in parts &&
+ "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var month_ = this.by_data.BYMONTH[monthkey];
+ var daysInMonth = ICAL.Time.daysInMonth(month_, aYear);
+ for (var monthdaykey in this.by_data.BYMONTHDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) {
+ continue;
+ }
+ var day_ = this.by_data.BYMONTHDAY[monthdaykey];
+ if (day_ < 0) {
+ day_ = day_ + daysInMonth + 1;
+ }
+ t.day = day_;
+ t.month = month_;
+ t.year = aYear;
+ t.isDate = true;
+
+ this.days.push(t.dayOfYear());
+ }
+ }
+ } else if (partCount == 1 && "BYWEEKNO" in parts) {
+ // TODO unimplemented in libical
+ } else if (partCount == 2 &&
+ "BYWEEKNO" in parts &&
+ "BYMONTHDAY" in parts) {
+ // TODO unimplemented in libical
+ } else if (partCount == 1 && "BYDAY" in parts) {
+ this.days = this.days.concat(this.expand_by_day(aYear));
+ } else if (partCount == 2 && "BYDAY" in parts && "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var month = this.by_data.BYMONTH[monthkey];
+ var daysInMonth = ICAL.Time.daysInMonth(month, aYear);
+
+ t.year = aYear;
+ t.month = this.by_data.BYMONTH[monthkey];
+ t.day = 1;
+ t.isDate = true;
+
+ var first_dow = t.dayOfWeek();
+ var doy_offset = t.dayOfYear() - 1;
+
+ t.day = daysInMonth;
+ var last_dow = t.dayOfWeek();
+
+ if (this.has_by_data("BYSETPOS")) {
+ var set_pos_counter = 0;
+ var by_month_day = [];
+ for (var day = 1; day <= daysInMonth; day++) {
+ t.day = day;
+ if (this.is_day_in_byday(t)) {
+ by_month_day.push(day);
+ }
+ }
+
+ for (var spIndex = 0; spIndex < by_month_day.length; spIndex++) {
+ if (this.check_set_position(spIndex + 1) ||
+ this.check_set_position(spIndex - by_month_day.length)) {
+ this.days.push(doy_offset + by_month_day[spIndex]);
+ }
+ }
+ } else {
+ for (var daycodedkey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daycodedkey)) {
+ continue;
+ }
+ var coded_day = this.by_data.BYDAY[daycodedkey];
+ var bydayParts = this.ruleDayOfWeek(coded_day);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var month_day;
+
+ var first_matching_day = ((dow + 7 - first_dow) % 7) + 1;
+ var last_matching_day = daysInMonth - ((last_dow + 7 - dow) % 7);
+
+ if (pos == 0) {
+ for (var day = first_matching_day; day <= daysInMonth; day += 7) {
+ this.days.push(doy_offset + day);
+ }
+ } else if (pos > 0) {
+ month_day = first_matching_day + (pos - 1) * 7;
+
+ if (month_day <= daysInMonth) {
+ this.days.push(doy_offset + month_day);
+ }
+ } else {
+ month_day = last_matching_day + (pos + 1) * 7;
+
+ if (month_day > 0) {
+ this.days.push(doy_offset + month_day);
+ }
+ }
+ }
+ }
+ }
+ // Return dates in order of occurrence (1,2,3,...) instead
+ // of by groups of weekdays (1,8,15,...,2,9,16,...).
+ this.days.sort(function(a, b) { return a - b; }); // Comparator function allows to sort numbers.
+ } else if (partCount == 2 && "BYDAY" in parts && "BYMONTHDAY" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+ if (this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 3 &&
+ "BYDAY" in parts &&
+ "BYMONTHDAY" in parts &&
+ "BYMONTH" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+
+ if (this.by_data.BYMONTH.indexOf(tt.month) >= 0 &&
+ this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 2 && "BYDAY" in parts && "BYWEEKNO" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+ var weekno = tt.weekNumber(this.rule.wkst);
+
+ if (this.by_data.BYWEEKNO.indexOf(weekno)) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 3 &&
+ "BYDAY" in parts &&
+ "BYWEEKNO" in parts &&
+ "BYMONTHDAY" in parts) {
+ // TODO unimplemted in libical
+ } else if (partCount == 1 && "BYYEARDAY" in parts) {
+ this.days = this.days.concat(this.by_data.BYYEARDAY);
+ } else {
+ this.days = [];
+ }
+ return 0;
+ },
+
+ expand_by_day: function expand_by_day(aYear) {
+
+ var days_list = [];
+ var tmp = this.last.clone();
+
+ tmp.year = aYear;
+ tmp.month = 1;
+ tmp.day = 1;
+ tmp.isDate = true;
+
+ var start_dow = tmp.dayOfWeek();
+
+ tmp.month = 12;
+ tmp.day = 31;
+ tmp.isDate = true;
+
+ var end_dow = tmp.dayOfWeek();
+ var end_year_day = tmp.dayOfYear();
+
+ for (var daykey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = this.by_data.BYDAY[daykey];
+ var parts = this.ruleDayOfWeek(day);
+ var pos = parts[0];
+ var dow = parts[1];
+
+ if (pos == 0) {
+ var tmp_start_doy = ((dow + 7 - start_dow) % 7) + 1;
+
+ for (var doy = tmp_start_doy; doy <= end_year_day; doy += 7) {
+ days_list.push(doy);
+ }
+
+ } else if (pos > 0) {
+ var first;
+ if (dow >= start_dow) {
+ first = dow - start_dow + 1;
+ } else {
+ first = dow - start_dow + 8;
+ }
+
+ days_list.push(first + (pos - 1) * 7);
+ } else {
+ var last;
+ pos = -pos;
+
+ if (dow <= end_dow) {
+ last = end_year_day - end_dow + dow;
+ } else {
+ last = end_year_day - end_dow + dow - 7;
+ }
+
+ days_list.push(last - (pos - 1) * 7);
+ }
+ }
+ return days_list;
+ },
+
+ is_day_in_byday: function is_day_in_byday(tt) {
+ for (var daykey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = this.by_data.BYDAY[daykey];
+ var parts = this.ruleDayOfWeek(day);
+ var pos = parts[0];
+ var dow = parts[1];
+ var this_dow = tt.dayOfWeek();
+
+ if ((pos == 0 && dow == this_dow) ||
+ (tt.nthWeekDay(dow, pos) == tt.day)) {
+ return 1;
+ }
+ }
+
+ return 0;
+ },
+
+ /**
+ * Checks if given value is in BYSETPOS.
+ *
+ * @private
+ * @param {Numeric} aPos position to check for.
+ * @return {Boolean} false unless BYSETPOS rules exist
+ * and the given value is present in rules.
+ */
+ check_set_position: function check_set_position(aPos) {
+ if (this.has_by_data('BYSETPOS')) {
+ var idx = this.by_data.BYSETPOS.indexOf(aPos);
+ // negative numbers are not false-y
+ return idx !== -1;
+ }
+ return false;
+ },
+
+ sort_byday_rules: function icalrecur_sort_byday_rules(aRules) {
+ for (var i = 0; i < aRules.length; i++) {
+ for (var j = 0; j < i; j++) {
+ var one = this.ruleDayOfWeek(aRules[j], this.rule.wkst)[1];
+ var two = this.ruleDayOfWeek(aRules[i], this.rule.wkst)[1];
+
+ if (one > two) {
+ var tmp = aRules[i];
+ aRules[i] = aRules[j];
+ aRules[j] = tmp;
+ }
+ }
+ }
+ },
+
+ check_contract_restriction: function check_contract_restriction(aRuleType, v) {
+ var indexMapValue = icalrecur_iterator._indexMap[aRuleType];
+ var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue];
+ var pass = false;
+
+ if (aRuleType in this.by_data &&
+ ruleMapValue == icalrecur_iterator.CONTRACT) {
+
+ var ruleType = this.by_data[aRuleType];
+
+ for (var bydatakey in ruleType) {
+ /* istanbul ignore else */
+ if (ruleType.hasOwnProperty(bydatakey)) {
+ if (ruleType[bydatakey] == v) {
+ pass = true;
+ break;
+ }
+ }
+ }
+ } else {
+ // Not a contracting byrule or has no data, test passes
+ pass = true;
+ }
+ return pass;
+ },
+
+ check_contracting_rules: function check_contracting_rules() {
+ var dow = this.last.dayOfWeek();
+ var weekNo = this.last.weekNumber(this.rule.wkst);
+ var doy = this.last.dayOfYear();
+
+ return (this.check_contract_restriction("BYSECOND", this.last.second) &&
+ this.check_contract_restriction("BYMINUTE", this.last.minute) &&
+ this.check_contract_restriction("BYHOUR", this.last.hour) &&
+ this.check_contract_restriction("BYDAY", ICAL.Recur.numericDayToIcalDay(dow)) &&
+ this.check_contract_restriction("BYWEEKNO", weekNo) &&
+ this.check_contract_restriction("BYMONTHDAY", this.last.day) &&
+ this.check_contract_restriction("BYMONTH", this.last.month) &&
+ this.check_contract_restriction("BYYEARDAY", doy));
+ },
+
+ setup_defaults: function setup_defaults(aRuleType, req, deftime) {
+ var indexMapValue = icalrecur_iterator._indexMap[aRuleType];
+ var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue];
+
+ if (ruleMapValue != icalrecur_iterator.CONTRACT) {
+ if (!(aRuleType in this.by_data)) {
+ this.by_data[aRuleType] = [deftime];
+ }
+ if (this.rule.freq != req) {
+ return this.by_data[aRuleType][0];
+ }
+ }
+ return deftime;
+ },
+
+ /**
+ * Convert iterator into a serialize-able object. Will preserve current
+ * iteration sequence to ensure the seamless continuation of the recurrence
+ * rule.
+ * @return {Object}
+ */
+ toJSON: function() {
+ var result = Object.create(null);
+
+ result.initialized = this.initialized;
+ result.rule = this.rule.toJSON();
+ result.dtstart = this.dtstart.toJSON();
+ result.by_data = this.by_data;
+ result.days = this.days;
+ result.last = this.last.toJSON();
+ result.by_indices = this.by_indices;
+ result.occurrence_number = this.occurrence_number;
+
+ return result;
+ }
+ };
+
+ icalrecur_iterator._indexMap = {
+ "BYSECOND": 0,
+ "BYMINUTE": 1,
+ "BYHOUR": 2,
+ "BYDAY": 3,
+ "BYMONTHDAY": 4,
+ "BYYEARDAY": 5,
+ "BYWEEKNO": 6,
+ "BYMONTH": 7,
+ "BYSETPOS": 8
+ };
+
+ icalrecur_iterator._expandMap = {
+ "SECONDLY": [1, 1, 1, 1, 1, 1, 1, 1],
+ "MINUTELY": [2, 1, 1, 1, 1, 1, 1, 1],
+ "HOURLY": [2, 2, 1, 1, 1, 1, 1, 1],
+ "DAILY": [2, 2, 2, 1, 1, 1, 1, 1],
+ "WEEKLY": [2, 2, 2, 2, 3, 3, 1, 1],
+ "MONTHLY": [2, 2, 2, 2, 2, 3, 3, 1],
+ "YEARLY": [2, 2, 2, 2, 2, 2, 2, 2]
+ };
+ icalrecur_iterator.UNKNOWN = 0;
+ icalrecur_iterator.CONTRACT = 1;
+ icalrecur_iterator.EXPAND = 2;
+ icalrecur_iterator.ILLEGAL = 3;
+
+ return icalrecur_iterator;
+
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.RecurExpansion = (function() {
+ function formatTime(item) {
+ return ICAL.helpers.formatClassType(item, ICAL.Time);
+ }
+
+ function compareTime(a, b) {
+ return a.compare(b);
+ }
+
+ function isRecurringComponent(comp) {
+ return comp.hasProperty('rdate') ||
+ comp.hasProperty('rrule') ||
+ comp.hasProperty('recurrence-id');
+ }
+
+ /**
+ * @classdesc
+ * Primary class for expanding recurring rules. Can take multiple rrules,
+ * rdates, exdate(s) and iterate (in order) over each next occurrence.
+ *
+ * Once initialized this class can also be serialized saved and continue
+ * iteration from the last point.
+ *
+ * NOTE: it is intended that this class is to be used
+ * with ICAL.Event which handles recurrence exceptions.
+ *
+ * @example
+ * // assuming event is a parsed ical component
+ * var event;
+ *
+ * var expand = new ICAL.RecurExpansion({
+ * component: event,
+ * dtstart: event.getFirstPropertyValue('dtstart')
+ * });
+ *
+ * // remember there are infinite rules
+ * // so it is a good idea to limit the scope
+ * // of the iterations then resume later on.
+ *
+ * // next is always an ICAL.Time or null
+ * var next;
+ *
+ * while (someCondition && (next = expand.next())) {
+ * // do something with next
+ * }
+ *
+ * // save instance for later
+ * var json = JSON.stringify(expand);
+ *
+ * //...
+ *
+ * // NOTE: if the component's properties have
+ * // changed you will need to rebuild the
+ * // class and start over. This only works
+ * // when the component's recurrence info is the same.
+ * var expand = new ICAL.RecurExpansion(JSON.parse(json));
+ *
+ * @description
+ * The options object can be filled with the specified initial values. It can
+ * also contain additional members, as a result of serializing a previous
+ * expansion state, as shown in the example.
+ *
+ * @class
+ * @alias ICAL.RecurExpansion
+ * @param {Object} options
+ * Recurrence expansion options
+ * @param {ICAL.Time} options.dtstart
+ * Start time of the event
+ * @param {ICAL.Component=} options.component
+ * Component for expansion, required if not resuming.
+ */
+ function RecurExpansion(options) {
+ this.ruleDates = [];
+ this.exDates = [];
+ this.fromData(options);
+ }
+
+ RecurExpansion.prototype = {
+ /**
+ * True when iteration is fully completed.
+ * @type {Boolean}
+ */
+ complete: false,
+
+ /**
+ * Array of rrule iterators.
+ *
+ * @type {ICAL.RecurIterator[]}
+ * @private
+ */
+ ruleIterators: null,
+
+ /**
+ * Array of rdate instances.
+ *
+ * @type {ICAL.Time[]}
+ * @private
+ */
+ ruleDates: null,
+
+ /**
+ * Array of exdate instances.
+ *
+ * @type {ICAL.Time[]}
+ * @private
+ */
+ exDates: null,
+
+ /**
+ * Current position in ruleDates array.
+ * @type {Number}
+ * @private
+ */
+ ruleDateInc: 0,
+
+ /**
+ * Current position in exDates array
+ * @type {Number}
+ * @private
+ */
+ exDateInc: 0,
+
+ /**
+ * Current negative date.
+ *
+ * @type {ICAL.Time}
+ * @private
+ */
+ exDate: null,
+
+ /**
+ * Current additional date.
+ *
+ * @type {ICAL.Time}
+ * @private
+ */
+ ruleDate: null,
+
+ /**
+ * Start date of recurring rules.
+ *
+ * @type {ICAL.Time}
+ */
+ dtstart: null,
+
+ /**
+ * Last expanded time
+ *
+ * @type {ICAL.Time}
+ */
+ last: null,
+
+ /**
+ * Initialize the recurrence expansion from the data object. The options
+ * object may also contain additional members, see the
+ * {@link ICAL.RecurExpansion constructor} for more details.
+ *
+ * @param {Object} options
+ * Recurrence expansion options
+ * @param {ICAL.Time} options.dtstart
+ * Start time of the event
+ * @param {ICAL.Component=} options.component
+ * Component for expansion, required if not resuming.
+ */
+ fromData: function(options) {
+ var start = ICAL.helpers.formatClassType(options.dtstart, ICAL.Time);
+
+ if (!start) {
+ throw new Error('.dtstart (ICAL.Time) must be given');
+ } else {
+ this.dtstart = start;
+ }
+
+ if (options.component) {
+ this._init(options.component);
+ } else {
+ this.last = formatTime(options.last) || start.clone();
+
+ if (!options.ruleIterators) {
+ throw new Error('.ruleIterators or .component must be given');
+ }
+
+ this.ruleIterators = options.ruleIterators.map(function(item) {
+ return ICAL.helpers.formatClassType(item, ICAL.RecurIterator);
+ });
+
+ this.ruleDateInc = options.ruleDateInc;
+ this.exDateInc = options.exDateInc;
+
+ if (options.ruleDates) {
+ this.ruleDates = options.ruleDates.map(formatTime);
+ this.ruleDate = this.ruleDates[this.ruleDateInc];
+ }
+
+ if (options.exDates) {
+ this.exDates = options.exDates.map(formatTime);
+ this.exDate = this.exDates[this.exDateInc];
+ }
+
+ if (typeof(options.complete) !== 'undefined') {
+ this.complete = options.complete;
+ }
+ }
+ },
+
+ /**
+ * Retrieve the next occurrence in the series.
+ * @return {ICAL.Time}
+ */
+ next: function() {
+ var iter;
+ var ruleOfDay;
+ var next;
+ var compare;
+
+ var maxTries = 500;
+ var currentTry = 0;
+
+ while (true) {
+ if (currentTry++ > maxTries) {
+ throw new Error(
+ 'max tries have occurred, rule may be impossible to fulfill.'
+ );
+ }
+
+ next = this.ruleDate;
+ iter = this._nextRecurrenceIter(this.last);
+
+ // no more matches
+ // because we increment the rule day or rule
+ // _after_ we choose a value this should be
+ // the only spot where we need to worry about the
+ // end of events.
+ if (!next && !iter) {
+ // there are no more iterators or rdates
+ this.complete = true;
+ break;
+ }
+
+ // no next rule day or recurrence rule is first.
+ if (!next || (iter && next.compare(iter.last) > 0)) {
+ // must be cloned, recur will reuse the time element.
+ next = iter.last.clone();
+ // move to next so we can continue
+ iter.next();
+ }
+
+ // if the ruleDate is still next increment it.
+ if (this.ruleDate === next) {
+ this._nextRuleDay();
+ }
+
+ this.last = next;
+
+ // check the negative rules
+ if (this.exDate) {
+ compare = this.exDate.compare(this.last);
+
+ if (compare < 0) {
+ this._nextExDay();
+ }
+
+ // if the current rule is excluded skip it.
+ if (compare === 0) {
+ this._nextExDay();
+ continue;
+ }
+ }
+
+ //XXX: The spec states that after we resolve the final
+ // list of dates we execute exdate this seems somewhat counter
+ // intuitive to what I have seen most servers do so for now
+ // I exclude based on the original date not the one that may
+ // have been modified by the exception.
+ return this.last;
+ }
+ },
+
+ /**
+ * Converts object into a serialize-able format. This format can be passed
+ * back into the expansion to resume iteration.
+ * @return {Object}
+ */
+ toJSON: function() {
+ function toJSON(item) {
+ return item.toJSON();
+ }
+
+ var result = Object.create(null);
+ result.ruleIterators = this.ruleIterators.map(toJSON);
+
+ if (this.ruleDates) {
+ result.ruleDates = this.ruleDates.map(toJSON);
+ }
+
+ if (this.exDates) {
+ result.exDates = this.exDates.map(toJSON);
+ }
+
+ result.ruleDateInc = this.ruleDateInc;
+ result.exDateInc = this.exDateInc;
+ result.last = this.last.toJSON();
+ result.dtstart = this.dtstart.toJSON();
+ result.complete = this.complete;
+
+ return result;
+ },
+
+ /**
+ * Extract all dates from the properties in the given component. The
+ * properties will be filtered by the property name.
+ *
+ * @private
+ * @param {ICAL.Component} component The component to search in
+ * @param {String} propertyName The property name to search for
+ * @return {ICAL.Time[]} The extracted dates.
+ */
+ _extractDates: function(component, propertyName) {
+ function handleProp(prop) {
+ idx = ICAL.helpers.binsearchInsert(
+ result,
+ prop,
+ compareTime
+ );
+
+ // ordered insert
+ result.splice(idx, 0, prop);
+ }
+
+ var result = [];
+ var props = component.getAllProperties(propertyName);
+ var len = props.length;
+ var i = 0;
+ var prop;
+
+ var idx;
+
+ for (; i < len; i++) {
+ props[i].getValues().forEach(handleProp);
+ }
+
+ return result;
+ },
+
+ /**
+ * Initialize the recurrence expansion.
+ *
+ * @private
+ * @param {ICAL.Component} component The component to initialize from.
+ */
+ _init: function(component) {
+ this.ruleIterators = [];
+
+ this.last = this.dtstart.clone();
+
+ // to provide api consistency non-recurring
+ // events can also use the iterator though it will
+ // only return a single time.
+ if (!isRecurringComponent(component)) {
+ this.ruleDate = this.last.clone();
+ this.complete = true;
+ return;
+ }
+
+ if (component.hasProperty('rdate')) {
+ this.ruleDates = this._extractDates(component, 'rdate');
+
+ // special hack for cases where first rdate is prior
+ // to the start date. We only check for the first rdate.
+ // This is mostly for google's crazy recurring date logic
+ // (contacts birthdays).
+ if ((this.ruleDates[0]) &&
+ (this.ruleDates[0].compare(this.dtstart) < 0)) {
+
+ this.ruleDateInc = 0;
+ this.last = this.ruleDates[0].clone();
+ } else {
+ this.ruleDateInc = ICAL.helpers.binsearchInsert(
+ this.ruleDates,
+ this.last,
+ compareTime
+ );
+ }
+
+ this.ruleDate = this.ruleDates[this.ruleDateInc];
+ }
+
+ if (component.hasProperty('rrule')) {
+ var rules = component.getAllProperties('rrule');
+ var i = 0;
+ var len = rules.length;
+
+ var rule;
+ var iter;
+
+ for (; i < len; i++) {
+ rule = rules[i].getFirstValue();
+ iter = rule.iterator(this.dtstart);
+ this.ruleIterators.push(iter);
+
+ // increment to the next occurrence so future
+ // calls to next return times beyond the initial iteration.
+ // XXX: I find this suspicious might be a bug?
+ iter.next();
+ }
+ }
+
+ if (component.hasProperty('exdate')) {
+ this.exDates = this._extractDates(component, 'exdate');
+ // if we have a .last day we increment the index to beyond it.
+ this.exDateInc = ICAL.helpers.binsearchInsert(
+ this.exDates,
+ this.last,
+ compareTime
+ );
+
+ this.exDate = this.exDates[this.exDateInc];
+ }
+ },
+
+ /**
+ * Advance to the next exdate
+ * @private
+ */
+ _nextExDay: function() {
+ this.exDate = this.exDates[++this.exDateInc];
+ },
+
+ /**
+ * Advance to the next rule date
+ * @private
+ */
+ _nextRuleDay: function() {
+ this.ruleDate = this.ruleDates[++this.ruleDateInc];
+ },
+
+ /**
+ * Find and return the recurrence rule with the most recent event and
+ * return it.
+ *
+ * @private
+ * @return {?ICAL.RecurIterator} Found iterator.
+ */
+ _nextRecurrenceIter: function() {
+ var iters = this.ruleIterators;
+
+ if (iters.length === 0) {
+ return null;
+ }
+
+ var len = iters.length;
+ var iter;
+ var iterTime;
+ var iterIdx = 0;
+ var chosenIter;
+
+ // loop through each iterator
+ for (; iterIdx < len; iterIdx++) {
+ iter = iters[iterIdx];
+ iterTime = iter.last;
+
+ // if iteration is complete
+ // then we must exclude it from
+ // the search and remove it.
+ if (iter.completed) {
+ len--;
+ if (iterIdx !== 0) {
+ iterIdx--;
+ }
+ iters.splice(iterIdx, 1);
+ continue;
+ }
+
+ // find the most recent possible choice
+ if (!chosenIter || chosenIter.last.compare(iterTime) > 0) {
+ // that iterator is saved
+ chosenIter = iter;
+ }
+ }
+
+ // the chosen iterator is returned but not mutated
+ // this iterator contains the most recent event.
+ return chosenIter;
+ }
+ };
+
+ return RecurExpansion;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Event = (function() {
+
+ /**
+ * @classdesc
+ * ICAL.js is organized into multiple layers. The bottom layer is a raw jCal
+ * object, followed by the component/property layer. The highest level is the
+ * event representation, which this class is part of. See the
+ * {@tutorial layers} guide for more details.
+ *
+ * @class
+ * @alias ICAL.Event
+ * @param {ICAL.Component=} component The ICAL.Component to base this event on
+ * @param {Object} options Options for this event
+ * @param {Boolean} options.strictExceptions
+ * When true, will verify exceptions are related by their UUID
+ * @param {Array<ICAL.Component|ICAL.Event>} options.exceptions
+ * Exceptions to this event, either as components or events. If not
+ * specified exceptions will automatically be set in relation of
+ * component's parent
+ */
+ function Event(component, options) {
+ if (!(component instanceof ICAL.Component)) {
+ options = component;
+ component = null;
+ }
+
+ if (component) {
+ this.component = component;
+ } else {
+ this.component = new ICAL.Component('vevent');
+ }
+
+ this._rangeExceptionCache = Object.create(null);
+ this.exceptions = Object.create(null);
+ this.rangeExceptions = [];
+
+ if (options && options.strictExceptions) {
+ this.strictExceptions = options.strictExceptions;
+ }
+
+ if (options && options.exceptions) {
+ options.exceptions.forEach(this.relateException, this);
+ } else if (this.component.parent && !this.isRecurrenceException()) {
+ this.component.parent.getAllSubcomponents('vevent').forEach(function(event) {
+ if (event.hasProperty('recurrence-id')) {
+ this.relateException(event);
+ }
+ }, this);
+ }
+ }
+
+ Event.prototype = {
+
+ THISANDFUTURE: 'THISANDFUTURE',
+
+ /**
+ * List of related event exceptions.
+ *
+ * @type {ICAL.Event[]}
+ */
+ exceptions: null,
+
+ /**
+ * When true, will verify exceptions are related by their UUID.
+ *
+ * @type {Boolean}
+ */
+ strictExceptions: false,
+
+ /**
+ * Relates a given event exception to this object. If the given component
+ * does not share the UID of this event it cannot be related and will throw
+ * an exception.
+ *
+ * If this component is an exception it cannot have other exceptions
+ * related to it.
+ *
+ * @param {ICAL.Component|ICAL.Event} obj Component or event
+ */
+ relateException: function(obj) {
+ if (this.isRecurrenceException()) {
+ throw new Error('cannot relate exception to exceptions');
+ }
+
+ if (obj instanceof ICAL.Component) {
+ obj = new ICAL.Event(obj);
+ }
+
+ if (this.strictExceptions && obj.uid !== this.uid) {
+ throw new Error('attempted to relate unrelated exception');
+ }
+
+ var id = obj.recurrenceId.toString();
+
+ // we don't sort or manage exceptions directly
+ // here the recurrence expander handles that.
+ this.exceptions[id] = obj;
+
+ // index RANGE=THISANDFUTURE exceptions so we can
+ // look them up later in getOccurrenceDetails.
+ if (obj.modifiesFuture()) {
+ var item = [
+ obj.recurrenceId.toUnixTime(), id
+ ];
+
+ // we keep them sorted so we can find the nearest
+ // value later on...
+ var idx = ICAL.helpers.binsearchInsert(
+ this.rangeExceptions,
+ item,
+ compareRangeException
+ );
+
+ this.rangeExceptions.splice(idx, 0, item);
+ }
+ },
+
+ /**
+ * Checks if this record is an exception and has the RANGE=THISANDFUTURE
+ * value.
+ *
+ * @return {Boolean} True, when exception is within range
+ */
+ modifiesFuture: function() {
+ if (!this.component.hasProperty('recurrence-id')) {
+ return false;
+ }
+
+ var range = this.component.getFirstProperty('recurrence-id').getParameter('range');
+ return range === this.THISANDFUTURE;
+ },
+
+ /**
+ * Finds the range exception nearest to the given date.
+ *
+ * @param {ICAL.Time} time usually an occurrence time of an event
+ * @return {?ICAL.Event} the related event/exception or null
+ */
+ findRangeException: function(time) {
+ if (!this.rangeExceptions.length) {
+ return null;
+ }
+
+ var utc = time.toUnixTime();
+ var idx = ICAL.helpers.binsearchInsert(
+ this.rangeExceptions,
+ [utc],
+ compareRangeException
+ );
+
+ idx -= 1;
+
+ // occurs before
+ if (idx < 0) {
+ return null;
+ }
+
+ var rangeItem = this.rangeExceptions[idx];
+
+ /* istanbul ignore next: sanity check only */
+ if (utc < rangeItem[0]) {
+ return null;
+ }
+
+ return rangeItem[1];
+ },
+
+ /**
+ * This object is returned by {@link ICAL.Event#getOccurrenceDetails getOccurrenceDetails}
+ *
+ * @typedef {Object} occurrenceDetails
+ * @memberof ICAL.Event
+ * @property {ICAL.Time} recurrenceId The passed in recurrence id
+ * @property {ICAL.Event} item The occurrence
+ * @property {ICAL.Time} startDate The start of the occurrence
+ * @property {ICAL.Time} endDate The end of the occurrence
+ */
+
+ /**
+ * Returns the occurrence details based on its start time. If the
+ * occurrence has an exception will return the details for that exception.
+ *
+ * NOTE: this method is intend to be used in conjunction
+ * with the {@link ICAL.Event#iterator iterator} method.
+ *
+ * @param {ICAL.Time} occurrence time occurrence
+ * @return {ICAL.Event.occurrenceDetails} Information about the occurrence
+ */
+ getOccurrenceDetails: function(occurrence) {
+ var id = occurrence.toString();
+ var utcId = occurrence.convertToZone(ICAL.Timezone.utcTimezone).toString();
+ var item;
+ var result = {
+ //XXX: Clone?
+ recurrenceId: occurrence
+ };
+
+ if (id in this.exceptions) {
+ item = result.item = this.exceptions[id];
+ result.startDate = item.startDate;
+ result.endDate = item.endDate;
+ result.item = item;
+ } else if (utcId in this.exceptions) {
+ item = this.exceptions[utcId];
+ result.startDate = item.startDate;
+ result.endDate = item.endDate;
+ result.item = item;
+ } else {
+ // range exceptions (RANGE=THISANDFUTURE) have a
+ // lower priority then direct exceptions but
+ // must be accounted for first. Their item is
+ // always the first exception with the range prop.
+ var rangeExceptionId = this.findRangeException(
+ occurrence
+ );
+ var end;
+
+ if (rangeExceptionId) {
+ var exception = this.exceptions[rangeExceptionId];
+
+ // range exception must modify standard time
+ // by the difference (if any) in start/end times.
+ result.item = exception;
+
+ var startDiff = this._rangeExceptionCache[rangeExceptionId];
+
+ if (!startDiff) {
+ var original = exception.recurrenceId.clone();
+ var newStart = exception.startDate.clone();
+
+ // zones must be same otherwise subtract may be incorrect.
+ original.zone = newStart.zone;
+ startDiff = newStart.subtractDate(original);
+
+ this._rangeExceptionCache[rangeExceptionId] = startDiff;
+ }
+
+ var start = occurrence.clone();
+ start.zone = exception.startDate.zone;
+ start.addDuration(startDiff);
+
+ end = start.clone();
+ end.addDuration(exception.duration);
+
+ result.startDate = start;
+ result.endDate = end;
+ } else {
+ // no range exception standard expansion
+ end = occurrence.clone();
+ end.addDuration(this.duration);
+
+ result.endDate = end;
+ result.startDate = occurrence;
+ result.item = this;
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Builds a recur expansion instance for a specific point in time (defaults
+ * to startDate).
+ *
+ * @param {ICAL.Time} startTime Starting point for expansion
+ * @return {ICAL.RecurExpansion} Expansion object
+ */
+ iterator: function(startTime) {
+ return new ICAL.RecurExpansion({
+ component: this.component,
+ dtstart: startTime || this.startDate
+ });
+ },
+
+ /**
+ * Checks if the event is recurring
+ *
+ * @return {Boolean} True, if event is recurring
+ */
+ isRecurring: function() {
+ var comp = this.component;
+ return comp.hasProperty('rrule') || comp.hasProperty('rdate');
+ },
+
+ /**
+ * Checks if the event describes a recurrence exception. See
+ * {@tutorial terminology} for details.
+ *
+ * @return {Boolean} True, if the event describes a recurrence exception
+ */
+ isRecurrenceException: function() {
+ return this.component.hasProperty('recurrence-id');
+ },
+
+ /**
+ * Returns the types of recurrences this event may have.
+ *
+ * Returned as an object with the following possible keys:
+ *
+ * - YEARLY
+ * - MONTHLY
+ * - WEEKLY
+ * - DAILY
+ * - MINUTELY
+ * - SECONDLY
+ *
+ * @return {Object.<ICAL.Recur.frequencyValues, Boolean>}
+ * Object of recurrence flags
+ */
+ getRecurrenceTypes: function() {
+ var rules = this.component.getAllProperties('rrule');
+ var i = 0;
+ var len = rules.length;
+ var result = Object.create(null);
+
+ for (; i < len; i++) {
+ var value = rules[i].getFirstValue();
+ result[value.freq] = true;
+ }
+
+ return result;
+ },
+
+ /**
+ * The uid of this event
+ * @type {String}
+ */
+ get uid() {
+ return this._firstProp('uid');
+ },
+
+ set uid(value) {
+ this._setProp('uid', value);
+ },
+
+ /**
+ * The start date
+ * @type {ICAL.Time}
+ */
+ get startDate() {
+ return this._firstProp('dtstart');
+ },
+
+ set startDate(value) {
+ this._setTime('dtstart', value);
+ },
+
+ /**
+ * The end date. This can be the result directly from the property, or the
+ * end date calculated from start date and duration. Setting the property
+ * will remove any duration properties.
+ * @type {ICAL.Time}
+ */
+ get endDate() {
+ var endDate = this._firstProp('dtend');
+ if (!endDate) {
+ var duration = this._firstProp('duration');
+ endDate = this.startDate.clone();
+ if (duration) {
+ endDate.addDuration(duration);
+ } else if (endDate.isDate) {
+ endDate.day += 1;
+ }
+ }
+ return endDate;
+ },
+
+ set endDate(value) {
+ if (this.component.hasProperty('duration')) {
+ this.component.removeProperty('duration');
+ }
+ this._setTime('dtend', value);
+ },
+
+ /**
+ * The duration. This can be the result directly from the property, or the
+ * duration calculated from start date and end date. Setting the property
+ * will remove any `dtend` properties.
+ * @type {ICAL.Duration}
+ */
+ get duration() {
+ var duration = this._firstProp('duration');
+ if (!duration) {
+ return this.endDate.subtractDateTz(this.startDate);
+ }
+ return duration;
+ },
+
+ set duration(value) {
+ if (this.component.hasProperty('dtend')) {
+ this.component.removeProperty('dtend');
+ }
+
+ this._setProp('duration', value);
+ },
+
+ /**
+ * The location of the event.
+ * @type {String}
+ */
+ get location() {
+ return this._firstProp('location');
+ },
+
+ set location(value) {
+ return this._setProp('location', value);
+ },
+
+ /**
+ * The attendees in the event
+ * @type {ICAL.Property[]}
+ * @readonly
+ */
+ get attendees() {
+ //XXX: This is way lame we should have a better
+ // data structure for this later.
+ return this.component.getAllProperties('attendee');
+ },
+
+
+ /**
+ * The event summary
+ * @type {String}
+ */
+ get summary() {
+ return this._firstProp('summary');
+ },
+
+ set summary(value) {
+ this._setProp('summary', value);
+ },
+
+ /**
+ * The event description.
+ * @type {String}
+ */
+ get description() {
+ return this._firstProp('description');
+ },
+
+ set description(value) {
+ this._setProp('description', value);
+ },
+
+ /**
+ * The event color from [rfc7986](https://datatracker.ietf.org/doc/html/rfc7986)
+ * @type {String}
+ */
+ get color() {
+ return this._firstProp('color');
+ },
+
+ set color(value) {
+ this._setProp('color', value);
+ },
+
+ /**
+ * The organizer value as an uri. In most cases this is a mailto: uri, but
+ * it can also be something else, like urn:uuid:...
+ * @type {String}
+ */
+ get organizer() {
+ return this._firstProp('organizer');
+ },
+
+ set organizer(value) {
+ this._setProp('organizer', value);
+ },
+
+ /**
+ * The sequence value for this event. Used for scheduling
+ * see {@tutorial terminology}.
+ * @type {Number}
+ */
+ get sequence() {
+ return this._firstProp('sequence');
+ },
+
+ set sequence(value) {
+ this._setProp('sequence', value);
+ },
+
+ /**
+ * The recurrence id for this event. See {@tutorial terminology} for details.
+ * @type {ICAL.Time}
+ */
+ get recurrenceId() {
+ return this._firstProp('recurrence-id');
+ },
+
+ set recurrenceId(value) {
+ this._setTime('recurrence-id', value);
+ },
+
+ /**
+ * Set/update a time property's value.
+ * This will also update the TZID of the property.
+ *
+ * TODO: this method handles the case where we are switching
+ * from a known timezone to an implied timezone (one without TZID).
+ * This does _not_ handle the case of moving between a known
+ * (by TimezoneService) timezone to an unknown timezone...
+ *
+ * We will not add/remove/update the VTIMEZONE subcomponents
+ * leading to invalid ICAL data...
+ * @private
+ * @param {String} propName The property name
+ * @param {ICAL.Time} time The time to set
+ */
+ _setTime: function(propName, time) {
+ var prop = this.component.getFirstProperty(propName);
+
+ if (!prop) {
+ prop = new ICAL.Property(propName);
+ this.component.addProperty(prop);
+ }
+
+ // utc and local don't get a tzid
+ if (
+ time.zone === ICAL.Timezone.localTimezone ||
+ time.zone === ICAL.Timezone.utcTimezone
+ ) {
+ // remove the tzid
+ prop.removeParameter('tzid');
+ } else {
+ prop.setParameter('tzid', time.zone.tzid);
+ }
+
+ prop.setValue(time);
+ },
+
+ _setProp: function(name, value) {
+ this.component.updatePropertyWithValue(name, value);
+ },
+
+ _firstProp: function(name) {
+ return this.component.getFirstPropertyValue(name);
+ },
+
+ /**
+ * The string representation of this event.
+ * @return {String}
+ */
+ toString: function() {
+ return this.component.toString();
+ }
+
+ };
+
+ function compareRangeException(a, b) {
+ if (a[0] > b[0]) return 1;
+ if (b[0] > a[0]) return -1;
+ return 0;
+ }
+
+ return Event;
+}());
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ * Portions Copyright (C) Philipp Kewisch, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.ComponentParser = (function() {
+ /**
+ * @classdesc
+ * The ComponentParser is used to process a String or jCal Object,
+ * firing callbacks for various found components, as well as completion.
+ *
+ * @example
+ * var options = {
+ * // when false no events will be emitted for type
+ * parseEvent: true,
+ * parseTimezone: true
+ * };
+ *
+ * var parser = new ICAL.ComponentParser(options);
+ *
+ * parser.onevent(eventComponent) {
+ * //...
+ * }
+ *
+ * // ontimezone, etc...
+ *
+ * parser.oncomplete = function() {
+ *
+ * };
+ *
+ * parser.process(stringOrComponent);
+ *
+ * @class
+ * @alias ICAL.ComponentParser
+ * @param {Object=} options Component parser options
+ * @param {Boolean} options.parseEvent Whether events should be parsed
+ * @param {Boolean} options.parseTimezeone Whether timezones should be parsed
+ */
+ function ComponentParser(options) {
+ if (typeof(options) === 'undefined') {
+ options = {};
+ }
+
+ var key;
+ for (key in options) {
+ /* istanbul ignore else */
+ if (options.hasOwnProperty(key)) {
+ this[key] = options[key];
+ }
+ }
+ }
+
+ ComponentParser.prototype = {
+
+ /**
+ * When true, parse events
+ *
+ * @type {Boolean}
+ */
+ parseEvent: true,
+
+ /**
+ * When true, parse timezones
+ *
+ * @type {Boolean}
+ */
+ parseTimezone: true,
+
+
+ /* SAX like events here for reference */
+
+ /**
+ * Fired when parsing is complete
+ * @callback
+ */
+ oncomplete: /* istanbul ignore next */ function() {},
+
+ /**
+ * Fired if an error occurs during parsing.
+ *
+ * @callback
+ * @param {Error} err details of error
+ */
+ onerror: /* istanbul ignore next */ function(err) {},
+
+ /**
+ * Fired when a top level component (VTIMEZONE) is found
+ *
+ * @callback
+ * @param {ICAL.Timezone} component Timezone object
+ */
+ ontimezone: /* istanbul ignore next */ function(component) {},
+
+ /**
+ * Fired when a top level component (VEVENT) is found.
+ *
+ * @callback
+ * @param {ICAL.Event} component Top level component
+ */
+ onevent: /* istanbul ignore next */ function(component) {},
+
+ /**
+ * Process a string or parse ical object. This function itself will return
+ * nothing but will start the parsing process.
+ *
+ * Events must be registered prior to calling this method.
+ *
+ * @param {ICAL.Component|String|Object} ical The component to process,
+ * either in its final form, as a jCal Object, or string representation
+ */
+ process: function(ical) {
+ //TODO: this is sync now in the future we will have a incremental parser.
+ if (typeof(ical) === 'string') {
+ ical = ICAL.parse(ical);
+ }
+
+ if (!(ical instanceof ICAL.Component)) {
+ ical = new ICAL.Component(ical);
+ }
+
+ var components = ical.getAllSubcomponents();
+ var i = 0;
+ var len = components.length;
+ var component;
+
+ for (; i < len; i++) {
+ component = components[i];
+
+ switch (component.name) {
+ case 'vtimezone':
+ if (this.parseTimezone) {
+ var tzid = component.getFirstPropertyValue('tzid');
+ if (tzid) {
+ this.ontimezone(new ICAL.Timezone({
+ tzid: tzid,
+ component: component
+ }));
+ }
+ }
+ break;
+ case 'vevent':
+ if (this.parseEvent) {
+ this.onevent(new ICAL.Event(component));
+ }
+ break;
+ default:
+ continue;
+ }
+ }
+
+ //XXX: ideally we should do a "nextTick" here
+ // so in all cases this is actually async.
+ this.oncomplete();
+ }
+ };
+
+ return ComponentParser;
+}());
diff --git a/comm/calendar/base/modules/calCalendarDeactivator.jsm b/comm/calendar/base/modules/calCalendarDeactivator.jsm
new file mode 100644
index 0000000000..c987d17728
--- /dev/null
+++ b/comm/calendar/base/modules/calCalendarDeactivator.jsm
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["calendarDeactivator"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Handles deactivation of calendar UI and background processes/services (such
+ * as the alarms service) when users do not want to use calendar functionality.
+ * Also handles re-activation when users change their mind.
+ *
+ * If all of a user's calendars are disabled (e.g. calendar > properties >
+ * "turn this calendar on") then full calendar functionality is deactivated.
+ * If one or more calendars are enabled then full calendar functionality is
+ * activated.
+ *
+ * Note we use "disabled"/"enabled" for a user's individual calendars and
+ * "deactivated"/"activated" for the calendar component as a whole.
+ *
+ * @implements {calICalendarManagerObserver}
+ * @implements {calIObserver}
+ */
+var calendarDeactivator = {
+ windows: new Set(),
+ calendars: null,
+ isCalendarActivated: null,
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver", "calIObserver"]),
+
+ initializeDeactivator() {
+ this.calendars = new Set(cal.manager.getCalendars());
+ cal.manager.addObserver(this);
+ cal.manager.addCalendarObserver(this);
+ this.isCalendarActivated = this.checkCalendarsEnabled();
+ },
+
+ /**
+ * Register a window to allow future modifications, and set up the window's
+ * deactivated/activated state. Deregistration is not required.
+ *
+ * @param {ChromeWindow} window - A ChromeWindow object.
+ */
+ registerWindow(window) {
+ if (this.calendars === null) {
+ this.initializeDeactivator();
+ }
+ this.windows.add(window);
+ window.addEventListener("unload", () => this.windows.delete(window));
+
+ if (this.isCalendarActivated) {
+ window.document.documentElement.removeAttribute("calendar-deactivated");
+ } else {
+ this.refreshNotificationBoxes(window, false);
+ }
+ },
+
+ /**
+ * Check the enabled state of all of the user's calendars.
+ *
+ * @returns {boolean} True if any calendars are enabled, false if all are disabled.
+ */
+ checkCalendarsEnabled() {
+ for (let calendar of this.calendars) {
+ if (!calendar.getProperty("disabled")) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * If needed, change the calendar activated/deactivated state and update the
+ * UI and background processes/services accordingly.
+ */
+ refreshDeactivatedState() {
+ let someCalsEnabled = this.checkCalendarsEnabled();
+
+ if (someCalsEnabled == this.isCalendarActivated) {
+ return;
+ }
+
+ for (let window of this.windows) {
+ if (someCalsEnabled) {
+ window.document.documentElement.removeAttribute("calendar-deactivated");
+ } else {
+ window.document.documentElement.setAttribute("calendar-deactivated", "");
+ }
+ this.refreshNotificationBoxes(window, someCalsEnabled);
+ }
+
+ if (someCalsEnabled) {
+ Services.prefs.setBoolPref("calendar.itip.showImipBar", true);
+ }
+
+ this.isCalendarActivated = someCalsEnabled;
+ },
+
+ /**
+ * Show or hide the notification boxes that appear at the top of the calendar
+ * and tasks tabs when calendar functionality is deactivated.
+ *
+ * @param {ChromeWindow} window - A ChromeWindow object.
+ * @param {boolean} isEnabled - Whether any calendars are enabled.
+ */
+ refreshNotificationBoxes(window, isEnabled) {
+ let notificationboxes = [
+ [
+ window.calendarTabType.modes.calendar.notificationbox,
+ "calendar-deactivated-notification-events",
+ ],
+ [
+ window.calendarTabType.modes.tasks.notificationbox,
+ "calendar-deactivated-notification-tasks",
+ ],
+ ];
+
+ let value = "calendarDeactivated";
+ for (let [notificationbox, l10nId] of notificationboxes) {
+ let existingNotification = notificationbox.getNotificationWithValue(value);
+
+ if (isEnabled) {
+ notificationbox.removeNotification(existingNotification);
+ } else if (!existingNotification) {
+ notificationbox.appendNotification(
+ value,
+ {
+ label: { "l10n-id": l10nId },
+ priority: notificationbox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ }
+ }
+ },
+
+ // calICalendarManagerObserver methods
+ onCalendarRegistered(calendar) {
+ this.calendars.add(calendar);
+
+ if (!this.isCalendarActivated && !calendar.getProperty("disabled")) {
+ this.refreshDeactivatedState();
+ }
+ },
+
+ onCalendarUnregistering(calendar) {
+ this.calendars.delete(calendar);
+
+ if (!calendar.getProperty("disabled")) {
+ this.refreshDeactivatedState();
+ }
+ },
+ onCalendarDeleting(calendar) {},
+
+ // calIObserver methods
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(item) {},
+ onModifyItem(newItem, oldItem) {},
+ onDeleteItem(deletedItem) {},
+ onError(calendar, errNo, message) {},
+
+ onPropertyChanged(calendar, name, value, oldValue) {
+ if (name == "disabled") {
+ this.refreshDeactivatedState();
+ }
+ },
+
+ onPropertyDeleting(calendar, name) {},
+};
diff --git a/comm/calendar/base/modules/calExtract.jsm b/comm/calendar/base/modules/calExtract.jsm
new file mode 100644
index 0000000000..4bb68cf77b
--- /dev/null
+++ b/comm/calendar/base/modules/calExtract.jsm
@@ -0,0 +1,1417 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["Extractor"];
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Initializes extraction
+ *
+ * @param fallbackLocale locale to use when others are not found or
+ * detection is disabled
+ * @param dayStart ambiguous hours earlier than this are considered to
+ * be in the afternoon, when null then by default
+ * set to 6
+ * @param fixedLang whether to use only fallbackLocale for extraction
+ */
+function Extractor(fallbackLocale, dayStart, fixedLang) {
+ this.bundleUrl = "resource:///chrome/LOCALE/locale/LOCALE/calendar/calendar-extract.properties";
+ this.fallbackLocale = fallbackLocale;
+ this.email = "";
+ this.marker = "--MARK--";
+ // this should never be found in an email
+ this.defPattern = "061dc19c-719f-47f3-b2b5-e767e6f02b7a";
+ this.collected = [];
+ this.numbers = [];
+ this.hourlyNumbers = [];
+ this.dailyNumbers = [];
+ this.allMonths = "";
+ this.months = [];
+ this.dayStart = 6;
+ this.now = new Date();
+ this.bundle = "";
+ this.overrides = {};
+ this.fixedLang = true;
+
+ if (dayStart != null) {
+ this.dayStart = dayStart;
+ }
+
+ if (fixedLang != null) {
+ this.fixedLang = fixedLang;
+ }
+
+ if (!this.checkBundle(fallbackLocale)) {
+ cal.WARN(
+ "Your installed Lightning only includes a single locale, extracting event info from other languages is likely inaccurate. You can install Lightning from addons.mozilla.org manually for multiple locale support."
+ );
+ }
+}
+
+Extractor.prototype = {
+ /**
+ * Removes confusing data like urls, timezones and phone numbers from email
+ * Also removes standard signatures and quoted content from previous emails
+ */
+ cleanup() {
+ // XXX remove earlier correspondence
+ // ideally this should be considered with lower certainty to fill in
+ // missing information
+
+ // remove last line preceding quoted message and first line of the quote
+ this.email = this.email.replace(/\r?\n[^>].*\r?\n>+.*$/m, "");
+ // remove the rest of quoted content
+ this.email = this.email.replace(/^>+.*$/gm, "");
+
+ // urls often contain dates dates that can confuse extraction
+ this.email = this.email.replace(/https?:\/\/[^\s]+\s/gm, "");
+ this.email = this.email.replace(/www\.[^\s]+\s/gm, "");
+
+ // remove phone numbers
+ // TODO allow locale specific configuration of formats
+ this.email = this.email.replace(/\d-\d\d\d-\d\d\d-\d\d\d\d/gm, "");
+
+ // remove standard signature
+ this.email = this.email.replace(/\r?\n-- \r?\n[\S\s]+$/, "");
+
+ // XXX remove timezone info, for now
+ this.email = this.email.replace(/gmt[+-]\d{2}:\d{2}/gi, "");
+ },
+
+ checkBundle(locale) {
+ let path = this.bundleUrl.replace(/LOCALE/g, locale);
+ let bundle = Services.strings.createBundle(path);
+
+ try {
+ bundle.GetStringFromName("from.today");
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ },
+
+ avgNonAsciiCharCode() {
+ let sum = 0;
+ let cnt = 0;
+
+ for (let i = 0; i < this.email.length; i++) {
+ let char = this.email.charCodeAt(i);
+ if (char > 128) {
+ sum += char;
+ cnt++;
+ }
+ }
+
+ let nonAscii = sum / cnt || 0;
+ cal.LOG("[calExtract] Average non-ascii charcode: " + nonAscii);
+ return nonAscii;
+ },
+
+ setLanguage() {
+ let path;
+
+ if (this.fixedLang) {
+ if (this.checkBundle(this.fallbackLocale)) {
+ cal.LOG(
+ "[calExtract] Fixed locale was used to choose " + this.fallbackLocale + " patterns."
+ );
+ } else {
+ cal.LOG(
+ "[calExtract] " + this.fallbackLocale + " patterns were not found. Using en-US instead"
+ );
+ this.fallbackLocale = "en-US";
+ }
+
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+
+ let pref = "calendar.patterns.last.used.languages";
+ let lastUsedLangs = Services.prefs.getStringPref(pref, "");
+ if (lastUsedLangs == "") {
+ Services.prefs.setStringPref(pref, this.fallbackLocale);
+ } else {
+ let langs = lastUsedLangs.split(",");
+ let idx = langs.indexOf(this.fallbackLocale);
+ if (idx == -1) {
+ Services.prefs.setStringPref(pref, this.fallbackLocale + "," + lastUsedLangs);
+ } else {
+ langs.splice(idx, 1);
+ Services.prefs.setStringPref(pref, this.fallbackLocale + "," + langs.join(","));
+ }
+ }
+ } else {
+ let spellchecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ let dicts = spellchecker.getDictionaryList();
+
+ if (dicts.length == 0) {
+ cal.LOG(
+ "[calExtract] There are no dictionaries installed and " +
+ "enabled. You might want to add some if date and time " +
+ "extraction from emails seems inaccurate."
+ );
+ }
+
+ let patterns;
+ let words = this.email.split(/\s+/);
+ let most = 0;
+ let mostLocale;
+ for (let dict in dicts) {
+ // dictionary locale and patterns locale match
+ if (this.checkBundle(dicts[dict])) {
+ let time1 = new Date().getTime();
+ spellchecker.dictionaries = [dicts[dict]];
+ let dur = new Date().getTime() - time1;
+ cal.LOG("[calExtract] Loading " + dicts[dict] + " dictionary took " + dur + "ms");
+ patterns = dicts[dict];
+ // beginning of dictionary locale matches patterns locale
+ } else if (this.checkBundle(dicts[dict].substring(0, 2))) {
+ let time1 = new Date().getTime();
+ spellchecker.dictionaries = [dicts[dict]];
+ let dur = new Date().getTime() - time1;
+ cal.LOG("[calExtract] Loading " + dicts[dict] + " dictionary took " + dur + "ms");
+ patterns = dicts[dict].substring(0, 2);
+ // dictionary for which patterns aren't present
+ } else {
+ cal.LOG("[calExtract] Dictionary present, rules missing: " + dicts[dict]);
+ continue;
+ }
+
+ let correct = 0;
+ let total = 0;
+ for (let word in words) {
+ words[word] = words[word].replace(/[()\d,;:?!#.]/g, "");
+ if (words[word].length >= 2) {
+ total++;
+ if (spellchecker.check(words[word])) {
+ correct++;
+ }
+ }
+ }
+
+ let percentage = (correct / total) * 100.0;
+ cal.LOG("[calExtract] " + dicts[dict] + " dictionary matches " + percentage + "% of words");
+
+ if (percentage > 50.0 && percentage > most) {
+ mostLocale = patterns;
+ most = percentage;
+ }
+ }
+
+ let avgCharCode = this.avgNonAsciiCharCode();
+
+ // using dictionaries for language recognition with non-latin letters doesn't work
+ // very well, possibly because of bug 471799
+ if (avgCharCode > 48000 && avgCharCode < 50000) {
+ cal.LOG("[calExtract] Using ko patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ko");
+ // is it possible to differentiate zh-TW and zh-CN?
+ } else if (avgCharCode > 24000 && avgCharCode < 32000) {
+ cal.LOG("[calExtract] Using zh-TW patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "zh-TW");
+ } else if (avgCharCode > 14000 && avgCharCode < 24000) {
+ cal.LOG("[calExtract] Using ja patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ja");
+ // Bulgarian also looks like that
+ } else if (avgCharCode > 1000 && avgCharCode < 1200) {
+ cal.LOG("[calExtract] Using ru patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ru");
+ // dictionary based
+ } else if (most > 0) {
+ cal.LOG("[calExtract] Using " + mostLocale + " patterns based on dictionary");
+ path = this.bundleUrl.replace(/LOCALE/g, mostLocale);
+ // fallbackLocale matches patterns exactly
+ } else if (this.checkBundle(this.fallbackLocale)) {
+ cal.LOG("[calExtract] Falling back to " + this.fallbackLocale);
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+ // beginning of fallbackLocale matches patterns
+ } else if (this.checkBundle(this.fallbackLocale.substring(0, 2))) {
+ this.fallbackLocale = this.fallbackLocale.substring(0, 2);
+ cal.LOG("[calExtract] Falling back to " + this.fallbackLocale);
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+ } else {
+ cal.LOG("[calExtract] Using en-US");
+ path = this.bundleUrl.replace(/LOCALE/g, "en-US");
+ }
+ }
+ this.bundle = Services.strings.createBundle(path);
+ },
+
+ /**
+ * Extracts dates, times and durations from email
+ *
+ * @param body email body
+ * @param now reference time against which relative times are interpreted,
+ * when null current time is used
+ * @param sel selection object of email content, when defined times
+ * outside selection are discarded
+ * @param title email title
+ * @returns sorted list of extracted datetime objects
+ */
+ extract(title, body, now, sel) {
+ let initial = {};
+ this.collected = [];
+ this.email = title + "\r\n" + body;
+ if (now != null) {
+ this.now = now;
+ }
+
+ initial.year = now.getFullYear();
+ initial.month = now.getMonth() + 1;
+ initial.day = now.getDate();
+ initial.hour = now.getHours();
+ initial.minute = now.getMinutes();
+
+ this.collected.push({
+ year: initial.year,
+ month: initial.month,
+ day: initial.day,
+ hour: initial.hour,
+ minute: initial.minute,
+ relation: "start",
+ });
+
+ this.cleanup();
+ cal.LOG("[calExtract] Email after processing for extraction: \n" + this.email);
+
+ this.overrides = JSON.parse(Services.prefs.getStringPref("calendar.patterns.override", "{}"));
+ this.setLanguage();
+
+ for (let i = 0; i <= 31; i++) {
+ this.numbers[i] = this.getPatterns("number." + i);
+ }
+ this.dailyNumbers = this.numbers.join(this.marker);
+
+ this.hourlyNumbers = this.numbers[0] + this.marker;
+ for (let i = 1; i <= 22; i++) {
+ this.hourlyNumbers += this.numbers[i] + this.marker;
+ }
+ this.hourlyNumbers += this.numbers[23];
+
+ this.hourlyNumbers = this.hourlyNumbers.replace(/\|/g, this.marker);
+ this.dailyNumbers = this.dailyNumbers.replace(/\|/g, this.marker);
+
+ for (let i = 0; i < 12; i++) {
+ this.months[i] = this.getPatterns("month." + (i + 1));
+ }
+ this.allMonths = this.months.join(this.marker).replace(/\|/g, this.marker);
+
+ // time
+ this.extractTime("from.noon", "start", 12, 0);
+ this.extractTime("until.noon", "end", 12, 0);
+
+ this.extractHour("from.hour", "start", "none");
+ this.extractHour("from.hour.am", "start", "ante");
+ this.extractHour("from.hour.pm", "start", "post");
+ this.extractHour("until.hour", "end", "none");
+ this.extractHour("until.hour.am", "end", "ante");
+ this.extractHour("until.hour.pm", "end", "post");
+
+ this.extractHalfHour("from.half.hour.before", "start", "ante");
+ this.extractHalfHour("until.half.hour.before", "end", "ante");
+ this.extractHalfHour("from.half.hour.after", "start", "post");
+ this.extractHalfHour("until.half.hour.after", "end", "post");
+
+ this.extractHourMinutes("from.hour.minutes", "start", "none");
+ this.extractHourMinutes("from.hour.minutes.am", "start", "ante");
+ this.extractHourMinutes("from.hour.minutes.pm", "start", "post");
+ this.extractHourMinutes("until.hour.minutes", "end", "none");
+ this.extractHourMinutes("until.hour.minutes.am", "end", "ante");
+ this.extractHourMinutes("until.hour.minutes.pm", "end", "post");
+
+ // date
+ this.extractRelativeDay("from.today", "start", 0);
+ this.extractRelativeDay("from.tomorrow", "start", 1);
+ this.extractRelativeDay("until.tomorrow", "end", 1);
+ this.extractWeekDay("from.weekday.", "start");
+ this.extractWeekDay("until.weekday.", "end");
+ this.extractDate("from.ordinal.date", "start");
+ this.extractDate("until.ordinal.date", "end");
+
+ this.extractDayMonth("from.month.day", "start");
+ this.extractDayMonthYear("from.year.month.day", "start");
+ this.extractDayMonth("until.month.day", "end");
+ this.extractDayMonthYear("until.year.month.day", "end");
+ this.extractDayMonthName("from.monthname.day", "start");
+ this.extractDayMonthNameYear("from.year.monthname.day", "start");
+ this.extractDayMonthName("until.monthname.day", "end");
+ this.extractDayMonthNameYear("until.year.monthname.day", "end");
+
+ // duration
+ this.extractDuration("duration.minutes", 1);
+ this.extractDuration("duration.hours", 60);
+ this.extractDuration("duration.days", 60 * 24);
+
+ if (sel !== undefined && sel !== null) {
+ this.markSelected(sel, title);
+ }
+ this.markContained();
+ this.collected = this.collected.sort(this.sort);
+
+ return this.collected;
+ },
+
+ extractDayMonthYear(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})", "(\\d{2,4})"]);
+
+ let res;
+ for (let alt in alts) {
+ let positions = alts[alt].positions;
+ let re = new RegExp(alts[alt].pattern, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = parseInt(res[positions[2]], 10);
+ let year = parseInt(this.normalizeYear(res[positions[3]]), 10);
+
+ if (this.isValidDay(day) && this.isValidMonth(month) && this.isValidYear(year)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ year,
+ month,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractDayMonthNameYear(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, [
+ "(\\d{1,2})",
+ "(" + this.allMonths + ")",
+ "(\\d{2,4})",
+ ]);
+
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let positions = alts[alt].positions;
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = res[positions[2]];
+ let year = parseInt(this.normalizeYear(res[positions[3]]), 10);
+
+ if (this.isValidDay(day)) {
+ for (let i = 0; i < 12; i++) {
+ if (this.months[i].split("|").includes(month.toLowerCase())) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ year,
+ i + 1,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ extractRelativeDay(pattern, relation, offset) {
+ let re = new RegExp(this.getPatterns(pattern), "ig");
+ let res;
+ if ((res = re.exec(this.email)) != null) {
+ if (!this.limitChars(res, this.email)) {
+ let item = new Date(this.now.getTime() + 60 * 60 * 24 * 1000 * offset);
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ item.getFullYear(),
+ item.getMonth() + 1,
+ item.getDate(),
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ },
+
+ extractDayMonthName(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, [
+ "(\\d{1,2}" + this.marker + this.dailyNumbers + ")",
+ "(" + this.allMonths + ")",
+ ]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let positions = alts[alt].positions;
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = this.parseNumber(res[positions[1]], this.numbers);
+ let month = res[positions[2]];
+
+ if (this.isValidDay(day)) {
+ for (let i = 0; i < 12; i++) {
+ let months = this.unescape(this.months[i]).split("|");
+ if (months.includes(month.toLowerCase())) {
+ let date = { year: this.now.getFullYear(), month: i + 1, day };
+ if (this.isPastDate(date, this.now)) {
+ // find next such date
+ let item = new Date(this.now.getTime());
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getMonth() == date.month - 1 && item.getDate() == date.day) {
+ date.year = item.getFullYear();
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.year,
+ date.month,
+ date.day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ extractDayMonth(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})"]);
+ let res;
+ for (let alt in alts) {
+ let re = new RegExp(alts[alt].pattern, "ig");
+ let positions = alts[alt].positions;
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = parseInt(res[positions[2]], 10);
+
+ if (this.isValidMonth(month) && this.isValidDay(day)) {
+ let date = { year: this.now.getFullYear(), month, day };
+
+ if (this.isPastDate(date, this.now)) {
+ // find next such date
+ let item = new Date(this.now.getTime());
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getMonth() == date.month - 1 && item.getDate() == date.day) {
+ date.year = item.getFullYear();
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.year,
+ date.month,
+ date.day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractDate(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = this.parseNumber(res[1], this.numbers);
+ if (this.isValidDay(day)) {
+ let item = new Date(this.now.getTime());
+ if (this.now.getDate() != day) {
+ // find next nth date
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getDate() == day) {
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ item.getFullYear(),
+ item.getMonth() + 1,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractWeekDay(pattern, relation) {
+ let days = [];
+ for (let i = 0; i < 7; i++) {
+ days[i] = this.getPatterns(pattern + i);
+ let re = new RegExp(days[i], "ig");
+ let res = re.exec(this.email);
+ if (res) {
+ if (!this.limitChars(res, this.email)) {
+ let date = new Date();
+ date.setDate(this.now.getDate());
+ date.setMonth(this.now.getMonth());
+ date.setYear(this.now.getFullYear());
+
+ let diff = (i - date.getDay() + 7) % 7;
+ date.setDate(date.getDate() + diff);
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.getFullYear(),
+ date.getMonth() + 1,
+ date.getDate(),
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern + i,
+ true
+ );
+ }
+ }
+ }
+ },
+
+ extractHour(pattern, relation, meridiem) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = this.parseNumber(res[1], this.numbers);
+
+ if (meridiem == "ante" && hour == 12) {
+ hour = hour - 12;
+ } else if (meridiem == "post" && hour != 12) {
+ hour = hour + 12;
+ } else {
+ hour = this.normalizeHour(hour);
+ }
+
+ if (this.isValidHour(res[1])) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ 0,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractHalfHour(pattern, relation, direction) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = this.parseNumber(res[1], this.numbers);
+
+ hour = this.normalizeHour(hour);
+ if (direction == "ante") {
+ if (hour == 1) {
+ hour = 12;
+ } else {
+ hour = hour - 1;
+ }
+ }
+
+ if (this.isValidHour(hour)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ 30,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractHourMinutes(pattern, relation, meridiem) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{2})"]);
+ let res;
+ for (let alt in alts) {
+ let positions = alts[alt].positions;
+ let re = new RegExp(alts[alt].pattern, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = parseInt(res[positions[1]], 10);
+ let minute = parseInt(res[positions[2]], 10);
+
+ if (meridiem == "ante" && hour == 12) {
+ hour = hour - 12;
+ } else if (meridiem == "post" && hour != 12) {
+ hour = hour + 12;
+ } else {
+ hour = this.normalizeHour(hour);
+ }
+
+ if (this.isValidHour(hour) && this.isValidMinute(hour)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ minute,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractTime(pattern, relation, hour, minute) {
+ let re = new RegExp(this.getPatterns(pattern), "ig");
+ let res;
+ if ((res = re.exec(this.email)) != null) {
+ if (!this.limitChars(res, this.email)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ minute,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ },
+
+ extractDuration(pattern, unit) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let length = this.parseNumber(res[1], this.numbers);
+ let guess = {};
+ let rev = this.prefixSuffixStartEnd(res, "duration", this.email);
+ guess.duration = length * unit;
+ guess.start = rev.start;
+ guess.end = rev.end;
+ guess.str = rev.pattern;
+ guess.relation = rev.relation;
+ guess.pattern = pattern;
+ this.collected.push(guess);
+ }
+ }
+ }
+ },
+
+ markContained() {
+ for (let outer = 0; outer < this.collected.length; outer++) {
+ for (let inner = 0; inner < this.collected.length; inner++) {
+ // included but not exactly the same
+ if (
+ outer != inner &&
+ this.collected[outer].start &&
+ this.collected[outer].end &&
+ this.collected[inner].start &&
+ this.collected[inner].end &&
+ this.collected[inner].start >= this.collected[outer].start &&
+ this.collected[inner].end <= this.collected[outer].end &&
+ !(
+ this.collected[inner].start == this.collected[outer].start &&
+ this.collected[inner].end == this.collected[outer].end
+ )
+ ) {
+ cal.LOG(
+ "[calExtract] " +
+ this.collected[outer].str +
+ " found as well, disgarding " +
+ this.collected[inner].str
+ );
+ this.collected[inner].relation = "notadatetime";
+ }
+ }
+ }
+ },
+
+ markSelected(sel, title) {
+ if (sel.rangeCount > 0) {
+ // mark the ones to not use
+ for (let i = 0; i < sel.rangeCount; i++) {
+ cal.LOG("[calExtract] Selection " + i + " is " + sel);
+ for (let j = 0; j < this.collected.length; j++) {
+ let selection = sel.getRangeAt(i).toString();
+
+ if (
+ !selection.includes(this.collected[j].str) &&
+ !title.includes(this.collected[j].str) &&
+ this.collected[j].start != null
+ ) {
+ // always keep email date, needed for tasks
+ cal.LOG(
+ "[calExtract] Marking " + JSON.stringify(this.collected[j]) + " as notadatetime"
+ );
+ this.collected[j].relation = "notadatetime";
+ }
+ }
+ }
+ }
+ },
+
+ sort(one, two) {
+ let rc;
+ // sort the guess from email date as the last one
+ if (one.start == null && two.start != null) {
+ return 1;
+ } else if (one.start != null && two.start == null) {
+ return -1;
+ } else if (one.start == null && two.start == null) {
+ return 0;
+ // sort dates before times
+ } else if (one.year != null && two.year == null) {
+ return -1;
+ } else if (one.year == null && two.year != null) {
+ return 1;
+ } else if (one.year != null && two.year != null) {
+ rc = (one.year > two.year) - (one.year < two.year);
+ if (rc == 0) {
+ rc = (one.month > two.month) - (one.month < two.month);
+ if (rc == 0) {
+ rc = (one.day > two.day) - (one.day < two.day);
+ }
+ }
+ return rc;
+ }
+ rc = (one.hour > two.hour) - (one.hour < two.hour);
+ if (rc == 0) {
+ rc = (one.minute > two.minute) - (one.minute < two.minute);
+ }
+ return rc;
+ },
+
+ /**
+ * Guesses start time from list of guessed datetimes
+ *
+ * @param isTask whether start time should be guessed for task or event
+ * @returns datetime object for start time
+ */
+ guessStart(isTask) {
+ let startTimes = this.collected.filter(val => val.relation == "start");
+ if (startTimes.length == 0) {
+ return {};
+ }
+
+ for (let val in startTimes) {
+ cal.LOG("[calExtract] Start: " + JSON.stringify(startTimes[val]));
+ }
+
+ let guess = {};
+ let wDayInit = startTimes.filter(val => val.day != null && val.start === undefined);
+
+ // with tasks we don't try to guess start but assume email date
+ if (isTask) {
+ guess.year = wDayInit[0].year;
+ guess.month = wDayInit[0].month;
+ guess.day = wDayInit[0].day;
+ guess.hour = wDayInit[0].hour;
+ guess.minute = wDayInit[0].minute;
+ return guess;
+ }
+
+ let wDay = startTimes.filter(val => val.day != null && val.start !== undefined);
+ let wDayNA = wDay.filter(val => val.ambiguous === undefined);
+
+ let wMinute = startTimes.filter(val => val.minute != null && val.start !== undefined);
+ let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined);
+
+ if (wMinuteNA.length != 0) {
+ guess.hour = wMinuteNA[0].hour;
+ guess.minute = wMinuteNA[0].minute;
+ } else if (wMinute.length != 0) {
+ guess.hour = wMinute[0].hour;
+ guess.minute = wMinute[0].minute;
+ }
+
+ // first use unambiguous guesses
+ if (wDayNA.length != 0) {
+ guess.year = wDayNA[0].year;
+ guess.month = wDayNA[0].month;
+ guess.day = wDayNA[0].day;
+ // then also ambiguous ones
+ } else if (wDay.length != 0) {
+ guess.year = wDay[0].year;
+ guess.month = wDay[0].month;
+ guess.day = wDay[0].day;
+ // next possible day considering time
+ } else if (
+ guess.hour != null &&
+ (wDayInit[0].hour > guess.hour ||
+ (wDayInit[0].hour == guess.hour && wDayInit[0].minute > guess.minute))
+ ) {
+ let nextDay = new Date(wDayInit[0].year, wDayInit[0].month - 1, wDayInit[0].day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ // and finally when nothing was found then use initial guess from send time
+ } else {
+ guess.year = wDayInit[0].year;
+ guess.month = wDayInit[0].month;
+ guess.day = wDayInit[0].day;
+ }
+
+ cal.LOG("[calExtract] Start picked: " + JSON.stringify(guess));
+ return guess;
+ },
+
+ /**
+ * Guesses end time from list of guessed datetimes relative to start time
+ *
+ * @param start start time to consider when guessing
+ * @param doGuessStart whether start time should be guessed for task or event
+ * @returns datetime object for end time
+ */
+ guessEnd(start, doGuessStart) {
+ let guess = {};
+ let endTimes = this.collected.filter(val => val.relation == "end");
+ let durations = this.collected.filter(val => val.relation == "duration");
+ if (endTimes.length == 0 && durations.length == 0) {
+ return {};
+ }
+ for (let val in endTimes) {
+ cal.LOG("[calExtract] End: " + JSON.stringify(endTimes[val]));
+ }
+
+ let wDay = endTimes.filter(val => val.day != null);
+ let wDayNA = wDay.filter(val => val.ambiguous === undefined);
+ let wMinute = endTimes.filter(val => val.minute != null);
+ let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined);
+
+ // first set non-ambiguous dates
+ let pos = doGuessStart ? 0 : wDayNA.length - 1;
+ if (wDayNA.length != 0) {
+ guess.year = wDayNA[pos].year;
+ guess.month = wDayNA[pos].month;
+ guess.day = wDayNA[pos].day;
+ // then ambiguous dates
+ } else if (wDay.length != 0) {
+ pos = doGuessStart ? 0 : wDay.length - 1;
+ guess.year = wDay[pos].year;
+ guess.month = wDay[pos].month;
+ guess.day = wDay[pos].day;
+ }
+
+ // then non-ambiguous times
+ if (wMinuteNA.length != 0) {
+ pos = doGuessStart ? 0 : wMinuteNA.length - 1;
+ guess.hour = wMinuteNA[pos].hour;
+ guess.minute = wMinuteNA[pos].minute;
+ if (guess.day == null || guess.day == start.day) {
+ if (
+ wMinuteNA[pos].hour < start.hour ||
+ (wMinuteNA[pos].hour == start.hour && wMinuteNA[pos].minute < start.minute)
+ ) {
+ let nextDay = new Date(start.year, start.month - 1, start.day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ }
+ }
+ // and ambiguous times
+ } else if (wMinute.length != 0) {
+ pos = doGuessStart ? 0 : wMinute.length - 1;
+ guess.hour = wMinute[pos].hour;
+ guess.minute = wMinute[pos].minute;
+ if (guess.day == null || guess.day == start.day) {
+ if (
+ wMinute[pos].hour < start.hour ||
+ (wMinute[pos].hour == start.hour && wMinute[pos].minute < start.minute)
+ ) {
+ let nextDay = new Date(start.year, start.month - 1, start.day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ }
+ }
+ }
+
+ // fill in date when time was guessed
+ if (guess.minute != null && guess.day == null) {
+ guess.year = start.year;
+ guess.month = start.month;
+ guess.day = start.day;
+ }
+
+ // fill in end from total duration
+ if (guess.day == null && guess.hour == null) {
+ let duration = 0;
+
+ for (let val in durations) {
+ duration += durations[val].duration;
+ cal.LOG("[calExtract] Dur: " + JSON.stringify(durations[val]));
+ }
+
+ if (duration != 0) {
+ let startDate = new Date(start.year, start.month - 1, start.day);
+ if ("hour" in start) {
+ startDate.setHours(start.hour);
+ startDate.setMinutes(start.minute);
+ } else {
+ startDate.setHours(0);
+ startDate.setMinutes(0);
+ }
+
+ let endTime = new Date(startDate.getTime() + duration * 60 * 1000);
+ guess.year = endTime.getFullYear();
+ guess.month = endTime.getMonth() + 1;
+ guess.day = endTime.getDate();
+ if (!(endTime.getHours() == 0 && endTime.getMinutes() == 0)) {
+ guess.hour = endTime.getHours();
+ guess.minute = endTime.getMinutes();
+ }
+ }
+ }
+
+ // no zero or negative length events/tasks
+ let startTime = new Date(
+ start.year || 0,
+ start.month - 1 || 0,
+ start.day || 0,
+ start.hour || 0,
+ start.minute || 0
+ ).getTime();
+ let guessTime = new Date(
+ guess.year || 0,
+ guess.month - 1 || 0,
+ guess.day || 0,
+ guess.hour || 0,
+ guess.minute || 0
+ ).getTime();
+ if (guessTime <= startTime) {
+ guess.year = null;
+ guess.month = null;
+ guess.day = null;
+ guess.hour = null;
+ guess.minute = null;
+ }
+
+ if (guess.year != null && guess.minute == null && doGuessStart) {
+ guess.hour = 0;
+ guess.minute = 0;
+ }
+
+ cal.LOG("[calExtract] End picked: " + JSON.stringify(guess));
+ return guess;
+ },
+
+ getPatterns(name) {
+ let value;
+ try {
+ value = this.bundle.GetStringFromName(name);
+ if (value.trim() == "") {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+ return this.defPattern;
+ }
+
+ let vals = this.cleanPatterns(value).split("|");
+ for (let idx = vals.length - 1; idx >= 0; idx--) {
+ if (vals[idx].trim() == "") {
+ vals.splice(idx, 1);
+ console.error("[calExtract] Faulty extraction pattern " + value + " for " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].add !== undefined) {
+ let additions = this.overrides[name].add;
+ additions = this.cleanPatterns(additions).split("|");
+ for (let pattern in additions) {
+ vals.push(additions[pattern]);
+ cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].remove !== undefined) {
+ let removals = this.overrides[name].remove;
+ removals = this.cleanPatterns(removals).split("|");
+ for (let pattern in removals) {
+ let idx = vals.indexOf(removals[pattern]);
+ if (idx != -1) {
+ vals.splice(idx, 1);
+ cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name);
+ }
+ }
+ }
+
+ vals.sort((a, b) => b.length - a.length);
+ return vals.join("|");
+ } catch (ex) {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+
+ // fake a value to avoid empty regexes creating endless loops
+ return this.defPattern;
+ }
+ },
+
+ getRepPatterns(name, replaceables) {
+ let alts = [];
+ let patterns = [];
+
+ try {
+ let value = this.bundle.GetStringFromName(name);
+ if (value.trim() == "") {
+ cal.LOG("[calExtract] Pattern empty: " + name);
+ return alts;
+ }
+
+ let vals = this.cleanPatterns(value).split("|");
+ for (let idx = vals.length - 1; idx >= 0; idx--) {
+ if (vals[idx].trim() == "") {
+ vals.splice(idx, 1);
+ console.error("[calExtract] Faulty extraction pattern " + value + " for " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].add !== undefined) {
+ let additions = this.overrides[name].add;
+ additions = this.cleanPatterns(additions).split("|");
+ for (let pattern in additions) {
+ vals.push(additions[pattern]);
+ cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].remove !== undefined) {
+ let removals = this.overrides[name].remove;
+ removals = this.cleanPatterns(removals).split("|");
+ for (let pattern in removals) {
+ let idx = vals.indexOf(removals[pattern]);
+ if (idx != -1) {
+ vals.splice(idx, 1);
+ cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name);
+ }
+ }
+ }
+
+ vals.sort((a, b) => b.length - a.length);
+ for (let val in vals) {
+ let pattern = vals[val];
+ for (let cnt = 1; cnt <= replaceables.length; cnt++) {
+ pattern = pattern.split("#" + cnt).join(replaceables[cnt - 1]);
+ }
+ patterns.push(pattern);
+ }
+
+ for (let val in vals) {
+ let positions = [];
+ if (replaceables.length == 1) {
+ positions[1] = 1;
+ } else {
+ positions = this.getPositionsFor(vals[val], name, replaceables.length);
+ }
+ alts[val] = { pattern: patterns[val], positions };
+ }
+ } catch (ex) {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+ }
+ return alts;
+ },
+
+ getPositionsFor(str, name, count) {
+ let positions = [];
+ let re = /#(\d)/g;
+ let match;
+ let i = 0;
+ while ((match = re.exec(str))) {
+ i++;
+ positions[parseInt(match[1], 10)] = i;
+ }
+
+ // correctness checking
+ for (i = 1; i <= count; i++) {
+ if (positions[i] === undefined) {
+ console.error(
+ "[calExtract] Faulty extraction pattern " + name + ", missing parameter #" + i
+ );
+ }
+ }
+ return positions;
+ },
+
+ cleanPatterns(pattern) {
+ // remove whitespace around | if present
+ let value = pattern.replace(/\s*\|\s*/g, "|");
+ // allow matching for patterns with missing or excessive whitespace
+ return this.sanitize(value).replace(/\s+/g, "\\s*");
+ },
+
+ isValidYear(year) {
+ return year >= 2000 && year <= 2050;
+ },
+
+ isValidMonth(month) {
+ return month >= 1 && month <= 12;
+ },
+
+ isValidDay(day) {
+ return day >= 1 && day <= 31;
+ },
+
+ isValidHour(hour) {
+ return hour >= 0 && hour <= 23;
+ },
+
+ isValidMinute(minute) {
+ return minute >= 0 && minute <= 59;
+ },
+
+ isPastDate(date, referenceDate) {
+ // avoid changing original refDate
+ let refDate = new Date(referenceDate.getTime());
+ refDate.setHours(0);
+ refDate.setMinutes(0);
+ refDate.setSeconds(0);
+ refDate.setMilliseconds(0);
+ let jsDate;
+ if (date.day != null) {
+ jsDate = new Date(date.year, date.month - 1, date.day);
+ }
+ return jsDate < refDate;
+ },
+
+ normalizeHour(hour) {
+ if (hour < this.dayStart && hour <= 11) {
+ return hour + 12;
+ }
+ return hour;
+ },
+
+ normalizeYear(year) {
+ return year.length == 2 ? "20" + year : year;
+ },
+
+ limitNums(res, email) {
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let before = email.charAt(res.index - 1);
+ let after = email.charAt(res.index + res[0].length);
+ let result =
+ (/\d/.exec(before) && /\d/.exec(pattern.charAt(0))) ||
+ (/\d/.exec(pattern.charAt(pattern.length - 1)) && /\d/.exec(after));
+ return result != null;
+ },
+
+ limitChars(res, email) {
+ let alphabet = this.getPatterns("alphabet");
+ // for languages without regular alphabet surrounding characters are ignored
+ if (alphabet == this.defPattern) {
+ return false;
+ }
+
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let before = email.charAt(res.index - 1);
+ let after = email.charAt(res.index + res[0].length);
+
+ let re = new RegExp("[" + alphabet + "]");
+ let result =
+ (re.exec(before) && re.exec(pattern.charAt(0))) ||
+ (re.exec(pattern.charAt(pattern.length - 1)) && re.exec(after));
+ return result != null;
+ },
+
+ prefixSuffixStartEnd(res, relation, email) {
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let prev = email.substring(0, res.index);
+ let next = email.substring(res.index + res[0].length);
+ let prefixSuffix = {
+ start: res.index,
+ end: res.index + res[0].length,
+ pattern,
+ relation,
+ };
+ let char = "\\s*";
+ let psres;
+
+ let re = new RegExp("(" + this.getPatterns("end.prefix") + ")" + char + "$", "ig");
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "end";
+ prefixSuffix.start = psres.index;
+ prefixSuffix.pattern = psres[0] + pattern;
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("end.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "end";
+ prefixSuffix.end = prefixSuffix.end + psres[0].length;
+ prefixSuffix.pattern = pattern + psres[0];
+ }
+
+ re = new RegExp("(" + this.getPatterns("start.prefix") + ")" + char + "$", "ig");
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "start";
+ prefixSuffix.start = psres.index;
+ prefixSuffix.pattern = psres[0] + pattern;
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("start.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "start";
+ prefixSuffix.end = prefixSuffix.end + psres[0].length;
+ prefixSuffix.pattern = pattern + psres[0];
+ }
+
+ re = new RegExp("\\s(" + this.getPatterns("no.datetime.prefix") + ")" + char + "$", "ig");
+
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "notadatetime";
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("no.datetime.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "notadatetime";
+ }
+
+ return prefixSuffix;
+ },
+
+ parseNumber(numberString, numbers) {
+ let number = parseInt(numberString, 10);
+ // number comes in as plain text, numbers are already adjusted for usage
+ // in regular expression
+ let cleanNumberString = this.cleanPatterns(numberString);
+ if (isNaN(number)) {
+ for (let i = 0; i <= 31; i++) {
+ let numberparts = numbers[i].split("|");
+ if (numberparts.includes(cleanNumberString.toLowerCase())) {
+ return i;
+ }
+ }
+ return -1;
+ }
+ return number;
+ },
+
+ guess(year, month, day, hour, minute, start, end, str, relation, pattern, ambiguous) {
+ let dateGuess = {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ start,
+ end,
+ str,
+ relation,
+ pattern,
+ ambiguous,
+ };
+
+ // past dates are kept for containment checks
+ if (this.isPastDate(dateGuess, this.now)) {
+ dateGuess.relation = "notadatetime";
+ }
+ this.collected.push(dateGuess);
+ },
+
+ sanitize(str) {
+ return str.replace(/[-[\]{}()*+?.,\\^$]/g, "\\$&");
+ },
+
+ unescape(str) {
+ return str.replace(/\\([.])/g, "$1");
+ },
+};
diff --git a/comm/calendar/base/modules/calHashedArray.jsm b/comm/calendar/base/modules/calHashedArray.jsm
new file mode 100644
index 0000000000..1094abb765
--- /dev/null
+++ b/comm/calendar/base/modules/calHashedArray.jsm
@@ -0,0 +1,258 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this
+
+/**
+ * An unsorted array of hashable items with some extra functions to quickly
+ * retrieve the item by its hash id.
+ *
+ * Performance Considerations:
+ * - Accessing items is fast
+ * - Adding items is fast (they are added to the end)
+ * - Deleting items is O(n)
+ * - Modifying items is fast.
+ */
+cal.HashedArray = function () {
+ this.clear();
+};
+
+cal.HashedArray.prototype = {
+ mArray: null,
+ mHash: null,
+
+ mBatch: 0,
+ mFirstDirty: -1,
+
+ /**
+ * Returns a copy of the internal array. Note this is a shallow copy.
+ */
+ get arrayCopy() {
+ return this.mArray.concat([]);
+ },
+
+ /**
+ * The function to retrieve the hashId given the item. This function can be
+ * overridden by implementations, in case the added items are not instances
+ * of calIItemBase.
+ *
+ * @param item The item to get the hashId for
+ * @returns The hashId of the item
+ */
+ hashAccessor(item) {
+ return item.hashId;
+ },
+
+ /**
+ * Returns the item, given its index in the array
+ *
+ * @param index The index of the item to retrieve.
+ * @returns The retrieved item.
+ */
+ itemByIndex(index) {
+ return this.mArray[index];
+ },
+
+ /**
+ * Returns the item, given its hashId
+ *
+ * @param id The hashId of the item to retrieve.
+ * @returns The retrieved item.
+ */
+ itemById(id) {
+ if (this.mBatch > 0) {
+ throw new Error("Accessing Array by ID not supported in batch mode");
+ }
+ return id in this.mHash ? this.mArray[this.mHash[id]] : null;
+ },
+
+ /**
+ * Returns the index of the given item. This function is cheap performance
+ * wise, since it uses the hash
+ *
+ * @param item The item to search for.
+ * @returns The index of the item.
+ */
+ indexOf(item) {
+ if (this.mBatch > 0) {
+ throw new Error("Accessing Array Indexes not supported in batch mode");
+ }
+ let hashId = this.hashAccessor(item);
+ return hashId in this.mHash ? this.mHash[hashId] : -1;
+ },
+
+ /**
+ * Remove the item with the given hashId.
+ *
+ * @param id The id of the item to be removed
+ */
+ removeById(id) {
+ if (this.mBatch > 0) {
+ throw new Error("Remvoing by ID in batch mode is not supported"); /* TODO */
+ }
+ let index = this.mHash[id];
+ delete this.mHash[id];
+ this.mArray.splice(index, 1);
+ this.reindex(index);
+ },
+
+ /**
+ * Remove the item at the given index.
+ *
+ * @param index The index of the item to remove.
+ */
+ removeByIndex(index) {
+ delete this.mHash[this.hashAccessor(this.mArray[index])];
+ this.mArray.splice(index, 1);
+ this.reindex(index);
+ },
+
+ /**
+ * Clear the whole array, removing all items. This also resets batch mode.
+ */
+ clear() {
+ this.mHash = {};
+ this.mArray = [];
+ this.mFirstDirty = -1;
+ this.mBatch = 0;
+ },
+
+ /**
+ * Add the item to the array
+ *
+ * @param item The item to add.
+ * @returns The index of the added item.
+ */
+ addItem(item) {
+ let index = this.mArray.length;
+ this.mArray.push(item);
+ this.reindex(index);
+ return index;
+ },
+
+ /**
+ * Modifies the item in the array. If the item is already in the array, then
+ * it is replaced by the passed item. Otherwise, the item is added to the
+ * array.
+ *
+ * @param item The item to modify.
+ * @returns The (new) index.
+ */
+ modifyItem(item) {
+ let hashId = this.hashAccessor(item);
+ if (hashId in this.mHash) {
+ let index = this.mHash[this.hashAccessor(item)];
+ this.mArray[index] = item;
+ return index;
+ }
+ return this.addItem(item);
+ },
+
+ /**
+ * Reindexes the items in the array. This function is mostly used
+ * internally. All parameters are inclusive. The ranges are automatically
+ * swapped if from > to.
+ *
+ * @param from (optional) The index to start indexing from. If left
+ * out, defaults to 0.
+ * @param to (optional) The index to end indexing on. If left out,
+ * defaults to the array length.
+ */
+ reindex(from, to) {
+ if (this.mArray.length == 0) {
+ return;
+ }
+
+ from = from === undefined ? 0 : from;
+ to = to === undefined ? this.mArray.length - 1 : to;
+
+ from = Math.min(this.mArray.length - 1, Math.max(0, from));
+ to = Math.min(this.mArray.length - 1, Math.max(0, to));
+
+ if (from > to) {
+ let tmp = from;
+ from = to;
+ to = tmp;
+ }
+
+ if (this.mBatch > 0) {
+ // No indexing in batch mode, but remember from where to index.
+ this.mFirstDirty = Math.min(Math.max(0, this.mFirstDirty), from);
+ return;
+ }
+
+ for (let idx = from; idx <= to; idx++) {
+ this.mHash[this.hashAccessor(this.mArray[idx])] = idx;
+ }
+ },
+
+ startBatch() {
+ this.mBatch++;
+ },
+
+ endBatch() {
+ this.mBatch = Math.max(0, this.mBatch - 1);
+
+ if (this.mBatch == 0 && this.mFirstDirty > -1) {
+ this.reindex(this.mFirstDirty);
+ this.mFirstDirty = -1;
+ }
+ },
+
+ /**
+ * Iterator to allow iterating the hashed array object.
+ */
+ *[Symbol.iterator]() {
+ yield* this.mArray;
+ },
+};
+
+/**
+ * Sorted hashed array. The array always stays sorted.
+ *
+ * Performance Considerations:
+ * - Accessing items is fast
+ * - Adding and deleting items is O(n)
+ * - Modifying items is fast.
+ */
+cal.SortedHashedArray = function (comparator) {
+ cal.HashedArray.apply(this, arguments);
+ if (!comparator) {
+ throw new Error("Sorted Hashed Array needs a comparator");
+ }
+ this.mCompFunc = comparator;
+};
+
+cal.SortedHashedArray.prototype = {
+ __proto__: cal.HashedArray.prototype,
+
+ mCompFunc: null,
+
+ addItem(item) {
+ let newIndex = cal.data.binaryInsert(this.mArray, item, this.mCompFunc, false);
+ this.reindex(newIndex);
+ return newIndex;
+ },
+
+ modifyItem(item) {
+ let hashId = this.hashAccessor(item);
+ if (hashId in this.mHash) {
+ let cmp = this.mCompFunc(item, this.mArray[this.mHash[hashId]]);
+ if (cmp == 0) {
+ // The item will be at the same index, we just need to replace it
+ this.mArray[this.mHash[hashId]] = item;
+ return this.mHash[hashId];
+ }
+ let oldIndex = this.mHash[hashId];
+
+ let newIndex = cal.data.binaryInsert(this.mArray, item, this.mCompFunc, false);
+ this.mArray.splice(oldIndex, 1);
+ this.reindex(oldIndex, newIndex);
+ return newIndex;
+ }
+ return this.addItem(item);
+ },
+};
diff --git a/comm/calendar/base/modules/calRecurrenceUtils.jsm b/comm/calendar/base/modules/calRecurrenceUtils.jsm
new file mode 100644
index 0000000000..125f429801
--- /dev/null
+++ b/comm/calendar/base/modules/calRecurrenceUtils.jsm
@@ -0,0 +1,553 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported recurrenceStringFromItem, recurrenceRule2String, splitRecurrenceRules,
+ * checkRecurrenceRule, countOccurrences
+ */
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalRecurrenceDate: "resource:///modules/CalRecurrenceDate.jsm",
+ CalRecurrenceRule: "resource:///modules/CalRecurrenceRule.jsm",
+});
+
+const EXPORTED_SYMBOLS = [
+ "recurrenceStringFromItem",
+ "recurrenceRule2String",
+ "splitRecurrenceRules",
+ "checkRecurrenceRule",
+ "countOccurrences",
+ "hasUnsupported",
+];
+
+/**
+ * Given a calendar event or task, return a string that describes the item's
+ * recurrence pattern. When the recurrence pattern is too complex, return a
+ * "too complex" string by getting that string using the arguments provided.
+ *
+ * @param {calIEvent | calITodo} item A calendar item.
+ * @param {string} bundleName - Name of the properties file, e.g. "calendar-event-dialog".
+ * @param {string} stringName - Name of the string within the properties file.
+ * @param {string[]} [params] - (optional) Parameters to format the string.
+ * @returns {string | null} A string describing the recurrence
+ * pattern or null if the item has no
+ * recurrence info.
+ */
+function recurrenceStringFromItem(item, bundleName, stringName, params) {
+ // See the `parentItem` property of `calIItemBase`.
+ let parent = item.parentItem;
+
+ let recurrenceInfo = parent.recurrenceInfo;
+ if (!recurrenceInfo) {
+ return null;
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let rawStartDate = parent.startDate || parent.entryDate;
+ let rawEndDate = parent.endDate || parent.dueDate;
+
+ let startDate = rawStartDate ? rawStartDate.getInTimezone(kDefaultTimezone) : null;
+ let endDate = rawEndDate ? rawEndDate.getInTimezone(kDefaultTimezone) : null;
+
+ return (
+ recurrenceRule2String(recurrenceInfo, startDate, endDate, startDate.isDate) ||
+ cal.l10n.getString(bundleName, stringName, params)
+ );
+}
+
+/**
+ * This function takes the recurrence info passed as argument and creates a
+ * literal string representing the repeat pattern in natural language.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @param startDate The start date to base rules on.
+ * @param endDate The end date to base rules on.
+ * @param allDay If true, the pattern should assume an allday item.
+ * @returns A human readable string describing the recurrence.
+ */
+function recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay) {
+ function getRString(name, args) {
+ return cal.l10n.getString("calendar-event-dialog", name, args);
+ }
+ function day_of_week(day) {
+ return Math.abs(day) % 8;
+ }
+ function day_position(day) {
+ return ((Math.abs(day) - day_of_week(day)) / 8) * (day < 0 ? -1 : 1);
+ }
+ function nounClass(aDayString, aRuleString) {
+ // Select noun class (grammatical gender) for rule string
+ let nounClassStr = getRString(aDayString + "Nounclass");
+ return aRuleString + nounClassStr.substr(0, 1).toUpperCase() + nounClassStr.substr(1);
+ }
+ function pluralWeekday(aDayString) {
+ let plural = getRString("pluralForWeekdays") == "true";
+ return plural ? aDayString + "Plural" : aDayString;
+ }
+ function everyWeekDay(aByDay) {
+ // Checks if aByDay contains only values from 1 to 7 with any order.
+ let mask = aByDay.reduce((value, item) => value | (1 << item), 1);
+ return aByDay.length == 7 && mask == Math.pow(2, 8) - 1;
+ }
+
+ // Retrieve a valid recurrence rule from the currently
+ // set recurrence info. Bail out if there's more
+ // than a single rule or something other than a rule.
+ recurrenceInfo = recurrenceInfo.clone();
+ if (hasUnsupported(recurrenceInfo)) {
+ return null;
+ }
+
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ if (rrules[0].length == 1) {
+ let rule = cal.wrapInstance(rrules[0][0], Ci.calIRecurrenceRule);
+ // Currently we allow only for BYDAY, BYMONTHDAY, BYMONTH rules.
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ /* "BYDAY", */
+ "BYHOUR",
+ /* "BYMONTHDAY", */
+ "BYYEARDAY",
+ "BYWEEKNO",
+ /* "BYMONTH", */
+ "BYSETPOS",
+ ];
+
+ if (rule && !checkRecurrenceRule(rule, byparts)) {
+ let dateFormatter = cal.dtz.formatter;
+ let ruleString;
+ if (rule.type == "DAILY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let days = rule.getComponent("BYDAY");
+ let weekdays = [2, 3, 4, 5, 6];
+ if (weekdays.length == days.length) {
+ let i;
+ for (i = 0; i < weekdays.length; i++) {
+ if (weekdays[i] != days[i]) {
+ break;
+ }
+ }
+ if (i == weekdays.length) {
+ ruleString = getRString("repeatDetailsRuleDaily4");
+ }
+ } else {
+ return null;
+ }
+ } else {
+ let dailyString = getRString("dailyEveryNth");
+ ruleString = PluralForm.get(rule.interval, dailyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "WEEKLY") {
+ // weekly recurrence, currently we
+ // support a single 'BYDAY'-rule only.
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ // create a string like 'Monday, Tuesday and Wednesday'
+ let days = rule.getComponent("BYDAY");
+ let weekdays = "";
+ // select noun class (grammatical gender) according to the
+ // first day of the list
+ let weeklyString = nounClass("repeatDetailsDay" + days[0], "weeklyNthOn");
+ for (let i = 0; i < days.length; i++) {
+ if (rule.interval == 1) {
+ weekdays += getRString(pluralWeekday("repeatDetailsDay" + days[i]));
+ } else {
+ weekdays += getRString("repeatDetailsDay" + days[i]);
+ }
+ if (days.length > 1 && i == days.length - 2) {
+ weekdays += " " + getRString("repeatDetailsAnd") + " ";
+ } else if (i < days.length - 1) {
+ weekdays += ", ";
+ }
+ }
+
+ weeklyString = getRString(weeklyString, [weekdays]);
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#2", rule.interval);
+ } else {
+ let weeklyString = getRString("weeklyEveryNth");
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "MONTHLY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let byday = rule.getComponent("BYDAY");
+ if (everyWeekDay(byday)) {
+ // Rule every day of the month.
+ ruleString = getRString("monthlyEveryDayOfNth");
+ ruleString = PluralForm.get(rule.interval, ruleString).replace("#2", rule.interval);
+ } else {
+ // For rules with generic number of weekdays with and
+ // without "position" prefix we build two separate
+ // strings depending on the position and then join them.
+ // Notice: we build the description string but currently
+ // the UI can manage only rules with only one weekday.
+ let weekdaysString_every = "";
+ let weekdaysString_position = "";
+ let firstDay = byday[0];
+ for (let i = 0; i < byday.length; i++) {
+ if (day_position(byday[i]) == 0) {
+ if (!weekdaysString_every) {
+ firstDay = byday[i];
+ }
+ weekdaysString_every +=
+ getRString(pluralWeekday("repeatDetailsDay" + byday[i])) + ", ";
+ } else {
+ if (day_position(byday[i]) < -1 || day_position(byday[i]) > 5) {
+ // We support only weekdays with -1 as negative
+ // position ('THE LAST ...').
+ return null;
+ }
+
+ let duplicateWeekday = byday.some(element => {
+ return (
+ day_position(element) == 0 && day_of_week(byday[i]) == day_of_week(element)
+ );
+ });
+ if (duplicateWeekday) {
+ // Prevent to build strings such as for example:
+ // "every Monday and the second Monday...".
+ continue;
+ }
+
+ let ordinalString = "repeatOrdinal" + day_position(byday[i]);
+ let dayString = "repeatDetailsDay" + day_of_week(byday[i]);
+ ordinalString = nounClass(dayString, ordinalString);
+ ordinalString = getRString(ordinalString);
+ dayString = getRString(dayString);
+ let stringOrdinalWeekday = getRString("ordinalWeekdayOrder", [
+ ordinalString,
+ dayString,
+ ]);
+ weekdaysString_position += stringOrdinalWeekday + ", ";
+ }
+ }
+ let weekdaysString = weekdaysString_every + weekdaysString_position;
+ weekdaysString = weekdaysString
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ let monthlyString = weekdaysString_every
+ ? "monthlyEveryOfEvery"
+ : "monthlyRuleNthOfEvery";
+ monthlyString = nounClass("repeatDetailsDay" + day_of_week(firstDay), monthlyString);
+ monthlyString = getRString(monthlyString, [weekdaysString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ let component = rule.getComponent("BYMONTHDAY");
+
+ // First, find out if the 'BYMONTHDAY' component contains
+ // any elements with a negative value lesser than -1 ("the
+ // last day"). If so we currently don't support any rule
+ if (component.some(element => element < -1)) {
+ // we don't support any other combination for now...
+ return getRString("ruleTooComplex");
+ } else if (component.length == 1 && component[0] == -1) {
+ // i.e. one day, the last day of the month
+ let monthlyString = getRString("monthlyLastDayOfNth");
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#1", rule.interval);
+ } else {
+ // i.e. one or more monthdays every N months.
+
+ // Build a string with a list of days separated with commas.
+ let day_string = "";
+ let lastDay = false;
+ for (let i = 0; i < component.length; i++) {
+ if (component[i] == -1) {
+ lastDay = true;
+ continue;
+ }
+ day_string += dateFormatter.formatDayWithOrdinal(component[i]) + ", ";
+ }
+ if (lastDay) {
+ day_string += getRString("monthlyLastDay") + ", ";
+ }
+ day_string = day_string
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ // Add the word "day" in plural form to the list of days then
+ // compose the final string with the interval of months
+ let monthlyDayString = getRString("monthlyDaysOfNth_day", [day_string]);
+ monthlyDayString = PluralForm.get(component.length, monthlyDayString);
+ let monthlyString = getRString("monthlyDaysOfNth", [monthlyDayString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else {
+ let monthlyString = getRString("monthlyDaysOfNth", [startDate.day]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (rule.type == "YEARLY") {
+ let bymonthday = null;
+ let bymonth = null;
+ if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ bymonthday = rule.getComponent("BYMONTHDAY");
+ }
+ if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ bymonth = rule.getComponent("BYMONTH");
+ }
+ if (
+ (bymonth && bymonth.length > 1) ||
+ (bymonthday && (bymonthday.length > 1 || bymonthday[0] < -1))
+ ) {
+ // Don't build a string for a recurrence rule that the UI
+ // currently can't show completely (with more than one month
+ // or than one monthday, or bymonthdays lesser than -1).
+ return getRString("ruleTooComplex");
+ }
+
+ if (
+ checkRecurrenceRule(rule, ["BYMONTHDAY"]) &&
+ (checkRecurrenceRule(rule, ["BYMONTH"]) || !checkRecurrenceRule(rule, ["BYDAY"]))
+ ) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYMONTHDAY=y.
+ // RRULE:FREQ=YEARLY;BYMONTHDAY=x (takes the month from the start date).
+ let monthNumber = bymonth ? bymonth[0] : startDate.month + 1;
+ let month = getRString("repeatDetailsMonth" + monthNumber);
+ let monthDay =
+ bymonthday[0] == -1
+ ? getRString("monthlyLastDay")
+ : dateFormatter.formatDayWithOrdinal(bymonthday[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, monthDay]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"]) && checkRecurrenceRule(rule, ["BYDAY"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYDAY=y1,y2,....
+ let byday = rule.getComponent("BYDAY");
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ if (everyWeekDay(byday)) {
+ // Every day of the month.
+ let yearlyString = "yearlyEveryDayOf";
+ yearlyString = getRString(yearlyString, [month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#2", rule.interval);
+ } else if (byday.length == 1) {
+ let dayString = "repeatDetailsDay" + day_of_week(byday[0]);
+ if (day_position(byday[0]) == 0) {
+ // Every any weekday.
+ let yearlyString = "yearlyOnEveryNthOfNth";
+ yearlyString = nounClass(dayString, yearlyString);
+ let day = getRString(pluralWeekday(dayString));
+ yearlyString = getRString(yearlyString, [day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (day_position(byday[0]) >= -1 || day_position(byday[0]) <= 5) {
+ // The first|the second|...|the last Monday, Tuesday, ..., day.
+ let yearlyString = "yearlyNthOnNthOf";
+ yearlyString = nounClass(dayString, yearlyString);
+ let ordinalString = "repeatOrdinal" + day_position(byday[0]);
+ ordinalString = nounClass(dayString, ordinalString);
+ let ordinal = getRString(ordinalString);
+ let day = getRString(dayString);
+ yearlyString = getRString(yearlyString, [ordinal, day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#4", rule.interval);
+ } else {
+ return getRString("ruleTooComplex");
+ }
+ } else {
+ // Currently we don't support yearly rules with
+ // more than one BYDAY element or exactly 7 elements
+ // with all the weekdays (the "every day" case).
+ return getRString("ruleTooComplex");
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x (takes the day from the start date).
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else {
+ let month = getRString("repeatDetailsMonth" + (startDate.month + 1));
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ }
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let detailsString;
+ if (!endDate || allDay) {
+ if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCountAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+
+ detailsString = PluralForm.get(rule.count, countString).replace("#3", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntilAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfiniteAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+ }
+ } else if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCount", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ detailsString = PluralForm.get(rule.count, countString).replace("#5", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntil", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfinite", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ return detailsString;
+ }
+ }
+ return null;
+}
+
+/**
+ * Used to test if the recurrence items of a calIRecurrenceInfo instance are
+ * supported. We do not currently allow the "SECONDLY" or "MINUTELY" frequency
+ * values.
+ *
+ * @param {calIRecurrenceInfo} recurrenceInfo
+ * @returns {boolean}
+ */
+function hasUnsupported(recurrenceInfo) {
+ return recurrenceInfo
+ .getRecurrenceItems()
+ .some(item => item.type == "SECONDLY" || item.type == "MINUTELY");
+}
+
+/**
+ * Split rules into negative and positive rules.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @returns An array with two elements: an array of positive
+ * rules and an array of negative rules.
+ */
+function splitRecurrenceRules(recurrenceInfo) {
+ let ritems = recurrenceInfo.getRecurrenceItems();
+ let rules = [];
+ let exceptions = [];
+ for (let ritem of ritems) {
+ if (ritem.isNegative) {
+ exceptions.push(ritem);
+ } else {
+ rules.push(ritem);
+ }
+ }
+ return [rules, exceptions];
+}
+
+/**
+ * Check if a recurrence rule's component is valid.
+ *
+ * @see calIRecurrenceRule
+ * @param aRule The recurrence rule to check.
+ * @param aArray An array of component names to check.
+ * @returns Returns true if the rule is valid.
+ */
+function checkRecurrenceRule(aRule, aArray) {
+ for (let comp of aArray) {
+ let ruleComp = aRule.getComponent(comp);
+ if (ruleComp && ruleComp.length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Counts the occurrences of the parent item if any of a provided item
+ *
+ * @param {(calIEvent|calIToDo)} aItem item to count for
+ * @returns {(number|null)} number of occurrences or null if the
+ * passed item's parent item isn't a
+ * recurring item or its recurrence is
+ * infinite
+ */
+function countOccurrences(aItem) {
+ let occCounter = null;
+ let recInfo = aItem.parentItem.recurrenceInfo;
+ if (recInfo && recInfo.isFinite) {
+ occCounter = 0;
+ let excCounter = 0;
+ let byCount = false;
+ let ritems = recInfo.getRecurrenceItems();
+ for (let ritem of ritems) {
+ if (ritem instanceof lazy.CalRecurrenceRule || ritem instanceof Ci.calIRecurrenceRule) {
+ if (ritem.isByCount) {
+ occCounter = occCounter + ritem.count;
+ byCount = true;
+ } else {
+ // The rule is limited by an until date.
+ let parentItem = aItem.parentItem;
+ let startDate = parentItem.startDate ?? parentItem.entryDate;
+ let endDate = parentItem.endDate ?? parentItem.dueDate ?? startDate;
+ let from = startDate.clone();
+ let until = endDate.clone();
+ if (until.compare(ritem.untilDate) == -1) {
+ until = ritem.untilDate.clone();
+ }
+
+ let exceptionIds = recInfo.getExceptionIds();
+ for (let exceptionId of exceptionIds) {
+ let recur = recInfo.getExceptionFor(exceptionId);
+ let recurStartDate = recur.startDate ?? recur.entryDate;
+ let recurEndDate = recur.endDate ?? recur.dueDate ?? recurStartDate;
+ if (from.compare(recurStartDate) == 1) {
+ from = recurStartDate.clone();
+ }
+ if (until.compare(recurEndDate) == -1) {
+ until = recurEndDate.clone();
+ }
+ }
+
+ // we add an extra day at beginning and end, so we don't
+ // need to take care of any timezone conversion
+ from.addDuration(cal.createDuration("-P1D"));
+ until.addDuration(cal.createDuration("P1D"));
+
+ let occurrences = recInfo.getOccurrences(from, until, 0);
+ occCounter = occCounter + occurrences.length;
+ }
+ } else if (
+ ritem instanceof lazy.CalRecurrenceDate ||
+ ritem instanceof Ci.calIRecurrenceDate
+ ) {
+ if (ritem.isNegative) {
+ // this is an exdate
+ excCounter++;
+ } else {
+ // this is an (additional) rdate
+ occCounter++;
+ }
+ }
+ }
+
+ if (byCount) {
+ // for a rrule by count, we still need to subtract exceptions if any
+ occCounter = occCounter - excCounter;
+ }
+ }
+ return occCounter;
+}
diff --git a/comm/calendar/base/modules/calUtils.jsm b/comm/calendar/base/modules/calUtils.jsm
new file mode 100644
index 0000000000..7ee5669344
--- /dev/null
+++ b/comm/calendar/base/modules/calUtils.jsm
@@ -0,0 +1,578 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { ConsoleAPI } = ChromeUtils.importESModule("resource://gre/modules/Console.sys.mjs");
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+ICAL.design.strict = false;
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalDuration: "resource:///modules/CalDuration.jsm",
+ CalRecurrenceDate: "resource:///modules/CalRecurrenceDate.jsm",
+ CalRecurrenceRule: "resource:///modules/CalRecurrenceRule.jsm",
+});
+
+// The calendar console instance
+var gCalendarConsole = new ConsoleAPI({
+ prefix: "Calendar",
+ consoleID: "calendar",
+ maxLogLevel: Services.prefs.getBoolPref("calendar.debug.log", false) ? "all" : "warn",
+});
+
+const EXPORTED_SYMBOLS = ["cal"];
+var cal = {
+ // These functions exist to reduce boilerplate code for creating instances
+ // as well as getting services and other (cached) objects.
+ createDateTime(value) {
+ let instance = new lazy.CalDateTime();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createDuration(value) {
+ let instance = new lazy.CalDuration();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createRecurrenceDate(value) {
+ let instance = new lazy.CalRecurrenceDate();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createRecurrenceRule(value) {
+ let instance = new lazy.CalRecurrenceRule();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+
+ /**
+ * The calendar console instance
+ */
+ console: gCalendarConsole,
+
+ /**
+ * Logs a calendar message to the console. Needs calendar.debug.log enabled to show messages.
+ * Shortcut to cal.console.log()
+ */
+ LOG: gCalendarConsole.log,
+ LOGverbose: gCalendarConsole.debug,
+
+ /**
+ * Logs a calendar warning to the console. Shortcut to cal.console.warn()
+ */
+ WARN: gCalendarConsole.warn,
+
+ /**
+ * Logs a calendar error to the console. Shortcut to cal.console.error()
+ */
+ ERROR: gCalendarConsole.error,
+
+ /**
+ * Uses the prompt service to display an error message. Use this sparingly,
+ * as it interrupts the user.
+ *
+ * @param aMsg The message to be shown
+ * @param aWindow The window to show the message in, or null for any window.
+ */
+ showError(aMsg, aWindow = null) {
+ Services.prompt.alert(aWindow, cal.l10n.getCalString("genericErrorTitle"), aMsg);
+ },
+
+ /**
+ * Returns a string describing the current js-stack with filename and line
+ * numbers.
+ *
+ * @param aDepth (optional) The number of frames to include. Defaults to 5.
+ * @param aSkip (optional) Number of frames to skip
+ */
+ STACK(aDepth = 10, aSkip = 0) {
+ let stack = "";
+ let frame = Components.stack.caller;
+ for (let i = 1; i <= aDepth + aSkip && frame; i++) {
+ if (i > aSkip) {
+ stack += `${i}: [${frame.filename}:${frame.lineNumber}] ${frame.name}\n`;
+ }
+ frame = frame.caller;
+ }
+ return stack;
+ },
+
+ /**
+ * Logs a message and the current js-stack, if aCondition fails
+ *
+ * @param aCondition the condition to test for
+ * @param aMessage the message to report in the case the assert fails
+ * @param aCritical if true, throw an error to stop current code execution
+ * if false, code flow will continue
+ * may be a result code
+ */
+ ASSERT(aCondition, aMessage, aCritical = false) {
+ if (aCondition) {
+ return;
+ }
+
+ let string = `Assert failed: ${aMessage}\n ${cal.STACK(0, 1)}`;
+ if (aCritical) {
+ let rescode = aCritical === true ? Cr.NS_ERROR_UNEXPECTED : aCritical;
+ throw new Components.Exception(string, rescode);
+ } else {
+ console.error(string);
+ }
+ },
+
+ /**
+ * Generates the QueryInterface function. This is a replacement for XPCOMUtils.generateQI, which
+ * is being replaced. Unfortunately Calendar's code depends on some of its classes providing
+ * nsIClassInfo, which causes xpconnect/xpcom to make all methods available, e.g. for an event
+ * both calIItemBase and calIEvent.
+ *
+ * @param {Array<string | nsIIDRef>} aInterfaces The interfaces to generate QI for.
+ * @returns {Function} The QueryInterface function
+ */
+ generateQI(aInterfaces) {
+ if (aInterfaces.length == 1) {
+ cal.WARN(
+ "When generating QI for one interface, please use ChromeUtils.generateQI",
+ cal.STACK(10)
+ );
+ return ChromeUtils.generateQI(aInterfaces);
+ }
+ /* Note that Ci[Ci.x] == Ci.x for all x */
+ let names = [];
+ if (aInterfaces) {
+ for (let i = 0; i < aInterfaces.length; i++) {
+ let iface = aInterfaces[i];
+ let name = (iface && iface.name) || String(iface);
+ if (name in Ci) {
+ names.push(name);
+ }
+ }
+ }
+ return makeQI(names);
+ },
+
+ /**
+ * Generate a ClassInfo implementation for a component. The returned object
+ * must be assigned to the 'classInfo' property of a JS object. The first and
+ * only argument should be an object that contains a number of optional
+ * properties: "interfaces", "contractID", "classDescription", "classID" and
+ * "flags". The values of the properties will be returned as the values of the
+ * various properties of the nsIClassInfo implementation.
+ */
+ generateCI(classInfo) {
+ if ("QueryInterface" in classInfo) {
+ throw Error("In generateCI, don't use a component for generating classInfo");
+ }
+ /* Note that Ci[Ci.x] == Ci.x for all x */
+ let _interfaces = [];
+ for (let i = 0; i < classInfo.interfaces.length; i++) {
+ let iface = classInfo.interfaces[i];
+ if (Ci[iface]) {
+ _interfaces.push(Ci[iface]);
+ }
+ }
+ return {
+ get interfaces() {
+ return [Ci.nsIClassInfo, Ci.nsISupports].concat(_interfaces);
+ },
+ getScriptableHelper() {
+ return null;
+ },
+ contractID: classInfo.contractID,
+ classDescription: classInfo.classDescription,
+ classID: classInfo.classID,
+ flags: classInfo.flags,
+ QueryInterface: ChromeUtils.generateQI(["nsIClassInfo"]),
+ };
+ },
+
+ /**
+ * Create an adapter for the given interface. If passed, methods will be
+ * added to the template object, otherwise a new object will be returned.
+ *
+ * @param iface The interface to adapt, either using
+ * Components.interfaces or the name as a string.
+ * @param template (optional) A template object to extend
+ * @returns If passed the adapted template object, otherwise a
+ * clean adapter.
+ *
+ * Currently supported interfaces are:
+ * - calIObserver
+ * - calICalendarManagerObserver
+ * - calIOperationListener
+ * - calICompositeObserver
+ */
+ createAdapter(iface, template) {
+ let methods;
+ let adapter = template || {};
+ switch (iface.name || iface) {
+ case "calIObserver":
+ methods = [
+ "onStartBatch",
+ "onEndBatch",
+ "onLoad",
+ "onAddItem",
+ "onModifyItem",
+ "onDeleteItem",
+ "onError",
+ "onPropertyChanged",
+ "onPropertyDeleting",
+ ];
+ break;
+ case "calICalendarManagerObserver":
+ methods = ["onCalendarRegistered", "onCalendarUnregistering", "onCalendarDeleting"];
+ break;
+ case "calIOperationListener":
+ methods = ["onGetResult", "onOperationComplete"];
+ break;
+ case "calICompositeObserver":
+ methods = ["onCalendarAdded", "onCalendarRemoved", "onDefaultCalendarChanged"];
+ break;
+ default:
+ methods = [];
+ break;
+ }
+
+ for (let method of methods) {
+ if (!(method in template)) {
+ adapter[method] = function () {};
+ }
+ }
+ adapter.QueryInterface = ChromeUtils.generateQI([iface]);
+
+ return adapter;
+ },
+
+ /**
+ * Make a UUID, without enclosing brackets, e.g. 0d3950fd-22e5-4508-91ba-0489bdac513f
+ *
+ * @returns {string} The generated UUID
+ */
+ getUUID() {
+ // generate uuids without braces to avoid problems with
+ // CalDAV servers that don't support filenames with {}
+ return Services.uuid.generateUUID().toString().replace(/[{}]/g, "");
+ },
+
+ /**
+ * Adds an observer listening for the topic.
+ *
+ * @param func function to execute on topic
+ * @param topic topic to listen for
+ * @param oneTime whether to listen only once
+ */
+ addObserver(func, topic, oneTime) {
+ let observer = {
+ // nsIObserver:
+ observe(subject, topic_, data) {
+ if (topic == topic_) {
+ if (oneTime) {
+ Services.obs.removeObserver(this, topic);
+ }
+ func(subject, topic, data);
+ }
+ },
+ };
+ Services.obs.addObserver(observer, topic);
+ },
+
+ /**
+ * Wraps an instance, making sure the xpcom wrapped object is used.
+ *
+ * @param aObj the object under consideration
+ * @param aInterface the interface to be wrapped
+ *
+ * Use this function to QueryInterface the object to a particular interface.
+ * You may only expect the return value to be wrapped, not the original passed object.
+ * For example:
+ * // BAD USAGE:
+ * if (cal.wrapInstance(foo, Ci.nsIBar)) {
+ * foo.barMethod();
+ * }
+ * // GOOD USAGE:
+ * foo = cal.wrapInstance(foo, Ci.nsIBar);
+ * if (foo) {
+ * foo.barMethod();
+ * }
+ *
+ */
+ wrapInstance(aObj, aInterface) {
+ if (!aObj) {
+ return null;
+ }
+
+ try {
+ return aObj.QueryInterface(aInterface);
+ } catch (e) {
+ return null;
+ }
+ },
+
+ /**
+ * Tries to get rid of wrappers, if this is not possible then return the
+ * passed object.
+ *
+ * @param aObj The object under consideration
+ * @returns The possibly unwrapped object.
+ */
+ unwrapInstance(aObj) {
+ return aObj && aObj.wrappedJSObject ? aObj.wrappedJSObject : aObj;
+ },
+
+ /**
+ * Adds an xpcom shutdown observer.
+ *
+ * @param func function to execute
+ */
+ addShutdownObserver(func) {
+ cal.addObserver(func, "xpcom-shutdown", true /* one time */);
+ },
+
+ /**
+ * Due to wrapped js objects, some objects may have cyclic references.
+ * You can register properties of objects to be cleaned up on xpcom-shutdown.
+ *
+ * @param obj object
+ * @param prop property to be deleted on shutdown
+ * (if null, |object| will be deleted)
+ */
+ registerForShutdownCleanup: shutdownCleanup,
+};
+
+/**
+ * Update the logging preferences for the calendar console based on the state of verbose logging and
+ * normal calendar logging.
+ */
+function updateLogPreferences() {
+ if (cal.verboseLogEnabled) {
+ gCalendarConsole.maxLogLevel = "all";
+ } else if (cal.debugLogEnabled) {
+ gCalendarConsole.maxLogLevel = "log";
+ } else {
+ gCalendarConsole.maxLogLevel = "warn";
+ }
+}
+
+// Preferences
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "debugLogEnabled",
+ "calendar.debug.log",
+ false,
+ updateLogPreferences
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "verboseLogEnabled",
+ "calendar.debug.log.verbose",
+ false,
+ updateLogPreferences
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "threadingEnabled",
+ "calendar.threading.disabled",
+ false
+);
+
+// Services
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "manager",
+ "@mozilla.org/calendar/manager;1",
+ "calICalendarManager"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "icsService",
+ "@mozilla.org/calendar/ics-service;1",
+ "calIICSService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "timezoneService",
+ "@mozilla.org/calendar/timezone-service;1",
+ "calITimezoneService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "freeBusyService",
+ "@mozilla.org/calendar/freebusy-service;1",
+ "calIFreeBusyService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "weekInfoService",
+ "@mozilla.org/calendar/weekinfo-service;1",
+ "calIWeekInfoService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "dragService",
+ "@mozilla.org/widget/dragservice;1",
+ "nsIDragService"
+);
+
+// Sub-modules for calUtils
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "acl",
+ "resource:///modules/calendar/utils/calACLUtils.jsm",
+ "calacl"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "alarms",
+ "resource:///modules/calendar/utils/calAlarmUtils.jsm",
+ "calalarms"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "auth",
+ "resource:///modules/calendar/utils/calAuthUtils.jsm",
+ "calauth"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "category",
+ "resource:///modules/calendar/utils/calCategoryUtils.jsm",
+ "calcategory"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "data",
+ "resource:///modules/calendar/utils/calDataUtils.jsm",
+ "caldata"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "dtz",
+ "resource:///modules/calendar/utils/calDateTimeUtils.jsm",
+ "caldtz"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "email",
+ "resource:///modules/calendar/utils/calEmailUtils.jsm",
+ "calemail"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "invitation",
+ "resource:///modules/calendar/utils/calInvitationUtils.jsm",
+ "calinvitation"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "item",
+ "resource:///modules/calendar/utils/calItemUtils.jsm",
+ "calitem"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "iterate",
+ "resource:///modules/calendar/utils/calIteratorUtils.jsm",
+ "caliterate"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "itip",
+ "resource:///modules/calendar/utils/calItipUtils.jsm",
+ "calitip"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "l10n",
+ "resource:///modules/calendar/utils/calL10NUtils.jsm",
+ "call10n"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "print",
+ "resource:///modules/calendar/utils/calPrintUtils.jsm",
+ "calprint"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "provider",
+ "resource:///modules/calendar/utils/calProviderUtils.jsm",
+ "calprovider"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "unifinder",
+ "resource:///modules/calendar/utils/calUnifinderUtils.jsm",
+ "calunifinder"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "view",
+ "resource:///modules/calendar/utils/calViewUtils.jsm",
+ "calview"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "window",
+ "resource:///modules/calendar/utils/calWindowUtils.jsm",
+ "calwindow"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "xml",
+ "resource:///modules/calendar/utils/calXMLUtils.jsm",
+ "calxml"
+);
+
+// will be used to clean up global objects on shutdown
+// some objects have cyclic references due to wrappers
+function shutdownCleanup(obj, prop) {
+ if (!shutdownCleanup.mEntries) {
+ shutdownCleanup.mEntries = [];
+ cal.addShutdownObserver(() => {
+ for (let entry of shutdownCleanup.mEntries) {
+ if (entry.mProp) {
+ delete entry.mObj[entry.mProp];
+ } else {
+ delete entry.mObj;
+ }
+ }
+ delete shutdownCleanup.mEntries;
+ });
+ }
+ shutdownCleanup.mEntries.push({ mObj: obj, mProp: prop });
+}
+
+/**
+ * This is the makeQI function from XPCOMUtils.sys.mjs, it is separate to avoid leaks
+ *
+ * @param {Array<string | nsIIDRef>} aInterfaces The interfaces to make QI for.
+ * @returns {Function} The QueryInterface function.
+ */
+function makeQI(aInterfaces) {
+ return function (iid) {
+ if (iid.equals(Ci.nsISupports)) {
+ return this;
+ }
+ if (iid.equals(Ci.nsIClassInfo) && "classInfo" in this) {
+ return this.classInfo;
+ }
+ for (let i = 0; i < aInterfaces.length; i++) {
+ if (Ci[aInterfaces[i]].equals(iid)) {
+ return this;
+ }
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ };
+}
diff --git a/comm/calendar/base/modules/moz.build b/comm/calendar/base/modules/moz.build
new file mode 100644
index 0000000000..f0269ff7f6
--- /dev/null
+++ b/comm/calendar/base/modules/moz.build
@@ -0,0 +1,36 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES.calendar.utils += [
+ "utils/calACLUtils.jsm",
+ "utils/calAlarmUtils.jsm",
+ "utils/calAuthUtils.jsm",
+ "utils/calCategoryUtils.jsm",
+ "utils/calDataUtils.jsm",
+ "utils/calDateTimeFormatter.jsm",
+ "utils/calDateTimeUtils.jsm",
+ "utils/calEmailUtils.jsm",
+ "utils/calInvitationUtils.jsm",
+ "utils/calItemUtils.jsm",
+ "utils/calIteratorUtils.jsm",
+ "utils/calItipUtils.jsm",
+ "utils/calL10NUtils.jsm",
+ "utils/calPrintUtils.jsm",
+ "utils/calProviderDetectionUtils.jsm",
+ "utils/calProviderUtils.jsm",
+ "utils/calUnifinderUtils.jsm",
+ "utils/calViewUtils.jsm",
+ "utils/calWindowUtils.jsm",
+ "utils/calXMLUtils.jsm",
+]
+
+EXTRA_JS_MODULES.calendar += [
+ "calCalendarDeactivator.jsm",
+ "calExtract.jsm",
+ "calHashedArray.jsm",
+ "calRecurrenceUtils.jsm",
+ "calUtils.jsm",
+ "Ical.jsm",
+]
diff --git a/comm/calendar/base/modules/utils/calACLUtils.jsm b/comm/calendar/base/modules/utils/calACLUtils.jsm
new file mode 100644
index 0000000000..507d9f232d
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calACLUtils.jsm
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helpers for permission checks and other ACL features
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.acl namespace.
+
+const EXPORTED_SYMBOLS = ["calacl"];
+
+var calacl = {
+ /**
+ * Check if the specified calendar is writable. This is the case when it is
+ * not marked readOnly, we are not offline, or we are offline and the
+ * calendar is local.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ isCalendarWritable(aCalendar) {
+ return (
+ !aCalendar.getProperty("disabled") &&
+ !aCalendar.readOnly &&
+ (!Services.io.offline ||
+ aCalendar.getProperty("cache.enabled") ||
+ aCalendar.getProperty("cache.always") ||
+ aCalendar.getProperty("requiresNetwork") === false)
+ );
+ },
+
+ /**
+ * Check if the specified calendar is writable from an ACL point of view.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ userCanAddItemsToCalendar(aCalendar) {
+ let aclEntry = aCalendar.aclEntry;
+ return (
+ !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanAddItems
+ );
+ },
+
+ /**
+ * Check if the user can delete items from the specified calendar, from an
+ * ACL point of view.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ userCanDeleteItemsFromCalendar(aCalendar) {
+ let aclEntry = aCalendar.aclEntry;
+ return (
+ !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanDeleteItems
+ );
+ },
+
+ /**
+ * Check if the user can fully modify the specified item, from an ACL point
+ * of view. Note to be confused with the right to respond to an
+ * invitation, which is handled instead by userCanRespondToInvitation.
+ *
+ * @param aItem The calendar item to check
+ * @returns True if the item is modifiable
+ */
+ userCanModifyItem(aItem) {
+ let aclEntry = aItem.aclEntry;
+ return (
+ !aclEntry ||
+ !aclEntry.calendarEntry.hasAccessControl ||
+ aclEntry.calendarEntry.userIsOwner ||
+ aclEntry.userCanModify
+ );
+ },
+
+ /**
+ * Checks if the user can modify the item and has the right to respond to
+ * invitations for the item.
+ *
+ * @param aItem The calendar item to check
+ * @returns True if the invitation w.r.t. the item can be
+ * responded to.
+ */
+ userCanRespondToInvitation(aItem) {
+ let aclEntry = aItem.aclEntry;
+ // TODO check if || is really wanted here
+ return calacl.userCanModifyItem(aItem) || aclEntry.userCanRespond;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calAlarmUtils.jsm b/comm/calendar/base/modules/utils/calAlarmUtils.jsm
new file mode 100644
index 0000000000..d4792652f7
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calAlarmUtils.jsm
@@ -0,0 +1,161 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helpers for manipulating calendar alarms
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.alarm namespace.
+
+const EXPORTED_SYMBOLS = ["calalarms"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+var calalarms = {
+ /**
+ * Read default alarm settings from user preferences and apply them to the
+ * event/todo passed in. The item's calendar should be set to ensure the
+ * correct alarm type is set.
+ *
+ * @param aItem The item to apply the default alarm values to.
+ */
+ setDefaultValues(aItem) {
+ let type = aItem.isEvent() ? "event" : "todo";
+ if (Services.prefs.getIntPref("calendar.alarms.onfor" + type + "s", 0) == 1) {
+ let alarmOffset = lazy.cal.createDuration();
+ let alarm = new lazy.CalAlarm();
+ let units = Services.prefs.getStringPref("calendar.alarms." + type + "alarmunit", "minutes");
+
+ // Make sure the alarm pref is valid, default to minutes otherwise
+ if (!["weeks", "days", "hours", "minutes", "seconds"].includes(units)) {
+ units = "minutes";
+ }
+
+ alarmOffset[units] = Services.prefs.getIntPref("calendar.alarms." + type + "alarmlen", 0);
+ alarmOffset.normalize();
+ alarmOffset.isNegative = true;
+ if (type == "todo" && !aItem.entryDate) {
+ // You can't have an alarm if the entryDate doesn't exist.
+ aItem.entryDate = lazy.cal.dtz.now();
+ }
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = alarmOffset;
+
+ // Default to a display alarm, unless the calendar doesn't support
+ // it or we have no calendar yet. (Man this is hard to wrap)
+ let actionValues = (aItem.calendar &&
+ aItem.calendar.getProperty("capabilities.alarms.actionValues")) || ["DISPLAY"];
+
+ alarm.action = actionValues.includes("DISPLAY") ? "DISPLAY" : actionValues[0];
+ aItem.addAlarm(alarm);
+ }
+ },
+
+ /**
+ * Calculate the alarm date for a calIAlarm.
+ *
+ * @param aItem The item used to calculate the alarm date.
+ * @param aAlarm The alarm to calculate the date for.
+ * @returns The alarm date.
+ */
+ calculateAlarmDate(aItem, aAlarm) {
+ if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_ABSOLUTE) {
+ return aAlarm.alarmDate;
+ }
+ let returnDate;
+ if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_START) {
+ returnDate = aItem[lazy.cal.dtz.startDateProp(aItem)];
+ } else if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_END) {
+ returnDate = aItem[lazy.cal.dtz.endDateProp(aItem)];
+ }
+
+ if (returnDate && aAlarm.offset) {
+ // Handle all day events. This is kinda weird, because they don't
+ // have a well defined startTime. We just consider the start/end
+ // to be midnight in the user's timezone.
+ if (returnDate.isDate) {
+ let timezone = lazy.cal.dtz.defaultTimezone;
+ // This returns a copy, so no extra cloning needed.
+ returnDate = returnDate.getInTimezone(timezone);
+ returnDate.isDate = false;
+ } else if (returnDate.timezone.tzid == "floating") {
+ let timezone = lazy.cal.dtz.defaultTimezone;
+ returnDate = returnDate.getInTimezone(timezone);
+ } else {
+ // Clone the date to correctly add the duration.
+ returnDate = returnDate.clone();
+ }
+
+ returnDate.addDuration(aAlarm.offset);
+ return returnDate;
+ }
+
+ return null;
+ },
+
+ /**
+ * Removes previous children and adds reminder images to a given container,
+ * making sure only one icon per alarm action is added.
+ *
+ * @param {Element} container - The element to add the images to.
+ * @param {CalAlarm[]} reminderSet - The set of reminders to add images for.
+ */
+ addReminderImages(container, reminderSet) {
+ while (container.lastChild) {
+ container.lastChild.remove();
+ }
+
+ let document = container.ownerDocument;
+ let suppressed = container.hasAttribute("suppressed");
+ let actionSet = [];
+ for (let reminder of reminderSet) {
+ // Up to one icon per action
+ if (actionSet.includes(reminder.action)) {
+ continue;
+ }
+ actionSet.push(reminder.action);
+
+ let src;
+ let l10nId;
+ switch (reminder.action) {
+ case "DISPLAY":
+ if (suppressed) {
+ src = "chrome://messenger/skin/icons/new/bell-disabled.svg";
+ l10nId = "calendar-editable-item-reminder-icon-suppressed-alarm";
+ } else {
+ src = "chrome://messenger/skin/icons/new/bell.svg";
+ l10nId = "calendar-editable-item-reminder-icon-alarm";
+ }
+ break;
+ case "EMAIL":
+ src = "chrome://messenger/skin/icons/new/mail-sm.svg";
+ l10nId = "calendar-editable-item-reminder-icon-email";
+ break;
+ case "AUDIO":
+ src = "chrome://messenger/skin/icons/new/bell-ring.svg";
+ l10nId = "calendar-editable-item-reminder-icon-audio";
+ break;
+ default:
+ // Never create icons for actions we don't handle.
+ continue;
+ }
+
+ let image = document.createElement("img");
+ image.setAttribute("class", "reminder-icon");
+ image.setAttribute("value", reminder.action);
+ image.setAttribute("src", src);
+ // Set alt.
+ document.l10n.setAttributes(image, l10nId);
+ container.appendChild(image);
+ }
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calAuthUtils.jsm b/comm/calendar/base/modules/utils/calAuthUtils.jsm
new file mode 100644
index 0000000000..1f14d1c6cd
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calAuthUtils.jsm
@@ -0,0 +1,564 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Authentication tools and prompts, mostly for providers
+ */
+
+// NOTE: This module should not be loaded directly, it is available when including
+// calUtils.jsm under the cal.auth namespace.
+
+const EXPORTED_SYMBOLS = ["calauth"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ MsgAuthPrompt: "resource:///modules/MsgAsyncPrompter.jsm",
+});
+
+/**
+ * The userContextId of nsIHttpChannel is currently implemented as a uint32, so
+ * the ContainerMap defined below must not return Ids greater then the allowed
+ * range of a uint32.
+ */
+const MAX_CONTAINER_ID = Math.pow(2, 32) - 1;
+
+/**
+ * A map that handles userContextIds and usernames and provides unique Ids for
+ * different usernames.
+ */
+class ContainerMap extends Map {
+ /**
+ * Create a container map with a given range of userContextIds.
+ *
+ * @param {number} min - The lower range limit of userContextIds to be
+ * used.
+ * @param {number} max - The upper range limit of userContextIds to be
+ * used.
+ * @param {?object} iterable - Optional parameter which is passed to the
+ * constructor of Map. See definition of Map
+ * for more details.
+ */
+ constructor(min = 0, max = MAX_CONTAINER_ID, iterable) {
+ super(iterable);
+ this.order = [];
+ this.inverted = {};
+ this.min = min;
+ // The userConextId is a uint32, limit accordingly.
+ this.max = Math.max(max, MAX_CONTAINER_ID);
+ if (this.min > this.max) {
+ throw new RangeError(
+ "[ContainerMap] The provided min value " +
+ "(" +
+ this.min +
+ ") must not be greater than the provided " +
+ "max value (" +
+ this.max +
+ ")"
+ );
+ }
+ }
+
+ /**
+ * Check if the allowed userContextId range is fully used.
+ */
+ get full() {
+ return this.size > this.max - this.min;
+ }
+
+ /**
+ * Add a new username to the map.
+ *
+ * @param {string} username - The username to be added.
+ * @returns {number} The userContextId assigned to the given username.
+ */
+ _add(username) {
+ let nextUserContextId;
+ if (this.full) {
+ let oldestUsernameEntry = this.order.shift();
+ nextUserContextId = this.get(oldestUsernameEntry);
+ this.delete(oldestUsernameEntry);
+ } else {
+ nextUserContextId = this.min + this.size;
+ }
+
+ Services.clearData.deleteDataFromOriginAttributesPattern({ userContextId: nextUserContextId });
+ this.order.push(username);
+ this.set(username, nextUserContextId);
+ this.inverted[nextUserContextId] = username;
+ return nextUserContextId;
+ }
+
+ /**
+ * Look up the userContextId for the given username. Create a new one,
+ * if the username is not yet known.
+ *
+ * @param {string} username - The username for which the userContextId
+ * is to be looked up.
+ * @returns {number} The userContextId which is assigned to
+ * the provided username.
+ */
+ getUserContextIdForUsername(username) {
+ if (this.has(username)) {
+ return this.get(username);
+ }
+ return this._add(username);
+ }
+
+ /**
+ * Look up the username for the given userContextId. Return empty string
+ * if not found.
+ *
+ * @param {number} userContextId - The userContextId for which the
+ * username is to be to looked up.
+ * @returns {string} The username mapped to the given
+ * userContextId.
+ */
+ getUsernameForUserContextId(userContextId) {
+ if (this.inverted.hasOwnProperty(userContextId)) {
+ return this.inverted[userContextId];
+ }
+ return "";
+ }
+}
+
+var calauth = {
+ /**
+ * Calendar Auth prompt implementation. This instance of the auth prompt should
+ * be used by providers and other components that handle authentication using
+ * nsIAuthPrompt2 and friends.
+ *
+ * This implementation guarantees there are no request loops when an invalid
+ * password is stored in the login-manager.
+ *
+ * There is one instance of that object per calendar provider.
+ */
+ Prompt: class {
+ constructor() {
+ this.mWindow = lazy.cal.window.getCalendarWindow();
+ this.mReturnedLogins = {};
+ this.mProvider = null;
+ }
+
+ /**
+ * @typedef {object} PasswordInfo
+ * @property {boolean} found True, if the password was found
+ * @property {?string} username The found username
+ * @property {?string} password The found password
+ */
+
+ /**
+ * Retrieve password information from the login manager
+ *
+ * @param {string} aPasswordRealm - The realm to retrieve password info for
+ * @param {string} aRequestedUser - The username to look up.
+ * @returns {PasswordInfo} The retrieved password information
+ */
+ getPasswordInfo(aPasswordRealm, aRequestedUser) {
+ // Prefill aRequestedUser, so it will be used in the prompter.
+ let username = aRequestedUser;
+ let password;
+ let found = false;
+
+ let logins = Services.logins.findLogins(aPasswordRealm.prePath, null, aPasswordRealm.realm);
+ for (let login of logins) {
+ if (!aRequestedUser || aRequestedUser == login.username) {
+ username = login.username;
+ password = login.password;
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ let keyStr = aPasswordRealm.prePath + ":" + aPasswordRealm.realm + ":" + aRequestedUser;
+ let now = new Date();
+ // Remove the saved password if it was already returned less
+ // than 60 seconds ago. The reason for the timestamp check is that
+ // nsIHttpChannel can call the nsIAuthPrompt2 interface
+ // again in some situation. ie: When using Digest auth token
+ // expires.
+ if (
+ this.mReturnedLogins[keyStr] &&
+ now.getTime() - this.mReturnedLogins[keyStr].getTime() < 60000
+ ) {
+ lazy.cal.LOG(
+ "Credentials removed for: user=" +
+ username +
+ ", host=" +
+ aPasswordRealm.prePath +
+ ", realm=" +
+ aPasswordRealm.realm
+ );
+
+ delete this.mReturnedLogins[keyStr];
+ calauth.passwordManagerRemove(username, aPasswordRealm.prePath, aPasswordRealm.realm);
+ return { found: false, username };
+ }
+ this.mReturnedLogins[keyStr] = now;
+ }
+ return { found, username, password };
+ }
+
+ // boolean promptAuth(in nsIChannel aChannel,
+ // in uint32_t level,
+ // in nsIAuthInformation authInfo)
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ let hostRealm = {};
+ hostRealm.prePath = aChannel.URI.prePath;
+ hostRealm.realm = aAuthInfo.realm;
+ let port = aChannel.URI.port;
+ if (port == -1) {
+ let handler = Services.io
+ .getProtocolHandler(aChannel.URI.scheme)
+ .QueryInterface(Ci.nsIProtocolHandler);
+ port = handler.defaultPort;
+ }
+ hostRealm.passwordRealm = aChannel.URI.host + ":" + port + " (" + aAuthInfo.realm + ")";
+
+ let requestedUser = lazy.cal.auth.containerMap.getUsernameForUserContextId(
+ aChannel.loadInfo.originAttributes.userContextId
+ );
+ let pwInfo = this.getPasswordInfo(hostRealm, requestedUser);
+ aAuthInfo.username = pwInfo.username;
+ if (pwInfo && pwInfo.found) {
+ aAuthInfo.password = pwInfo.password;
+ return true;
+ }
+ let savePasswordLabel = null;
+ if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
+ savePasswordLabel = lazy.cal.l10n.getAnyString(
+ "passwordmgr",
+ "passwordmgr",
+ "rememberPassword"
+ );
+ }
+ let savePassword = {};
+ let returnValue = new lazy.MsgAuthPrompt().promptAuth(
+ aChannel,
+ aLevel,
+ aAuthInfo,
+ savePasswordLabel,
+ savePassword
+ );
+ if (savePassword.value) {
+ calauth.passwordManagerSave(
+ aAuthInfo.username,
+ aAuthInfo.password,
+ hostRealm.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return returnValue;
+ }
+
+ // nsICancelable asyncPromptAuth(in nsIChannel aChannel,
+ // in nsIAuthPromptCallback aCallback,
+ // in nsISupports aContext,
+ // in uint32_t level,
+ // in nsIAuthInformation authInfo);
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ let self = this;
+ let promptlistener = {
+ onPromptStartAsync(callback) {
+ callback.onAuthResult(this.onPromptStart());
+ },
+
+ onPromptStart() {
+ let res = self.promptAuth(aChannel, aLevel, aAuthInfo);
+ if (res) {
+ gAuthCache.setAuthInfo(hostKey, aAuthInfo);
+ this.onPromptAuthAvailable();
+ return true;
+ }
+
+ this.onPromptCanceled();
+ return false;
+ },
+
+ onPromptAuthAvailable() {
+ let authInfo = gAuthCache.retrieveAuthInfo(hostKey);
+ if (authInfo) {
+ aAuthInfo.username = authInfo.username;
+ aAuthInfo.password = authInfo.password;
+ }
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ },
+
+ onPromptCanceled() {
+ gAuthCache.retrieveAuthInfo(hostKey);
+ aCallback.onAuthCancelled(aContext, true);
+ },
+ };
+
+ let requestedUser = lazy.cal.auth.containerMap.getUsernameForUserContextId(
+ aChannel.loadInfo.originAttributes.userContextId
+ );
+ let hostKey = aChannel.URI.prePath + ":" + aAuthInfo.realm + ":" + requestedUser;
+ gAuthCache.planForAuthInfo(hostKey);
+
+ let queuePrompt = function () {
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(hostKey, false, promptlistener);
+ };
+
+ let finalSteps = function () {
+ // the prompt will fail if we are too early
+ if (self.mWindow.document.readyState == "complete") {
+ queuePrompt();
+ } else {
+ self.mWindow.addEventListener("load", queuePrompt, true);
+ }
+ };
+
+ let tryUntilReady = function () {
+ self.mWindow = lazy.cal.window.getCalendarWindow();
+ if (!self.mWindow) {
+ lazy.setTimeout(tryUntilReady, 1000);
+ return;
+ }
+
+ finalSteps();
+ };
+
+ // We might reach this code when cal.window.getCalendarWindow()
+ // returns null, which means the window obviously isn't yet
+ // in readyState complete, and we also cannot yet queue a prompt.
+ // It may happen if startup shows a blocking primary password
+ // prompt, which delays starting up the application windows.
+ // Use a timer to retry until we can access the calendar window.
+
+ tryUntilReady();
+ }
+ },
+
+ /**
+ * Tries to get the username/password combination of a specific calendar name from the password
+ * manager or asks the user.
+ *
+ * @param {string} aTitle - The dialog title.
+ * @param {string} aCalendarName - The calendar name or url to look up. Can be null.
+ * @param {{value: string}} aUsername The username that belongs to the calendar.
+ * @param {{value: string}} aPassword The password that belongs to the calendar.
+ * @param {{value: string}} aSavePassword Should the password be saved?
+ * @param {boolean} aFixedUsername - Whether the user name is fixed or editable
+ * @returns {boolean} Could a password be retrieved?
+ */
+ getCredentials(aTitle, aCalendarName, aUsername, aPassword, aSavePassword, aFixedUsername) {
+ if (
+ typeof aUsername != "object" ||
+ typeof aPassword != "object" ||
+ typeof aSavePassword != "object"
+ ) {
+ throw new Components.Exception("", Cr.NS_ERROR_XPC_NEED_OUT_OBJECT);
+ }
+
+ let prompter = new lazy.MsgAuthPrompt();
+
+ // Only show the save password box if we are supposed to.
+ let savepassword = null;
+ if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
+ savepassword = lazy.cal.l10n.getAnyString("passwordmgr", "passwordmgr", "rememberPassword");
+ }
+
+ let aText;
+ if (aFixedUsername) {
+ aText = lazy.cal.l10n.getAnyString("global", "commonDialogs", "EnterPasswordFor", [
+ aUsername.value,
+ aCalendarName,
+ ]);
+ return prompter.promptPassword(aTitle, aText, aPassword, savepassword, aSavePassword);
+ }
+ aText = lazy.cal.l10n.getAnyString("global", "commonDialogs", "EnterUserPasswordFor2", [
+ aCalendarName,
+ ]);
+ return prompter.promptUsernameAndPassword(
+ aTitle,
+ aText,
+ aUsername,
+ aPassword,
+ savepassword,
+ aSavePassword
+ );
+ },
+
+ /**
+ * Make sure the passed origin is actually an uri string, because password manager functions
+ * require it. This is a fallback for compatibility only and should be removed a few versions
+ * after Lightning 6.2
+ *
+ * @param {string} aOrigin - The hostname or origin to check
+ * @returns {string} The origin uri
+ */
+ _ensureOrigin(aOrigin) {
+ try {
+ let { prePath, spec } = Services.io.newURI(aOrigin);
+ if (prePath == "oauth:") {
+ return spec;
+ }
+ return prePath;
+ } catch (e) {
+ return "https://" + aOrigin;
+ }
+ },
+
+ /**
+ * Helper to insert/update an entry to the password manager.
+ *
+ * @param {string} aUsername - The username to insert
+ * @param {string} aPassword - The corresponding password
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ */
+ passwordManagerSave(aUsername, aPassword, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+ lazy.cal.ASSERT(aPassword);
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ if (!Services.logins.getLoginSavingEnabled(origin)) {
+ throw new Components.Exception(
+ "Password saving is disabled for " + origin,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, aRealm);
+
+ let newLoginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ newLoginInfo.init(origin, null, aRealm, aUsername, aPassword, "", "");
+ for (let login of logins) {
+ if (aUsername == login.username) {
+ Services.logins.modifyLogin(login, newLoginInfo);
+ return;
+ }
+ }
+ Services.logins.addLogin(newLoginInfo);
+ } catch (exc) {
+ // Only show the message if its not an abort, which can happen if
+ // the user canceled the primary password dialog
+ lazy.cal.ASSERT(exc.result == Cr.NS_ERROR_ABORT, exc);
+ }
+ },
+
+ /**
+ * Helper to retrieve an entry from the password manager.
+ *
+ * @param {string} aUsername - The username to search
+ * @param {string} aPassword - The corresponding password
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ * @returns {boolean} True, if an entry exists in the password manager
+ */
+ passwordManagerGet(aUsername, aPassword, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+
+ if (typeof aPassword != "object") {
+ throw new Components.Exception("", Cr.NS_ERROR_XPC_NEED_OUT_OBJECT);
+ }
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, "");
+ for (let loginInfo of logins) {
+ if (
+ loginInfo.username == aUsername &&
+ (loginInfo.httpRealm == aRealm || loginInfo.httpRealm.split(" ").includes(aRealm))
+ ) {
+ aPassword.value = loginInfo.password;
+ return true;
+ }
+ }
+ } catch (exc) {
+ lazy.cal.ASSERT(false, exc);
+ }
+ return false;
+ },
+
+ /**
+ * Helper to remove an entry from the password manager
+ *
+ * @param {string} aUsername - The username to remove
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ * @returns {boolean} Could the user be removed?
+ */
+ passwordManagerRemove(aUsername, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, aRealm);
+ for (let loginInfo of logins) {
+ if (loginInfo.username == aUsername) {
+ Services.logins.removeLogin(loginInfo);
+ return true;
+ }
+ }
+ } catch (exc) {
+ // If no logins are found, fall through to the return statement below.
+ }
+ return false;
+ },
+
+ /**
+ * A map which maps usernames to userContextIds, reserving a range
+ * of 20000 - 29999 for userContextIds to be used within calendar.
+ *
+ * @param {number} min - The lower range limit of userContextIds to be
+ * used.
+ * @param {number} max - The upper range limit of userContextIds to be
+ * used.
+ */
+ containerMap: new ContainerMap(20000, 29999),
+};
+
+// Cache for authentication information since onAuthInformation in the prompt
+// listener is called without further information. If the password is not
+// saved, there is no way to retrieve it. We use ref counting to avoid keeping
+// the password in memory longer than needed.
+var gAuthCache = {
+ _authInfoCache: new Map(),
+ planForAuthInfo(hostKey) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.refCnt++;
+ } else {
+ this._authInfoCache.set(hostKey, { refCnt: 1 });
+ }
+ },
+
+ setAuthInfo(hostKey, aAuthInfo) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.username = aAuthInfo.username;
+ authInfo.password = aAuthInfo.password;
+ }
+ },
+
+ retrieveAuthInfo(hostKey) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.refCnt--;
+
+ if (authInfo.refCnt == 0) {
+ this._authInfoCache.delete(hostKey);
+ }
+ }
+ return authInfo;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calCategoryUtils.jsm b/comm/calendar/base/modules/utils/calCategoryUtils.jsm
new file mode 100644
index 0000000000..c06708b61b
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calCategoryUtils.jsm
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helpers for reading and writing calendar categories
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.category namespace.
+
+const EXPORTED_SYMBOLS = ["calcategory"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calcategory = {
+ /**
+ * Sets up the default categories from the localized string
+ *
+ * @returns The default set of categories as a comma separated string.
+ */
+ setupDefaultCategories() {
+ let defaultBranch = Services.prefs.getDefaultBranch("");
+
+ // First, set up the category names
+ let categories = lazy.cal.l10n.getString("categories", "categories2");
+ defaultBranch.setStringPref("calendar.categories.names", categories);
+
+ // Now, initialize the category default colors
+ let categoryArray = calcategory.stringToArray(categories);
+ for (let category of categoryArray) {
+ let prefName = lazy.cal.view.formatStringForCSSRule(category);
+ defaultBranch.setStringPref(
+ "calendar.category.color." + prefName,
+ lazy.cal.view.hashColor(category)
+ );
+ }
+
+ // Return the list of categories for further processing
+ return categories;
+ },
+
+ /**
+ * Get array of category names from preferences or locale default,
+ * unescaping any commas in each category name.
+ *
+ * @returns array of category names
+ */
+ fromPrefs() {
+ let categories = Services.prefs.getStringPref("calendar.categories.names", null);
+
+ // If no categories are configured load a default set from properties file
+ if (!categories) {
+ categories = calcategory.setupDefaultCategories();
+ }
+ return calcategory.stringToArray(categories);
+ },
+
+ /**
+ * Convert categories string to list of category names.
+ *
+ * Stored categories may include escaped commas within a name. Split
+ * categories string at commas, but not at escaped commas (\,). Afterward,
+ * replace escaped commas (\,) with commas (,) in each name.
+ *
+ * @param aCategoriesPrefValue string from "calendar.categories.names" pref,
+ * which may contain escaped commas (\,) in names.
+ * @returns list of category names
+ */
+ stringToArray(aCategories) {
+ if (!aCategories) {
+ return [];
+ }
+ /* eslint-disable no-control-regex */
+ // \u001A is the unicode "SUBSTITUTE" character
+ let categories = aCategories
+ .replace(/\\,/g, "\u001A")
+ .split(",")
+ .map(name => name.replace(/\u001A/g, ","));
+ /* eslint-enable no-control-regex */
+ if (categories.length == 1 && categories[0] == "") {
+ // Split will return an array with an empty element when splitting an
+ // empty string, correct this.
+ categories.pop();
+ }
+ return categories;
+ },
+
+ /**
+ * Convert array of category names to string.
+ *
+ * Category names may contain commas (,). Escape commas (\,) in each, then
+ * join them in comma separated string for storage.
+ *
+ * @param aSortedCategoriesArray sorted array of category names, may
+ * contain unescaped commas, which will
+ * be escaped in combined string.
+ */
+ arrayToString(aSortedCategoriesArray) {
+ return aSortedCategoriesArray.map(cat => cat.replace(/,/g, "\\,")).join(",");
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calDataUtils.jsm b/comm/calendar/base/modules/utils/calDataUtils.jsm
new file mode 100644
index 0000000000..be37a876d3
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDataUtils.jsm
@@ -0,0 +1,313 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Data structures and algorithms used within the codebase
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.data namespace.
+
+const EXPORTED_SYMBOLS = ["caldata"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+class ListenerSet extends Set {
+ constructor(iid, iterable) {
+ super(iterable);
+ this.mIID = iid;
+ }
+
+ add(item) {
+ super.add(item.QueryInterface(this.mIID));
+ }
+
+ has(item) {
+ return super.has(item.QueryInterface(this.mIID));
+ }
+
+ delete(item) {
+ super.delete(item.QueryInterface(this.mIID));
+ }
+
+ notify(func, args = []) {
+ let currentObservers = [...this.values()];
+ for (let observer of currentObservers) {
+ try {
+ observer[func](...args);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+}
+
+class ObserverSet extends ListenerSet {
+ constructor(iid, iterable) {
+ super(iid, iterable);
+ this.mCalendarsInBatch = new Set();
+ }
+
+ get batchCount() {
+ return this.mCalendarsInBatch.size;
+ }
+
+ notify(func, args = []) {
+ switch (func) {
+ case "onStartBatch":
+ this.mCalendarsInBatch.add(args[0]);
+ break;
+ case "onEndBatch":
+ this.mCalendarsInBatch.delete(args[0]);
+ break;
+ }
+ return super.notify(func, args);
+ }
+
+ add(item) {
+ if (!this.has(item)) {
+ // Replay batch notifications, because the onEndBatch notifications are yet to come.
+ // We may think about doing the reverse on remove, though I currently see no need:
+ for (let calendar of this.mCalendarsInBatch) {
+ item.onStartBatch(calendar);
+ }
+ }
+ super.add(item);
+ }
+}
+
+/**
+ * This object implements calIOperation and could group multiple sub
+ * operations into one. You can pass a cancel function which is called once
+ * the operation group is cancelled.
+ * Users must call notifyCompleted() once all sub operations have been
+ * successful, else the operation group will stay pending.
+ * The reason for the latter is that providers currently should (but need
+ * not) implement (and return) calIOperation handles, thus there may be pending
+ * calendar operations (without handle).
+ */
+class OperationGroup {
+ static nextGroupId() {
+ if (typeof OperationGroup.mOpGroupId == "undefined") {
+ OperationGroup.mOpGroupId = 0;
+ }
+
+ return OperationGroup.mOpGroupId++;
+ }
+
+ constructor(aCancelFunc) {
+ this.mId = lazy.cal.getUUID() + "-" + OperationGroup.nextGroupId();
+ this.mIsPending = true;
+
+ this.mCancelFunc = aCancelFunc;
+ this.mSubOperations = [];
+ this.mStatus = Cr.NS_OK;
+ }
+
+ get id() {
+ return this.mId;
+ }
+ get isPending() {
+ return this.mIsPending;
+ }
+ get status() {
+ return this.mStatus;
+ }
+ get isEmpty() {
+ return this.mSubOperations.length == 0;
+ }
+
+ add(aOperation) {
+ if (aOperation && aOperation.isPending) {
+ this.mSubOperations.push(aOperation);
+ }
+ }
+
+ remove(aOperation) {
+ if (aOperation) {
+ this.mSubOperations = this.mSubOperations.filter(operation => aOperation.id != operation.id);
+ }
+ }
+
+ notifyCompleted(aStatus) {
+ lazy.cal.ASSERT(this.isPending, "[OperationGroup_notifyCompleted] this.isPending");
+ if (this.isPending) {
+ this.mIsPending = false;
+ if (aStatus) {
+ this.mStatus = aStatus;
+ }
+ }
+ }
+
+ cancel(aStatus = Ci.calIErrors.OPERATION_CANCELLED) {
+ if (this.isPending) {
+ this.notifyCompleted(aStatus);
+ let cancelFunc = this.mCancelFunc;
+ if (cancelFunc) {
+ this.mCancelFunc = null;
+ cancelFunc();
+ }
+ let subOperations = this.mSubOperations;
+ this.mSubOperations = [];
+ for (let operation of subOperations) {
+ operation.cancel(Ci.calIErrors.OPERATION_CANCELLED);
+ }
+ }
+ }
+
+ toString() {
+ return `[OperationGroup id=${this.id}]`;
+ }
+}
+
+var caldata = {
+ ListenerSet,
+ ObserverSet,
+ OperationGroup,
+
+ /**
+ * Use the binary search algorithm to search for an item in an array.
+ * function.
+ *
+ * The comptor function may look as follows for calIDateTime objects.
+ * function comptor(a, b) {
+ * return a.compare(b);
+ * }
+ * If no comptor is specified, the default greater-than comptor will be used.
+ *
+ * @param itemArray The array to search.
+ * @param newItem The item to search in the array.
+ * @param comptor A comparison function that can compare two items.
+ * @returns The index of the new item.
+ */
+ binarySearch(itemArray, newItem, comptor) {
+ function binarySearchInternal(low, high) {
+ // Are we done yet?
+ if (low == high) {
+ return low + (comptor(newItem, itemArray[low]) < 0 ? 0 : 1);
+ }
+
+ let mid = Math.floor(low + (high - low) / 2);
+ let cmp = comptor(newItem, itemArray[mid]);
+ if (cmp > 0) {
+ return binarySearchInternal(mid + 1, high);
+ } else if (cmp < 0) {
+ return binarySearchInternal(low, mid);
+ }
+ return mid;
+ }
+
+ if (itemArray.length < 1) {
+ return -1;
+ }
+ if (!comptor) {
+ comptor = function (a, b) {
+ return (a > b) - (a < b);
+ };
+ }
+ return binarySearchInternal(0, itemArray.length - 1);
+ },
+
+ /**
+ * Insert a new node underneath the given parentNode, using binary search. See binarySearch
+ * for a note on how the comptor works.
+ *
+ * @param parentNode The parent node underneath the new node should be inserted.
+ * @param inserNode The node to insert
+ * @param aItem The calendar item to add a widget for.
+ * @param comptor A comparison function that can compare two items (not DOM Nodes!)
+ * @param discardDuplicates Use the comptor function to check if the item in
+ * question is already in the array. If so, the
+ * new item is not inserted.
+ * @param itemAccessor [optional] A function that receives a DOM node and returns the associated item
+ * If null, this function will be used: function(n) n.item
+ */
+ binaryInsertNode(parentNode, insertNode, aItem, comptor, discardDuplicates, itemAccessor) {
+ let accessor = itemAccessor || caldata.binaryInsertNodeDefaultAccessor;
+
+ // Get the index of the node before which the inserNode will be inserted
+ let newIndex = caldata.binarySearch(Array.from(parentNode.children, accessor), aItem, comptor);
+
+ if (newIndex < 0) {
+ parentNode.appendChild(insertNode);
+ newIndex = 0;
+ } else if (
+ !discardDuplicates ||
+ comptor(
+ accessor(parentNode.children[Math.min(newIndex, parentNode.children.length - 1)]),
+ aItem
+ ) >= 0
+ ) {
+ // Only add the node if duplicates should not be discarded, or if
+ // they should and the childNode[newIndex] == node.
+ let node = parentNode.children[newIndex];
+ parentNode.insertBefore(insertNode, node);
+ }
+ return newIndex;
+ },
+ binaryInsertNodeDefaultAccessor: n => n.item,
+
+ /**
+ * Insert an item into the given array, using binary search. See binarySearch
+ * for a note on how the comptor works.
+ *
+ * @param itemArray The array to insert into.
+ * @param item The item to insert into the array.
+ * @param comptor A comparison function that can compare two items.
+ * @param discardDuplicates Use the comptor function to check if the item in
+ * question is already in the array. If so, the
+ * new item is not inserted.
+ * @returns The index of the new item.
+ */
+ binaryInsert(itemArray, item, comptor, discardDuplicates) {
+ let newIndex = caldata.binarySearch(itemArray, item, comptor);
+
+ if (newIndex < 0) {
+ itemArray.push(item);
+ newIndex = 0;
+ } else if (
+ !discardDuplicates ||
+ comptor(itemArray[Math.min(newIndex, itemArray.length - 1)], item) != 0
+ ) {
+ // Only add the item if duplicates should not be discarded, or if
+ // they should and itemArray[newIndex] != item.
+ itemArray.splice(newIndex, 0, item);
+ }
+ return newIndex;
+ },
+
+ /**
+ * Generic object comparer
+ * Use to compare two objects which are not of type calIItemBase, in order
+ * to avoid the js-wrapping issues mentioned above.
+ *
+ * @param aObject first object to be compared
+ * @param aOtherObject second object to be compared
+ * @param aIID IID to use in comparison, undefined/null defaults to nsISupports
+ */
+ compareObjects(aObject, aOtherObject, aIID) {
+ // xxx todo: seems to work fine, but I still mistrust this trickery...
+ // Anybody knows an official API that could be used for this purpose?
+ // For what reason do clients need to pass aIID since
+ // every XPCOM object has to implement nsISupports?
+ // XPCOM (like COM, like UNO, ...) defines that QueryInterface *only* needs to return
+ // the very same pointer for nsISupports during its lifetime.
+ if (!aIID) {
+ aIID = Ci.nsISupports;
+ }
+ let sip1 = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ sip1.data = aObject;
+ sip1.dataIID = aIID;
+
+ let sip2 = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ sip2.data = aOtherObject;
+ sip2.dataIID = aIID;
+ return sip1.data == sip2.data;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm b/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm
new file mode 100644
index 0000000000..42df519e22
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm
@@ -0,0 +1,620 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(lazy, "gDateStringBundle", () =>
+ Services.strings.createBundle("chrome://calendar/locale/dateFormat.properties")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "dateFormat", "calendar.date.format", 0);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "timeBeforeDate",
+ "calendar.date.formatTimeBeforeDate",
+ false
+);
+
+/** Cache of calls to new Services.intl.DateTimeFormat. */
+var formatCache = new Map();
+
+/*
+ * Date time formatting functions for display.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.dtz.formatter namespace.
+
+const EXPORTED_SYMBOLS = ["formatter"];
+
+var formatter = {
+ /**
+ * Format a date in either short or long format, depending on the users preference.
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDate(aDate) {
+ // Format the date using user's format preference (long or short)
+ return lazy.dateFormat == 0 ? this.formatDateLong(aDate) : this.formatDateShort(aDate);
+ },
+
+ /**
+ * Format a date into a short format, for example "12/17/2005".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateShort(aDate) {
+ return formatDateTimeWithOptions(aDate, { dateStyle: "short" });
+ },
+
+ /**
+ * Format a date into a long format, for example "Sat Dec 17 2005".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateLong(aDate) {
+ return formatDateTimeWithOptions(aDate, { dateStyle: "full" });
+ },
+
+ /**
+ * Format a date into a short format without mentioning the year, for example "Dec 17"
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateWithoutYear(aDate) {
+ return formatDateTimeWithOptions(aDate, { month: "short", day: "numeric" });
+ },
+
+ /**
+ * Format a date into a long format without mentioning the year, for example
+ * "Monday, December 17".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateLongWithoutYear(aDate) {
+ return formatDateTimeWithOptions(aDate, { weekday: "long", month: "long", day: "numeric" });
+ },
+
+ /**
+ * Format the time portion of a date-time object. Note: only the hour and
+ * minutes are shown.
+ *
+ * @param {calIDateTime} time - The date-time to format the time of.
+ * @param {boolean} [preferEndOfDay = false] - Whether to prefer showing a
+ * midnight time as the end of a day, rather than the start of the day, if
+ * the time formatting allows for it. I.e. if the formatter would use a
+ * 24-hour format, then this would show midnight as 24:00, rather than
+ * 00:00.
+ *
+ * @returns {string} A string representing the time.
+ */
+ formatTime(time, preferEndOfDay = false) {
+ if (time.isDate) {
+ return lazy.gDateStringBundle.GetStringFromName("AllDay");
+ }
+
+ const options = { timeStyle: "short" };
+ if (preferEndOfDay && time.hour == 0 && time.minute == 0) {
+ // Midnight. Note that the timeStyle is short, so we don't test for
+ // seconds.
+ // Test what hourCycle the default formatter would use.
+ if (getFormatter(options).resolvedOptions().hourCycle == "h23") {
+ // Midnight start-of-day is 00:00, so we can show midnight end-of-day
+ // as 24:00.
+ options.hourCycle = "h24";
+ }
+ // NOTE: Regarding the other hourCycle values:
+ // + "h24": This is not expected in any locale.
+ // + "h12": In a 12-hour format that cycles 12 -> 1 -> ... -> 11, there is
+ // no convention to distinguish between midnight start-of-day and
+ // midnight end-of-day. So we do nothing.
+ // + "h11": The ja-JP locale with a 12-hour format returns this. In this
+ // locale, midnight start-of-day is shown as "εˆε‰0:00" (i.e. 0 AM),
+ // which means midnight end-of-day can be shown as "午後12:00" (12 PM).
+ // However, Intl.DateTimeFormatter does not expose a means to do this.
+ // Just forcing a h12 hourCycle will show midnight as "εˆε‰12:00", which
+ // would be incorrect in this locale. Therefore, we similarly do nothing
+ // in this case as well.
+ }
+
+ return formatDateTimeWithOptions(time, options);
+ },
+
+ /**
+ * Format a datetime into the format specified by the OS settings. Will omit the seconds from the
+ * output.
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the datetime.
+ */
+ formatDateTime(aDate) {
+ let formattedDate = this.formatDate(aDate);
+ let formattedTime = this.formatTime(aDate);
+
+ if (lazy.timeBeforeDate) {
+ return formattedTime + " " + formattedDate;
+ }
+ return formattedDate + " " + formattedTime;
+ },
+
+ /**
+ * Format a time interval like formatInterval, but show only the time.
+ *
+ * @param {calIDateTime} aStartDate - The start of the interval.
+ * @param {calIDateTime} aEndDate - The end of the interval.
+ * @returns {string} The formatted time interval.
+ */
+ formatTimeInterval(aStartDate, aEndDate) {
+ if (!aStartDate && aEndDate) {
+ return this.formatTime(aEndDate);
+ }
+ if (!aEndDate && aStartDate) {
+ return this.formatTime(aStartDate);
+ }
+ if (!aStartDate && !aEndDate) {
+ return "";
+ }
+
+ // TODO do we need l10n for this?
+ // TODO should we check for the same day? The caller should know what
+ // he is doing...
+ return this.formatTime(aStartDate) + "\u2013" + this.formatTime(aEndDate);
+ },
+
+ /**
+ * Format a date/time interval to a string. The returned string may assume
+ * that the dates are so close to each other, that it can leave out some parts
+ * of the part string denoting the end date.
+ *
+ * @param {calIDateTime} startDate - The start of the interval.
+ * @param {calIDateTime} endDate - The end of the interval.
+ * @returns {string} - A string describing the interval in a legible form.
+ */
+ formatInterval(startDate, endDate) {
+ let format = this.formatIntervalParts(startDate, endDate);
+ switch (format.type) {
+ case "task-without-dates":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDate");
+
+ case "task-without-due-date":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDueDate", [
+ format.startDate,
+ format.startTime,
+ ]);
+
+ case "task-without-start-date":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutStartDate", [
+ format.endDate,
+ format.endTime,
+ ]);
+
+ case "all-day":
+ return format.startDate;
+
+ case "all-day-between-years":
+ return lazy.cal.l10n.getCalString("daysIntervalBetweenYears", [
+ format.startMonth,
+ format.startDay,
+ format.startYear,
+ format.endMonth,
+ format.endDay,
+ format.endYear,
+ ]);
+
+ case "all-day-in-month":
+ return lazy.cal.l10n.getCalString("daysIntervalInMonth", [
+ format.month,
+ format.startDay,
+ format.endDay,
+ format.year,
+ ]);
+
+ case "all-day-between-months":
+ return lazy.cal.l10n.getCalString("daysIntervalBetweenMonths", [
+ format.startMonth,
+ format.startDay,
+ format.endMonth,
+ format.endDay,
+ format.year,
+ ]);
+
+ case "same-date-time":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDateTime", [
+ format.startDate,
+ format.startTime,
+ ]);
+
+ case "same-day":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDay", [
+ format.startDate,
+ format.startTime,
+ format.endTime,
+ ]);
+
+ case "several-days":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSeveralDays", [
+ format.startDate,
+ format.startTime,
+ format.endDate,
+ format.endTime,
+ ]);
+ default:
+ return "";
+ }
+ },
+
+ /**
+ * Object used to describe the parts of a formatted interval.
+ *
+ * @typedef {object} IntervalParts
+ * @property {string} type
+ * Used to distinguish IntervalPart results.
+ * @property {string?} startDate
+ * The full date of the start of the interval.
+ * @property {string?} startTime
+ * The time part of the start of the interval.
+ * @property {string?} startDay
+ * The day (of the month) the interval starts on.
+ * @property {string?} startMonth
+ * The month the interval starts on.
+ * @property {string?} startYear
+ * The year interval starts on.
+ * @property {string?} endDate
+ * The full date of the end of the interval.
+ * @property {string?} endTime
+ * The time part of the end of the interval.
+ * @property {string?} endDay
+ * The day (of the month) the interval ends on.
+ * @property {string?} endMonth
+ * The month the interval ends on.
+ * @property {string?} endYear
+ * The year interval ends on.
+ * @property {string?} month
+ * The month the interval occurs in when the start is all day and the
+ * interval does not span multiple months.
+ * @property {string?} year
+ * The year the interval occurs in when the the start is all day and the
+ * interval does not span multiple years.
+ */
+
+ /**
+ * Format a date interval into various parts suitable for building
+ * strings that describe the interval. This result may leave out some parts of
+ * either date based on the closeness of the two.
+ *
+ * @param {calIDateTime} startDate - The start of the interval.
+ * @param {calIDateTime} endDate - The end of the interval.
+ * @returns {IntervalParts} An object to be used to create an
+ * interval string.
+ */
+ formatIntervalParts(startDate, endDate) {
+ if (endDate == null && startDate == null) {
+ return { type: "task-without-dates" };
+ }
+
+ if (endDate == null) {
+ return {
+ type: "task-without-due-date",
+ startDate: this.formatDate(startDate),
+ startTime: this.formatTime(startDate),
+ };
+ }
+
+ if (startDate == null) {
+ return {
+ type: "task-without-start-date",
+ endDate: this.formatDate(endDate),
+ endTime: this.formatTime(endDate),
+ };
+ }
+
+ // Here there are only events or tasks with both start and due date.
+ // make sure start and end use the same timezone when formatting intervals:
+ let testdate = startDate.clone();
+ testdate.isDate = true;
+ let originalEndDate = endDate.clone();
+ endDate = endDate.getInTimezone(startDate.timezone);
+ let sameDay = testdate.compare(endDate) == 0;
+ if (startDate.isDate) {
+ // All-day interval, so we should leave out the time part
+ if (sameDay) {
+ return {
+ type: "all-day",
+ startDate: this.formatDateLong(startDate),
+ };
+ }
+
+ let startDay = this.formatDayWithOrdinal(startDate.day);
+ let startYear = String(startDate.year);
+ let endDay = this.formatDayWithOrdinal(endDate.day);
+ let endYear = String(endDate.year);
+ if (startDate.year != endDate.year) {
+ return {
+ type: "all-day-between-years",
+ startDay,
+ startMonth: lazy.cal.l10n.formatMonth(
+ startDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenYears"
+ ),
+ startYear,
+ endDay,
+ endMonth: lazy.cal.l10n.formatMonth(
+ originalEndDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenYears"
+ ),
+ endYear,
+ };
+ }
+
+ if (startDate.month == endDate.month) {
+ return {
+ type: "all-day-in-month",
+ startDay,
+ month: lazy.cal.l10n.formatMonth(startDate.month + 1, "calendar", "daysIntervalInMonth"),
+ endDay,
+ year: endYear,
+ };
+ }
+
+ return {
+ type: "all-day-between-months",
+ startDay,
+ startMonth: lazy.cal.l10n.formatMonth(
+ startDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenMonths"
+ ),
+ endDay,
+ endMonth: lazy.cal.l10n.formatMonth(
+ originalEndDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenMonths"
+ ),
+ year: endYear,
+ };
+ }
+
+ let startDateString = this.formatDate(startDate);
+ let startTime = this.formatTime(startDate);
+ let endDateString = this.formatDate(endDate);
+ let endTime = this.formatTime(endDate);
+ // non-allday, so need to return date and time
+ if (sameDay) {
+ // End is on the same day as start, so we can leave out the end date
+ if (startTime == endTime) {
+ // End time is on the same time as start, so we can leave out the end time
+ // "5 Jan 2006 13:00"
+ return {
+ type: "same-date-time",
+ startDate: startDateString,
+ startTime,
+ };
+ }
+ // still include end time
+ // "5 Jan 2006 13:00 - 17:00"
+ return {
+ type: "same-day",
+ startDate: startDateString,
+ startTime,
+ endTime,
+ };
+ }
+
+ // Spanning multiple days, so need to include date and time
+ // for start and end
+ // "5 Jan 2006 13:00 - 7 Jan 2006 9:00"
+ return {
+ type: "several-days",
+ startDate: startDateString,
+ startTime,
+ endDate: endDateString,
+ endTime,
+ };
+ },
+
+ /**
+ * Get the monthday followed by its ordinal symbol in the current locale.
+ * e.g. monthday 1 -> 1st
+ * monthday 2 -> 2nd etc.
+ *
+ * @param {number} aDay - A number from 1 to 31.
+ * @returns {string} The monthday number in ordinal format in the current locale.
+ */
+ formatDayWithOrdinal(aDay) {
+ let ordinalSymbols = lazy.gDateStringBundle.GetStringFromName("dayOrdinalSymbol").split(",");
+ let dayOrdinalSymbol = ordinalSymbols[aDay - 1] || ordinalSymbols[0];
+ return aDay + dayOrdinalSymbol;
+ },
+
+ /**
+ * Helper to get the start/end dates for a given item.
+ *
+ * @param {calIItemBase} item - The item to get the dates for.
+ * @returns {[calIDateTime, calIDateTime]} An array with start and end date.
+ */
+ getItemDates(item) {
+ let start = item[lazy.cal.dtz.startDateProp(item)];
+ let end = item[lazy.cal.dtz.endDateProp(item)];
+ let kDefaultTimezone = lazy.cal.dtz.defaultTimezone;
+ // Check for tasks without start and/or due date
+ if (start) {
+ start = start.getInTimezone(kDefaultTimezone);
+ }
+ if (end) {
+ end = end.getInTimezone(kDefaultTimezone);
+ }
+ // EndDate is exclusive. For all-day events, we need to subtract one day,
+ // to get into a format that's understandable.
+ if (start && start.isDate && end) {
+ end.day -= 1;
+ }
+
+ return [start, end];
+ },
+
+ /**
+ * Format an interval that is defined by an item with the default timezone.
+ *
+ * @param {calIItemBase} aItem - The item describing the interval.
+ * @returns {string} The formatted item interval.
+ */
+ formatItemInterval(aItem) {
+ return this.formatInterval(...this.getItemDates(aItem));
+ },
+
+ /**
+ * Format a time interval like formatItemInterval, but only show times.
+ *
+ * @param {calIItemBase} aItem - The item describing the interval.
+ * @returns {string} The formatted item interval.
+ */
+ formatItemTimeInterval(aItem) {
+ return this.formatTimeInterval(...this.getItemDates(aItem));
+ },
+
+ /**
+ * Get the month name.
+ *
+ * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
+ * @returns {string} The month name in the current locale.
+ */
+ monthName(aMonthIndex) {
+ let oneBasedMonthIndex = aMonthIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".name");
+ },
+
+ /**
+ * Get the abbreviation of the month name.
+ *
+ * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
+ * @returns {string} The abbreviated month name in the current locale.
+ */
+ shortMonthName(aMonthIndex) {
+ let oneBasedMonthIndex = aMonthIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".Mmm");
+ },
+
+ /**
+ * Get the day name.
+ *
+ * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
+ * @returns {string} The day name in the current locale.
+ */
+ dayName(aDayIndex) {
+ let oneBasedDayIndex = aDayIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".name");
+ },
+
+ /**
+ * Get the abbreviation of the day name.
+ *
+ * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
+ * @returns {string} The abbrevidated day name in the current locale.
+ */
+ shortDayName(aDayIndex) {
+ let oneBasedDayIndex = aDayIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".Mmm");
+ },
+};
+
+/**
+ * Determine whether a datetime is specified relative to the user, i.e. a date
+ * or floating datetime, both of which should be displayed the same regardless
+ * of the user's time zone.
+ *
+ * @param {calIDateTime} dateTime The datetime object to check.
+ * @returns {boolean}
+ */
+function isDateTimeRelativeToUser(dateTime) {
+ return dateTime.isDate || dateTime.timezone.isFloating;
+}
+
+/**
+ * Format a datetime object as a string with a given set of formatting options.
+ *
+ * @param {calIDateTime} dateTime The datetime object to be formatted.
+ * @param {object} options
+ * The set of Intl.DateTimeFormat options to use for formatting.
+ * @returns {string} A formatted string representing the given datetime.
+ */
+function formatDateTimeWithOptions(dateTime, options) {
+ const jsDate = getDateTimeAsAdjustedJsDate(dateTime);
+
+ // We want floating datetimes and dates to be formatted without regard to
+ // timezone; everything else has been adjusted so that "UTC" will produce the
+ // correct result because we cannot guarantee that the datetime's timezone is
+ // supported by Gecko.
+ const timezone = isDateTimeRelativeToUser(dateTime) ? undefined : "UTC";
+
+ return getFormatter({ ...options, timeZone: timezone }).format(jsDate);
+}
+
+/**
+ * Convert a calendar datetime object to a JavaScript standard Date adjusted
+ * for timezone offset.
+ *
+ * @param {calIDateTime} dateTime The datetime object to convert and adjust.
+ * @returns {Date} The standard JS equivalent of the given datetime, offset
+ * from UTC according to the datetime's timezone.
+ */
+function getDateTimeAsAdjustedJsDate(dateTime) {
+ const unadjustedJsDate = lazy.cal.dtz.dateTimeToJsDate(dateTime);
+
+ // If the datetime is date-only, it doesn't make sense to adjust for timezone.
+ // Floating datetimes likewise are not fixed in a single timezone.
+ if (isDateTimeRelativeToUser(dateTime)) {
+ return unadjustedJsDate;
+ }
+
+ // We abuse `Date` slightly here: its internal representation is intended to
+ // contain the date as seconds from the epoch, but `Intl` relies on adjusting
+ // timezone and we can't be sure we have a recognized timezone ID. Instead, we
+ // force the internal representation to compensate for timezone offset.
+ const offsetInMs = dateTime.timezoneOffset * 1000;
+ return new Date(unadjustedJsDate.valueOf() + offsetInMs);
+}
+
+/**
+ * Get a formatter that can be used to format a date-time in a
+ * locale-appropriate way.
+ *
+ * NOTE: formatters are cached for future requests.
+ *
+ * @param {object} formatOptions - Intl.DateTimeFormatter options.
+ *
+ * @returns {DateTimeFormatter} - The formatter.
+ */
+function getFormatter(formatOptions) {
+ let cacheKey = JSON.stringify(formatOptions);
+ if (formatCache.has(cacheKey)) {
+ return formatCache.get(cacheKey);
+ }
+
+ // Use en-US when running in a test to make the result independent of the test
+ // machine.
+ let locale = Services.appinfo.name == "xpcshell" ? "en-US" : undefined;
+ let formatter;
+ if ("hourCycle" in formatOptions) {
+ // FIXME: The hourCycle property is currently ignored by Services.intl, so
+ // we use Intl instead. Once bug 1749459 is closed, we should only use
+ // Services.intl again.
+ formatter = new Intl.DateTimeFormat(locale, formatOptions);
+ } else {
+ formatter = new Services.intl.DateTimeFormat(locale, formatOptions);
+ }
+
+ formatCache.set(cacheKey, formatter);
+ return formatter;
+}
diff --git a/comm/calendar/base/modules/utils/calDateTimeUtils.jsm b/comm/calendar/base/modules/utils/calDateTimeUtils.jsm
new file mode 100644
index 0000000000..5ea62313b7
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDateTimeUtils.jsm
@@ -0,0 +1,430 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Date, time and timezone related functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.dtz namespace.
+
+const EXPORTED_SYMBOLS = ["caldtz"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var caldtz = {
+ /**
+ * Shortcut to the timezone service's defaultTimezone
+ */
+ get defaultTimezone() {
+ return lazy.cal.timezoneService.defaultTimezone;
+ },
+
+ /**
+ * Shorcut to the UTC timezone
+ */
+ get UTC() {
+ return lazy.cal.timezoneService.UTC;
+ },
+
+ /**
+ * Shortcut to the floating (local) timezone
+ */
+ get floating() {
+ return lazy.cal.timezoneService.floating;
+ },
+
+ /**
+ * Makes sure the given timezone id is part of the list of recent timezones.
+ *
+ * @param aTzid The timezone id to add
+ */
+ saveRecentTimezone(aTzid) {
+ let recentTimezones = caldtz.getRecentTimezones();
+ const MAX_RECENT_TIMEZONES = 5; // We don't need a pref for *everything*.
+
+ if (aTzid != caldtz.defaultTimezone.tzid && !recentTimezones.includes(aTzid)) {
+ // Add the timezone if its not already the default timezone
+ recentTimezones.unshift(aTzid);
+ recentTimezones.splice(MAX_RECENT_TIMEZONES);
+ Services.prefs.setStringPref("calendar.timezone.recent", JSON.stringify(recentTimezones));
+ }
+ },
+
+ /**
+ * Returns a calIDateTime that corresponds to the current time in the user's
+ * default timezone.
+ */
+ now() {
+ let date = caldtz.jsDateToDateTime(new Date());
+ return date.getInTimezone(caldtz.defaultTimezone);
+ },
+
+ /**
+ * Get the default event start date. This is the next full hour, or 23:00 if it
+ * is past 23:00.
+ *
+ * @param aReferenceDate If passed, the time of this date will be modified,
+ * keeping the date and timezone intact.
+ */
+ getDefaultStartDate(aReferenceDate) {
+ let startDate = caldtz.now();
+ if (aReferenceDate) {
+ let savedHour = startDate.hour;
+ startDate = aReferenceDate;
+ if (!startDate.isMutable) {
+ startDate = startDate.clone();
+ }
+ startDate.isDate = false;
+ startDate.hour = savedHour;
+ }
+
+ startDate.second = 0;
+ startDate.minute = 0;
+ if (startDate.hour < 23) {
+ startDate.hour++;
+ }
+ return startDate;
+ },
+
+ /**
+ * Setup the default start and end hours of the given item. This can be a task
+ * or an event.
+ *
+ * @param aItem The item to set up the start and end date for.
+ * @param aReferenceDate If passed, the time of this date will be modified,
+ * keeping the date and timezone intact.
+ */
+ setDefaultStartEndHour(aItem, aReferenceDate) {
+ aItem[caldtz.startDateProp(aItem)] = caldtz.getDefaultStartDate(aReferenceDate);
+
+ if (aItem.isEvent()) {
+ aItem.endDate = aItem.startDate.clone();
+ aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ }
+ },
+
+ /**
+ * Returns the property name used for the start date of an item, ie either an
+ * event's start date or a task's entry date.
+ */
+ startDateProp(aItem) {
+ if (aItem) {
+ if (aItem.isEvent()) {
+ return "startDate";
+ } else if (aItem.isTodo()) {
+ return "entryDate";
+ }
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /**
+ * Returns the property name used for the end date of an item, ie either an
+ * event's end date or a task's due date.
+ */
+ endDateProp(aItem) {
+ if (aItem) {
+ if (aItem.isEvent()) {
+ return "endDate";
+ } else if (aItem.isTodo()) {
+ return "dueDate";
+ }
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /**
+ * Check if the two dates are on the same day (ignoring time)
+ *
+ * @param date1 The left date to compare
+ * @param date2 The right date to compare
+ * @returns True, if dates are on the same day
+ */
+ sameDay(date1, date2) {
+ if (date1 && date2) {
+ if (date1.day == date2.day && date1.month == date2.month && date1.year == date2.year) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Many computations want to work only with date-times, not with dates. This
+ * method will return a proper datetime (set to midnight) for a date object. If
+ * the object is already a datetime, it will simply be returned.
+ *
+ * @param aDate the date or datetime to check
+ */
+ ensureDateTime(aDate) {
+ if (!aDate || !aDate.isDate) {
+ return aDate;
+ }
+ let newDate = aDate.clone();
+ newDate.isDate = false;
+ return newDate;
+ },
+
+ /**
+ * Returns a calIDateTime corresponding to a javascript Date.
+ *
+ * @param aDate a javascript date
+ * @param aTimezone (optional) a timezone that should be enforced
+ * @returns a calIDateTime
+ *
+ * @warning Use of this function is strongly discouraged. calIDateTime should
+ * be used directly whenever possible.
+ * If you pass a timezone, then the passed jsDate's timezone will be ignored,
+ * but only its local time portions are be taken.
+ */
+ jsDateToDateTime(aDate, aTimezone) {
+ let newDate = lazy.cal.createDateTime();
+ if (aTimezone) {
+ newDate.resetTo(
+ aDate.getFullYear(),
+ aDate.getMonth(),
+ aDate.getDate(),
+ aDate.getHours(),
+ aDate.getMinutes(),
+ aDate.getSeconds(),
+ aTimezone
+ );
+ } else {
+ newDate.resetTo(
+ aDate.getUTCFullYear(),
+ aDate.getUTCMonth(),
+ aDate.getUTCDate(),
+ aDate.getUTCHours(),
+ aDate.getUTCMinutes(),
+ aDate.getUTCSeconds(),
+ // Use the existing timezone instead of caldtz.UTC, or starting the
+ // timezone service becomes a requirement in tests.
+ newDate.timezone
+ );
+ }
+ return newDate;
+ },
+
+ /**
+ * Convert a calIDateTime to a Javascript date object. This is the
+ * replacement for the former .jsDate property.
+ *
+ * @param cdt The calIDateTime instance
+ * @returns The Javascript date equivalent.
+ */
+ dateTimeToJsDate(cdt) {
+ if (cdt.isDate) {
+ return new Date(cdt.year, cdt.month, cdt.day);
+ }
+
+ if (cdt.timezone.isFloating) {
+ return new Date(cdt.year, cdt.month, cdt.day, cdt.hour, cdt.minute, cdt.second);
+ }
+ return new Date(cdt.nativeTime / 1000);
+ },
+
+ /**
+ * fromRFC3339
+ * Convert a RFC3339 compliant Date string to a calIDateTime.
+ *
+ * @param aStr The RFC3339 compliant Date String
+ * @param aTimezone The timezone this date string is most likely in
+ * @returns A calIDateTime object
+ */
+ fromRFC3339(aStr, aTimezone) {
+ // XXX I have not covered leapseconds (matches[8]), this might need to
+ // be done. The only reference to leap seconds I found is bug 227329.
+ let dateTime = lazy.cal.createDateTime();
+
+ // Killer regex to parse RFC3339 dates
+ let re = new RegExp(
+ "^([0-9]{4})-([0-9]{2})-([0-9]{2})" +
+ "([Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\\.[0-9]+)?)?" +
+ "(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?"
+ );
+
+ let matches = re.exec(aStr);
+
+ if (!matches) {
+ return null;
+ }
+
+ // Set usual date components
+ dateTime.isDate = matches[4] == null;
+
+ dateTime.year = matches[1];
+ dateTime.month = matches[2] - 1; // Jan is 0
+ dateTime.day = matches[3];
+
+ if (!dateTime.isDate) {
+ dateTime.hour = matches[5];
+ dateTime.minute = matches[6];
+ dateTime.second = matches[7];
+ }
+
+ // Timezone handling
+ if (matches[9] == "Z" || matches[9] == "z") {
+ // If the dates timezone is "Z" or "z", then this is UTC, no matter
+ // what timezone was passed
+ dateTime.timezone = lazy.cal.dtz.UTC;
+ } else if (matches[9] == null) {
+ // We have no timezone info, only a date. We have no way to
+ // know what timezone we are in, so lets assume we are in the
+ // timezone of our local calendar, or whatever was passed.
+
+ dateTime.timezone = aTimezone;
+ } else {
+ let offset_in_s = (matches[11] == "-" ? -1 : 1) * (matches[12] * 3600 + matches[13] * 60);
+
+ // try local timezone first
+ dateTime.timezone = aTimezone;
+
+ // If offset does not match, go through timezones. This will
+ // give you the first tz in the alphabet and kill daylight
+ // savings time, but we have no other choice
+ if (dateTime.timezoneOffset != offset_in_s) {
+ // TODO A patch to Bug 363191 should make this more efficient.
+
+ // Enumerate timezones, set them, check their offset
+ for (let id of lazy.cal.timezoneService.timezoneIds) {
+ dateTime.timezone = lazy.cal.timezoneService.getTimezone(id);
+ if (dateTime.timezoneOffset == offset_in_s) {
+ // This is our last step, so go ahead and return
+ return dateTime;
+ }
+ }
+ // We are still here: no timezone was found
+ dateTime.timezone = lazy.cal.dtz.UTC;
+ if (!dateTime.isDate) {
+ dateTime.hour += (matches[11] == "-" ? -1 : 1) * matches[12];
+ dateTime.minute += (matches[11] == "-" ? -1 : 1) * matches[13];
+ }
+ }
+ }
+ return dateTime;
+ },
+
+ /**
+ * toRFC3339
+ * Convert a calIDateTime to a RFC3339 compliant Date string
+ *
+ * @param aDateTime The calIDateTime object
+ * @returns The RFC3339 compliant date string
+ */
+ toRFC3339(aDateTime) {
+ if (!aDateTime) {
+ return "";
+ }
+
+ let full_tzoffset = aDateTime.timezoneOffset;
+ let tzoffset_hr = Math.floor(Math.abs(full_tzoffset) / 3600);
+
+ let tzoffset_mn = ((Math.abs(full_tzoffset) / 3600).toFixed(2) - tzoffset_hr) * 60;
+
+ let str =
+ aDateTime.year +
+ "-" +
+ ("00" + (aDateTime.month + 1)).substr(-2) +
+ "-" +
+ ("00" + aDateTime.day).substr(-2);
+
+ // Time and Timezone extension
+ if (!aDateTime.isDate) {
+ str +=
+ "T" +
+ ("00" + aDateTime.hour).substr(-2) +
+ ":" +
+ ("00" + aDateTime.minute).substr(-2) +
+ ":" +
+ ("00" + aDateTime.second).substr(-2);
+ if (aDateTime.timezoneOffset != 0) {
+ str +=
+ (full_tzoffset < 0 ? "-" : "+") +
+ ("00" + tzoffset_hr).substr(-2) +
+ ":" +
+ ("00" + tzoffset_mn).substr(-2);
+ } else if (aDateTime.timezone.isFloating) {
+ // RFC3339 Section 4.3 Unknown Local Offset Convention
+ str += "-00:00";
+ } else {
+ // ZULU Time, according to ISO8601's timezone-offset
+ str += "Z";
+ }
+ }
+ return str;
+ },
+
+ /**
+ * Gets the list of recent timezones. Optionally returns the list as
+ * calITimezones.
+ *
+ * @param aConvertZones (optional) If true, return calITimezones instead
+ * @returns An array of timezone ids or calITimezones.
+ */
+ getRecentTimezones(aConvertZones) {
+ let recentTimezones = JSON.parse(
+ Services.prefs.getStringPref("calendar.timezone.recent", "[]") || "[]"
+ );
+ if (!Array.isArray(recentTimezones)) {
+ recentTimezones = [];
+ }
+
+ if (aConvertZones) {
+ let oldZonesLength = recentTimezones.length;
+ for (let i = 0; i < recentTimezones.length; i++) {
+ let timezone = lazy.cal.timezoneService.getTimezone(recentTimezones[i]);
+ if (timezone) {
+ // Replace id with found timezone
+ recentTimezones[i] = timezone;
+ } else {
+ // Looks like the timezone doesn't longer exist, remove it
+ recentTimezones.splice(i, 1);
+ i--;
+ }
+ }
+
+ if (oldZonesLength != recentTimezones.length) {
+ // Looks like the one or other timezone dropped out. Go ahead and
+ // modify the pref.
+ Services.prefs.setStringPref(
+ "calendar.timezone.recent",
+ JSON.stringify(recentTimezones.map(zone => zone.tzid))
+ );
+ }
+ }
+ return recentTimezones;
+ },
+
+ /**
+ * Returns a string representation of a given datetime. For example, to show
+ * in the calendar item summary dialog.
+ *
+ * @param {calIDateTime} dateTime - Datetime to convert.
+ * @returns {string} A string representation of the datetime.
+ */
+ getStringForDateTime(dateTime) {
+ const kDefaultTimezone = lazy.cal.dtz.defaultTimezone;
+ let localTime = dateTime.getInTimezone(kDefaultTimezone);
+ let formatter = lazy.cal.dtz.formatter;
+ let formattedLocalTime = formatter.formatDateTime(localTime);
+
+ if (!dateTime.timezone.isFloating && dateTime.timezone.tzid != kDefaultTimezone.tzid) {
+ // Additionally display the original datetime with timezone.
+ let originalTime = lazy.cal.l10n.getCalString("datetimeWithTimezone", [
+ formatter.formatDateTime(dateTime),
+ dateTime.timezone.tzid,
+ ]);
+ return `${formattedLocalTime} (${originalTime})`;
+ }
+ return formattedLocalTime;
+ },
+};
+
+ChromeUtils.defineModuleGetter(
+ caldtz,
+ "formatter",
+ "resource:///modules/calendar/utils/calDateTimeFormatter.jsm"
+);
diff --git a/comm/calendar/base/modules/utils/calEmailUtils.jsm b/comm/calendar/base/modules/utils/calEmailUtils.jsm
new file mode 100644
index 0000000000..5892ba569f
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calEmailUtils.jsm
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Functions for processing email addresses and sending email
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.email namespace.
+
+const EXPORTED_SYMBOLS = ["calemail"];
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calemail = {
+ /**
+ * Convenience function to open the compose window pre-filled with the information from the
+ * parameters. These parameters are mostly raw header fields, see #createRecipientList function
+ * to create a recipient list string.
+ *
+ * @param {string} aRecipient - The email recipients string.
+ * @param {string} aSubject - The email subject.
+ * @param {string} aBody - The encoded email body text.
+ * @param {nsIMsgIdentity} aIdentity - The email identity to use for sending
+ */
+ sendTo(aRecipient, aSubject, aBody, aIdentity) {
+ let msgParams = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ let composeFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+
+ composeFields.to = aRecipient;
+ composeFields.subject = aSubject;
+ composeFields.body = aBody;
+
+ msgParams.type = Ci.nsIMsgCompType.New;
+ msgParams.format = Ci.nsIMsgCompFormat.Default;
+ msgParams.composeFields = composeFields;
+ msgParams.identity = aIdentity;
+
+ MailServices.compose.OpenComposeWindowWithParams(null, msgParams);
+ },
+
+ /**
+ * Iterates all email identities and calls the passed function with identity and account.
+ * If the called function returns false, iteration is stopped.
+ *
+ * @param {Function} aFunc - The function to be called for each identity and account
+ */
+ iterateIdentities(aFunc) {
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ if (!aFunc(identity, account)) {
+ break;
+ }
+ }
+ }
+ },
+
+ /**
+ * Prepends a mailto: prefix to an email address like string
+ *
+ * @param {string} aId The string to prepend the prefix if not already there
+ * @returns {string} The string with prefix
+ */
+ prependMailTo(aId) {
+ return aId.replace(/^(?:mailto:)?(.*)@/i, "mailto:$1@");
+ },
+
+ /**
+ * Removes an existing mailto: prefix from an attendee id
+ *
+ * @param {string} aId The string to remove the prefix from if any
+ * @returns {string} The string without prefix
+ */
+ removeMailTo(aId) {
+ return aId.replace(/^mailto:/i, "");
+ },
+
+ /**
+ * Provides a string to use in email "to" header for given attendees
+ *
+ * @param {calIAttendee[]} aAttendees Array of calIAttendee's to check
+ * @returns {string} Valid string to use in a 'to' header of an email
+ */
+ createRecipientList(aAttendees) {
+ let cbEmail = function (aVal) {
+ let email = calemail.getAttendeeEmail(aVal, true);
+ if (!email.length) {
+ lazy.cal.LOG("Dropping invalid recipient for email transport: " + aVal.toString());
+ }
+ return email;
+ };
+ return aAttendees
+ .map(cbEmail)
+ .filter(aVal => aVal.length > 0)
+ .join(", ");
+ },
+
+ /**
+ * Returns a wellformed email string like 'attendee@example.net',
+ * 'Common Name <attendee@example.net>' or '"Name, Common" <attendee@example.net>'
+ *
+ * @param {calIAttendee} aAttendee The attendee to check
+ * @param {boolean} aIncludeCn Whether or not to return also the CN if available
+ * @returns {string} Valid email string or an empty string in case of error
+ */
+ getAttendeeEmail(aAttendee, aIncludeCn) {
+ // If the recipient id is of type urn, we need to figure out the email address, otherwise
+ // we fall back to the attendee id
+ let email = aAttendee.id.match(/^urn:/i) ? aAttendee.getProperty("EMAIL") || "" : aAttendee.id;
+ // Strip leading "mailto:" if it exists.
+ email = email.replace(/^mailto:/i, "");
+ // We add the CN if requested and available
+ let commonName = aAttendee.commonName;
+ if (aIncludeCn && email.length > 0 && commonName && commonName.length > 0) {
+ if (commonName.match(/[,;]/)) {
+ commonName = '"' + commonName + '"';
+ }
+ commonName = commonName + " <" + email + ">";
+ if (calemail.validateRecipientList(commonName) == commonName) {
+ email = commonName;
+ }
+ }
+ return email;
+ },
+
+ /**
+ * Returns a basically checked recipient list - malformed elements will be removed
+ *
+ * @param {string} aRecipients - A comma-seperated list of e-mail addresses
+ * @returns {string} A validated comma-seperated list of e-mail addresses
+ */
+ validateRecipientList(aRecipients) {
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ // Resolve the list considering also configured common names
+ let members = compFields.splitRecipients(aRecipients, false);
+ let list = [];
+ let prefix = "";
+ for (let member of members) {
+ if (prefix != "") {
+ // the previous member had no email address - this happens if a recipients CN
+ // contains a ',' or ';' (splitRecipients(..) behaves wrongly here and produces an
+ // additional member with only the first CN part of that recipient and no email
+ // address while the next has the second part of the CN and the according email
+ // address) - we still need to identify the original delimiter to append it to the
+ // prefix
+ let memberCnPart = member.match(/(.*) <.*>/);
+ if (memberCnPart) {
+ let pattern = new RegExp(prefix + "([;,] *)" + memberCnPart[1]);
+ let delimiter = aRecipients.match(pattern);
+ if (delimiter) {
+ prefix = prefix + delimiter[1];
+ }
+ }
+ }
+ let parts = (prefix + member).match(/(.*)( <.*>)/);
+ if (parts) {
+ if (parts[2] == " <>") {
+ // CN but no email address - we keep the CN part to prefix the next member's CN
+ prefix = parts[1];
+ } else {
+ // CN with email address
+ let commonName = parts[1].trim();
+ // in case of any special characters in the CN string, we make sure to enclose
+ // it with dquotes - simple spaces don't require dquotes
+ if (commonName.match(/[-[\]{}()*+?.,;\\^$|#\f\n\r\t\v]/)) {
+ commonName = '"' + commonName.replace(/\\"|"/, "").trim() + '"';
+ }
+ list.push(commonName + parts[2]);
+ prefix = "";
+ }
+ } else if (member.length) {
+ // email address only
+ list.push(member);
+ prefix = "";
+ }
+ }
+ return list.join(", ");
+ },
+
+ /**
+ * Check if the attendee object matches one of the addresses in the list. This
+ * is useful to determine whether the current user acts as a delegate.
+ *
+ * @param {calIAttendee} aRefAttendee - The reference attendee object
+ * @param {string[]} aAddresses - The list of addresses
+ * @returns {boolean} True, if there is a match
+ */
+ attendeeMatchesAddresses(aRefAttendee, aAddresses) {
+ let attId = aRefAttendee.id;
+ if (!attId.match(/^mailto:/i)) {
+ // Looks like its not a normal attendee, possibly urn:uuid:...
+ // Try getting the email through the EMAIL property.
+ let emailProp = aRefAttendee.getProperty("EMAIL");
+ if (emailProp) {
+ attId = emailProp;
+ }
+ }
+
+ attId = attId.toLowerCase().replace(/^mailto:/, "");
+ for (let address of aAddresses) {
+ if (attId == address.toLowerCase().replace(/^mailto:/, "")) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calInvitationUtils.jsm b/comm/calendar/base/modules/utils/calInvitationUtils.jsm
new file mode 100644
index 0000000000..732c6bbf6c
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calInvitationUtils.jsm
@@ -0,0 +1,875 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { recurrenceRule2String } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "CalRecurrenceDate",
+ "resource:///modules/CalRecurrenceDate.jsm"
+);
+ChromeUtils.defineModuleGetter(lazy, "MailStringUtils", "resource:///modules/MailStringUtils.jsm");
+
+const EXPORTED_SYMBOLS = ["calinvitation"];
+
+var calinvitation = {
+ /**
+ * Returns a header title for an ITIP item depending on the response method
+ *
+ * @param {calItipItem} aItipItem the itip item to check
+ * @returns {string} the header title
+ */
+ getItipHeader(aItipItem) {
+ let header;
+
+ if (aItipItem) {
+ let item = aItipItem.getItemList()[0];
+ let summary = item.getProperty("SUMMARY") || "";
+ let organizer = item.organizer;
+ let organizerString = organizer ? organizer.commonName || organizer.toString() : "";
+
+ switch (aItipItem.responseMethod) {
+ case "REQUEST":
+ header = cal.l10n.getLtnString("itipRequestBody", [organizerString, summary]);
+ break;
+ case "CANCEL":
+ header = cal.l10n.getLtnString("itipCancelBody", [organizerString, summary]);
+ break;
+ case "COUNTER":
+ // falls through
+ case "REPLY": {
+ let attendees = item.getAttendees();
+ let sender = cal.itip.getAttendeesBySender(attendees, aItipItem.sender);
+ if (sender.length == 1) {
+ if (aItipItem.responseMethod == "COUNTER") {
+ header = cal.l10n.getLtnString("itipCounterBody", [sender[0].toString(), summary]);
+ } else {
+ let statusString =
+ sender[0].participationStatus == "DECLINED"
+ ? "itipReplyBodyDecline"
+ : "itipReplyBodyAccept";
+ header = cal.l10n.getLtnString(statusString, [sender[0].toString()]);
+ }
+ } else {
+ header = "";
+ }
+ break;
+ }
+ case "DECLINECOUNTER":
+ header = cal.l10n.getLtnString("itipDeclineCounterBody", [organizerString, summary]);
+ break;
+ }
+ }
+
+ if (!header) {
+ header = cal.l10n.getLtnString("imipHtml.header");
+ }
+
+ return header;
+ },
+
+ _createAddedElement(doc) {
+ let el = doc.createElement("ins");
+ el.classList.add("added");
+ return el;
+ },
+
+ _createRemovedElement(doc) {
+ let el = doc.createElement("del");
+ el.classList.add("removed");
+ return el;
+ },
+
+ /**
+ * Creates new icon and text label for the given event attendee.
+ *
+ * @param {Document} doc - The document the new label will belong to.
+ * @param {calIAttendee} attendee - The attendee to create the label for.
+ * @param {calIAttendee[]} attendeeList - The full list of attendees for the
+ * event.
+ * @param {calIAttendee} [oldAttendee] - The previous version of this attendee
+ * for this event.
+ * @param {calIAttendee[]} [attendeeList] - The previous list of attendees for
+ * this event. This is not optional if oldAttendee is given.
+ *
+ * @returns {HTMLDivElement} - The new attendee label.
+ */
+ createAttendeeLabel(doc, attendee, attendeeList, oldAttendee, oldAttendeeList) {
+ let userType = attendee.userType || "INDIVIDUAL";
+ let role = attendee.role || "REQ-PARTICIPANT";
+ let partstat = attendee.participationStatus || "NEEDS-ACTION";
+
+ let modified =
+ oldAttendee &&
+ ((oldAttendee.userType || "INDIVIDUAL") != userType ||
+ (oldAttendee.role || "REQ-PARTICIPANT") != role ||
+ (oldAttendee.participationStatus || "NEEDS-ACTION") != partstat);
+
+ // resolve delegatees/delegators to display also the CN
+ let del = cal.itip.resolveDelegation(attendee, attendeeList);
+ if (oldAttendee && !modified) {
+ let oldDel = cal.itip.resolveDelegation(oldAttendee, oldAttendeeList);
+ modified = oldDel.delegatees !== del.delegatees || oldDel.delegator !== del.delegator;
+ }
+
+ let userTypeString = cal.l10n.getLtnString("imipHtml.attendeeUserType2." + userType, [
+ attendee.toString(),
+ ]);
+ let roleString = cal.l10n.getLtnString("imipHtml.attendeeRole2." + role, [userTypeString]);
+ let partstatString = cal.l10n.getLtnString("imipHtml.attendeePartStat2." + partstat, [
+ attendee.commonName || attendee.toString(),
+ del.delegatees,
+ ]);
+ let tooltip = cal.l10n.getLtnString("imipHtml.attendee.combined", [roleString, partstatString]);
+
+ let name = attendee.toString();
+ if (del.delegators) {
+ name += " " + cal.l10n.getLtnString("imipHtml.attendeeDelegatedFrom", [del.delegators]);
+ }
+
+ let attendeeLabel = doc.createElement("div");
+ attendeeLabel.classList.add("attendee-label");
+ // NOTE: tooltip will not appear when the top level is XUL.
+ attendeeLabel.setAttribute("title", tooltip);
+ attendeeLabel.setAttribute("attendeeid", attendee.id);
+ attendeeLabel.setAttribute("tabindex", "0");
+
+ if (modified) {
+ attendeeLabel.classList.add("modified");
+ }
+
+ // FIXME: Replace icon with an img element with src and alt. The current
+ // problem is that the icon image is set in CSS on the itip-icon class
+ // with a background image that changes with the role attribute. This is
+ // generally inaccessible (see Bug 1702560).
+ let icon = doc.createElement("div");
+ icon.classList.add("itip-icon");
+ icon.setAttribute("partstat", partstat);
+ icon.setAttribute("usertype", userType);
+ icon.setAttribute("attendeerole", role);
+ attendeeLabel.appendChild(icon);
+
+ let text = doc.createElement("div");
+ text.classList.add("attendee-name");
+ text.appendChild(doc.createTextNode(name));
+ attendeeLabel.appendChild(text);
+
+ return attendeeLabel;
+ },
+
+ /**
+ * Create an new list item element for an attendee, to be used as a child of
+ * an "attendee-list" element.
+ *
+ * @param {Document} doc - The document the new list item will belong to.
+ * @param {Element} attendeeLabel - The attendee label to place within the
+ * list item.
+ *
+ * return {HTMLLIElement} - The attendee list item.
+ */
+ createAttendeeListItem(doc, attendeeLabel) {
+ let listItem = doc.createElement("li");
+ listItem.classList.add("attendee-list-item");
+ listItem.appendChild(attendeeLabel);
+ return listItem;
+ },
+
+ /**
+ * Creates a new element that lists the given attendees.
+ *
+ * @param {Document} doc - The document the new list will belong to.
+ * @param {calIAttendee[]} attendees - The attendees to create the list for.
+ * @param {calIAttendee[]} [oldAttendees] - A list of attendees for a
+ * previous version of the event.
+ *
+ * @returns {HTMLUListElement} - The list of attendees.
+ */
+ createAttendeesList(doc, attendees, oldAttendees) {
+ let list = doc.createElement("ul");
+ list.classList.add("attendee-list");
+
+ let oldAttendeeData;
+ if (oldAttendees) {
+ oldAttendeeData = [];
+ for (let attendee of oldAttendees) {
+ let data = { attendee, item: null };
+ oldAttendeeData.push(data);
+ }
+ }
+
+ for (let attendee of attendees) {
+ let attendeeLabel;
+ let oldData;
+ if (oldAttendeeData) {
+ oldData = oldAttendeeData.find(old => old.attendee.id == attendee.id);
+ if (oldData) {
+ // Same attendee.
+ attendeeLabel = this.createAttendeeLabel(
+ doc,
+ attendee,
+ attendees,
+ oldData.attendee,
+ oldAttendees
+ );
+ } else {
+ // Added attendee.
+ attendeeLabel = this._createAddedElement(doc);
+ attendeeLabel.appendChild(this.createAttendeeLabel(doc, attendee, attendees));
+ }
+ } else {
+ attendeeLabel = this.createAttendeeLabel(doc, attendee, attendees);
+ }
+ let listItem = this.createAttendeeListItem(doc, attendeeLabel);
+ if (oldData) {
+ oldData.item = listItem;
+ }
+ list.appendChild(listItem);
+ }
+
+ if (oldAttendeeData) {
+ let next = null;
+ // Traverse from the end of the list to the start.
+ for (let i = oldAttendeeData.length - 1; i >= 0; i--) {
+ let data = oldAttendeeData[i];
+ if (!data.item) {
+ // Removed attendee.
+ let attendeeLabel = this._createRemovedElement(doc);
+ attendeeLabel.appendChild(this.createAttendeeLabel(doc, data.attendee, attendees));
+ let listItem = this.createAttendeeListItem(doc, attendeeLabel);
+ data.item = listItem;
+
+ // Insert the removed attendee list item *before* the list item that
+ // corresponds to the attendee that follows this attendee in the
+ // oldAttendees list.
+ //
+ // NOTE: by traversing from the end of the list to the start, we are
+ // prioritising being next to the attendee that follows us, rather
+ // than being next to the attendee that precedes us in the oldAttendee
+ // list.
+ //
+ // Specifically, if a new attendee is added between these two old
+ // neighbours, the added attendee will be shown earlier than the
+ // removed attendee in the list.
+ //
+ // E.g., going from the list
+ // [first@person, removed@person, second@person]
+ // to
+ // [first@person, added@person, second@person]
+ // will be shown as
+ // first@person
+ // + added@person
+ // - removed@person
+ // second@person
+ // because the removed@person's uses second@person as their reference
+ // point.
+ //
+ // NOTE: next.item is always non-null because next.item is always set
+ // by the end of the last loop.
+ list.insertBefore(listItem, next ? next.item : null);
+ }
+ next = data;
+ }
+ }
+
+ return list;
+ },
+
+ /**
+ * Returns the html representation of the event as a DOM document.
+ *
+ * @param {calIItemBase} event - The event to parse into html.
+ * @param {calItipItem} itipItem - The itip item, which contains the event.
+ * @returns {Document} The html representation of the event.
+ */
+ createInvitationOverlay(event, itipItem) {
+ // Creates HTML using the Node strings in the properties file
+ const parser = new DOMParser();
+ let doc = parser.parseFromString(calinvitation.htmlTemplate, "text/html");
+ this.updateInvitationOverlay(doc, event, itipItem);
+ return doc;
+ },
+
+ /**
+ * Update the document created by createInvitationOverlay to show the new
+ * event details, and optionally show changes in the event against an older
+ * version of it.
+ *
+ * For example, this can be used for email invitations to update the invite to
+ * show the most recent version of the event found in the calendar, whilst
+ * also showing the event details that were removed since the original email
+ * invitation. I.e. contrasting the event found in the calendar with the event
+ * found within the email. Alternatively, if the email invitation is newer
+ * than the event found in the calendar, you can switch the comparison around.
+ * (As used in imip-bar.js.)
+ *
+ * @param {Document} doc - The document to update, previously created through
+ * createInvitationOverlay.
+ * @param {calIItemBase} event - The newest version of the event.
+ * @param {calItipItem} itipItem - The itip item, which contains the event.
+ * @param {calIItemBase} [oldEvent] - A previous version of the event to
+ * show as updated.
+ */
+ updateInvitationOverlay(doc, event, itipItem, oldEvent) {
+ let headerDescr = doc.getElementById("imipHtml-header");
+ if (headerDescr) {
+ headerDescr.textContent = calinvitation.getItipHeader(itipItem);
+ }
+
+ let formatter = cal.dtz.formatter;
+
+ /**
+ * Set whether the given field should be shown.
+ *
+ * @param {string} fieldName - The name of the field.
+ * @param {boolean} show - Whether the field should be shown.
+ */
+ let showField = (fieldName, show) => {
+ let row = doc.getElementById("imipHtml-" + fieldName + "-row");
+ if (row.hidden && show) {
+ // Make sure the field name is set.
+ doc.getElementById("imipHtml-" + fieldName + "-descr").textContent = cal.l10n.getLtnString(
+ "imipHtml." + fieldName
+ );
+ }
+ row.hidden = !show;
+ };
+
+ /**
+ * Set the given element to display the given value.
+ *
+ * @param {Element} element - The element to display the value within.
+ * @param {string} value - The value to show.
+ * @param {boolean} [convert=false] - Whether the value will need converting
+ * to a sanitised document fragment.
+ * @param {string} [html] - The html to use as the value. This is only used
+ * if convert is set to true.
+ */
+ let setElementValue = (element, value, convert = false, html) => {
+ if (convert) {
+ element.appendChild(cal.view.textToHtmlDocumentFragment(value, doc, html));
+ } else {
+ element.textContent = value;
+ }
+ };
+
+ /**
+ * Set the given field.
+ *
+ * If oldEvent is set, and the new value differs from the old one, it will
+ * be shown as added and/or removed content.
+ *
+ * If neither events have a value, the field will be hidden.
+ *
+ * @param {string} fieldName - The name of the field to set.
+ * @param {Function} getValue - A method to retrieve the field value from an
+ * event. Should return a string, or a falsey value if the event has no
+ * value for this field.
+ * @param {boolean} [convert=false] - Whether the value will need converting
+ * to a sanitised document fragment.
+ * @param {Function} [getHtml] - A method to retrieve the value as a html.
+ */
+ let setField = (fieldName, getValue, convert = false, getHtml) => {
+ let cell = doc.getElementById("imipHtml-" + fieldName + "-content");
+ while (cell.lastChild) {
+ cell.lastChild.remove();
+ }
+ let value = getValue(event);
+ let oldValue = oldEvent && getValue(oldEvent);
+ let html = getHtml && getHtml(event);
+ let oldHtml = oldEvent && getHtml && getHtml(event);
+ if (oldEvent && (oldValue || value) && oldValue !== value) {
+ // Different values, with at least one being truthy.
+ showField(fieldName, true);
+ if (!oldValue) {
+ let added = this._createAddedElement(doc);
+ setElementValue(added, value, convert, html);
+ cell.appendChild(added);
+ } else if (!value) {
+ let removed = this._createRemovedElement(doc);
+ setElementValue(removed, oldValue, convert, oldHtml);
+ cell.appendChild(removed);
+ } else {
+ let added = this._createAddedElement(doc);
+ setElementValue(added, value, convert, html);
+ let removed = this._createRemovedElement(doc);
+ setElementValue(removed, oldValue, convert, oldHtml);
+
+ cell.appendChild(added);
+ cell.appendChild(doc.createElement("br"));
+ cell.appendChild(removed);
+ }
+ } else if (value) {
+ // Same truthy value.
+ showField(fieldName, true);
+ setElementValue(cell, value, convert, html);
+ } else {
+ showField(fieldName, false);
+ }
+ };
+
+ setField("summary", ev => ev.title, true);
+ setField("location", ev => ev.getProperty("LOCATION"), true);
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ setField("when", ev => {
+ if (ev.recurrenceInfo) {
+ let startDate = ev.startDate?.getInTimezone(kDefaultTimezone) ?? null;
+ let endDate = ev.endDate?.getInTimezone(kDefaultTimezone) ?? null;
+ let repeatString = recurrenceRule2String(
+ ev.recurrenceInfo,
+ startDate,
+ endDate,
+ startDate.isDate
+ );
+ if (repeatString) {
+ return repeatString;
+ }
+ }
+ return formatter.formatItemInterval(ev);
+ });
+
+ setField("canceledOccurrences", ev => {
+ if (!ev.recurrenceInfo) {
+ return null;
+ }
+ let formattedExDates = [];
+
+ // Show removed instances
+ for (let exc of ev.recurrenceInfo.getRecurrenceItems()) {
+ if (
+ (exc instanceof lazy.CalRecurrenceDate || exc instanceof Ci.calIRecurrenceDate) &&
+ exc.isNegative
+ ) {
+ // This is an EXDATE
+ let excDate = exc.date.getInTimezone(kDefaultTimezone);
+ formattedExDates.push(formatter.formatDateTime(excDate));
+ }
+ }
+ if (formattedExDates.length > 0) {
+ return formattedExDates.join("\n");
+ }
+ return null;
+ });
+
+ let dateComptor = (a, b) => a.startDate.compare(b.startDate);
+
+ setField("modifiedOccurrences", ev => {
+ if (!ev.recurrenceInfo) {
+ return null;
+ }
+ let modifiedOccurrences = [];
+
+ for (let exc of ev.recurrenceInfo.getRecurrenceItems()) {
+ if (
+ (exc instanceof lazy.CalRecurrenceDate || exc instanceof Ci.calIRecurrenceDate) &&
+ !exc.isNegative
+ ) {
+ // This is an RDATE, close enough to a modified occurrence
+ let excItem = ev.recurrenceInfo.getOccurrenceFor(exc.date);
+ cal.data.binaryInsert(modifiedOccurrences, excItem, dateComptor, true);
+ }
+ }
+ for (let recurrenceId of ev.recurrenceInfo.getExceptionIds()) {
+ let exc = ev.recurrenceInfo.getExceptionFor(recurrenceId);
+ let excLocation = exc.getProperty("LOCATION");
+
+ // Only show modified occurrence if start, duration or location
+ // has changed.
+ exc.QueryInterface(Ci.calIEvent);
+ if (
+ exc.startDate.compare(exc.recurrenceId) != 0 ||
+ exc.duration.compare(ev.duration) != 0 ||
+ excLocation != ev.getProperty("LOCATION")
+ ) {
+ cal.data.binaryInsert(modifiedOccurrences, exc, dateComptor, true);
+ }
+ }
+
+ if (modifiedOccurrences.length > 0) {
+ let evLocation = ev.getProperty("LOCATION");
+ return modifiedOccurrences
+ .map(occ => {
+ let formattedExc = formatter.formatItemInterval(occ);
+ let occLocation = occ.getProperty("LOCATION");
+ if (occLocation != evLocation) {
+ formattedExc +=
+ " (" + cal.l10n.getLtnString("imipHtml.newLocation", [occLocation]) + ")";
+ }
+ return formattedExc;
+ })
+ .join("\n");
+ }
+ return null;
+ });
+
+ setField(
+ "description",
+ // We remove the useless "Outlookism" squiggle.
+ ev => ev.descriptionText?.replace("*~*~*~*~*~*~*~*~*~*", ""),
+ true,
+ ev => ev.descriptionHTML
+ );
+
+ setField("url", ev => ev.getProperty("URL"), true);
+ setField(
+ "attachments",
+ ev => {
+ // ATTACH - we only display URI but no BINARY type attachments here
+ let links = [];
+ for (let attachment of ev.getAttachments()) {
+ if (attachment.uri) {
+ links.push(attachment.uri.spec);
+ }
+ }
+ return links.join("\n");
+ },
+ true
+ );
+
+ // ATTENDEE and ORGANIZER fields
+ let attendees = event.getAttendees();
+ let oldAttendees = oldEvent?.getAttendees();
+
+ let organizerCell = doc.getElementById("imipHtml-organizer-cell");
+ while (organizerCell.lastChild) {
+ organizerCell.lastChild.remove();
+ }
+
+ let organizer = event.organizer;
+ if (oldEvent) {
+ let oldOrganizer = oldEvent.organizer;
+ if (!organizer && !oldOrganizer) {
+ showField("organizer", false);
+ } else {
+ showField("organizer", true);
+
+ let removed = false;
+ let added = false;
+ if (!organizer) {
+ removed = true;
+ } else if (!oldOrganizer) {
+ added = true;
+ } else if (organizer.id !== oldOrganizer.id) {
+ removed = true;
+ added = true;
+ } else {
+ // Same organizer, potentially modified.
+ organizerCell.appendChild(
+ this.createAttendeeLabel(doc, organizer, attendees, oldOrganizer, oldAttendees)
+ );
+ }
+ // Append added first.
+ if (added) {
+ let addedEl = this._createAddedElement(doc);
+ addedEl.appendChild(this.createAttendeeLabel(doc, organizer, attendees));
+ organizerCell.appendChild(addedEl);
+ }
+ if (removed) {
+ let removedEl = this._createRemovedElement(doc);
+ removedEl.appendChild(this.createAttendeeLabel(doc, oldOrganizer, oldAttendees));
+ organizerCell.appendChild(removedEl);
+ }
+ }
+ } else if (!organizer) {
+ showField("organizer", false);
+ } else {
+ showField("organizer", true);
+ organizerCell.appendChild(this.createAttendeeLabel(doc, organizer, attendees));
+ }
+
+ let attendeesCell = doc.getElementById("imipHtml-attendees-cell");
+ while (attendeesCell.lastChild) {
+ attendeesCell.lastChild.remove();
+ }
+
+ // Hide if we have no attendees, and neither does the old event.
+ if (attendees.length == 0 && (!oldEvent || oldAttendees.length == 0)) {
+ showField("attendees", false);
+ } else {
+ // oldAttendees is undefined if oldEvent is undefined.
+ showField("attendees", true);
+ attendeesCell.appendChild(this.createAttendeesList(doc, attendees, oldAttendees));
+ }
+ },
+
+ /**
+ * Returns the header section for an invitation email.
+ *
+ * @param {string} aMessageId the message id to use for that email
+ * @param {nsIMsgIdentity} aIdentity the identity to use for that email
+ * @returns {string} the source code of the header section of the email
+ */
+ getHeaderSection(aMessageId, aIdentity, aToList, aSubject) {
+ let recipient = aIdentity.fullName + " <" + aIdentity.email + ">";
+ let from = aIdentity.fullName.length
+ ? cal.email.validateRecipientList(recipient)
+ : aIdentity.email;
+ let header =
+ "MIME-version: 1.0\r\n" +
+ (aIdentity.replyTo
+ ? "Return-path: " + calinvitation.encodeMimeHeader(aIdentity.replyTo, true) + "\r\n"
+ : "") +
+ "From: " +
+ calinvitation.encodeMimeHeader(from, true) +
+ "\r\n" +
+ (aIdentity.organization
+ ? "Organization: " + calinvitation.encodeMimeHeader(aIdentity.organization) + "\r\n"
+ : "") +
+ "Message-ID: " +
+ aMessageId +
+ "\r\n" +
+ "To: " +
+ calinvitation.encodeMimeHeader(aToList, true) +
+ "\r\n" +
+ "Date: " +
+ calinvitation.getRfc5322FormattedDate() +
+ "\r\n" +
+ "Subject: " +
+ calinvitation.encodeMimeHeader(aSubject.replace(/(\n|\r\n)/, "|")) +
+ "\r\n";
+ let validRecipients;
+ if (aIdentity.doCc) {
+ validRecipients = cal.email.validateRecipientList(aIdentity.doCcList);
+ if (validRecipients != "") {
+ header += "Cc: " + calinvitation.encodeMimeHeader(validRecipients, true) + "\r\n";
+ }
+ }
+ if (aIdentity.doBcc) {
+ validRecipients = cal.email.validateRecipientList(aIdentity.doBccList);
+ if (validRecipients != "") {
+ header += "Bcc: " + calinvitation.encodeMimeHeader(validRecipients, true) + "\r\n";
+ }
+ }
+ return header;
+ },
+
+ /**
+ * Returns a datetime string according to section 3.3 of RfC5322
+ *
+ * @param {Date} [optional] Js Date object to format; if not provided current DateTime is used
+ * @returns {string} Datetime string with a modified tz-offset notation compared to
+ * Date.toString() like "Fri, 20 Nov 2015 09:45:36 +0100"
+ */
+ getRfc5322FormattedDate(aDate = null) {
+ let date = aDate || new Date();
+ let str = date
+ .toString()
+ .replace(
+ /^(\w{3}) (\w{3}) (\d{2}) (\d{4}) ([0-9:]{8}) GMT([+-])(\d{4}).*$/,
+ "$1, $3 $2 $4 $5 $6$7"
+ );
+ // according to section 3.3 of RfC5322, +0000 should be used for defined timezones using
+ // UTC time, while -0000 should indicate a floating time instead
+ let timezone = cal.dtz.defaultTimezone;
+ if (timezone && timezone.isFloating) {
+ str.replace(/\+0000$/, "-0000");
+ }
+ return str;
+ },
+
+ /**
+ * Converts a given unicode text to utf-8 and normalizes line-breaks to \r\n
+ *
+ * @param {string} aText a unicode encoded string
+ * @returns {string} the converted uft-8 encoded string
+ */
+ encodeUTF8(aText) {
+ return calinvitation.convertFromUnicode(aText).replace(/(\r\n)|\n/g, "\r\n");
+ },
+
+ /**
+ * Converts a given unicode text
+ *
+ * @param {string} aSrc unicode text to convert
+ * @returns {string} the converted string
+ */
+ convertFromUnicode(aSrc) {
+ return lazy.MailStringUtils.stringToByteString(aSrc);
+ },
+
+ /**
+ * Converts a header to a mime encoded header
+ *
+ * @param {string} aHeader a header to encode
+ * @param {boolean} aIsEmail if enabled, only the CN but not the email address gets
+ * converted - default value is false
+ * @returns {string} the encoded string
+ */
+ encodeMimeHeader(aHeader, aIsEmail = false) {
+ let fieldNameLen = aHeader.indexOf(": ") + 2;
+ return MailServices.mimeConverter.encodeMimePartIIStr_UTF8(
+ aHeader,
+ aIsEmail,
+ fieldNameLen,
+ Ci.nsIMimeConverter.MIME_ENCODED_WORD_SIZE
+ );
+ },
+
+ /**
+ * Parses a counterproposal to extract differences to the existing event
+ *
+ * @param {calIEvent|calITodo} aProposedItem The counterproposal
+ * @param {calIEvent|calITodo} aExistingItem The item to compare with
+ * @returns {JSObject} Objcet of result and differences of parsing
+ * @returns {string} JsObject.result.type Parsing result: OK|OLDVERSION|ERROR|NODIFF
+ * @returns {string} JsObject.result.descr Parsing result description
+ * @returns {Array} JsObject.differences Array of objects consisting of property, proposed
+ * and original properties.
+ * @returns {string} JsObject.comment A comment of the attendee, if any
+ */
+ parseCounter(aProposedItem, aExistingItem) {
+ let isEvent = aProposedItem.isEvent();
+ // atm we only support a subset of properties, for a full list see RfC 5546 section 3.2.7
+ let properties = ["SUMMARY", "LOCATION", "DTSTART", "DTEND", "COMMENT"];
+ if (!isEvent) {
+ cal.LOG("Parsing of counterproposals is currently only supported for events.");
+ properties = [];
+ }
+
+ let diff = [];
+ let status = { descr: "", type: "OK" };
+ // As required in https://tools.ietf.org/html/rfc5546#section-3.2.7 a valid counterproposal
+ // is referring to as existing UID and must include the same sequence number and organizer as
+ // the original request being countered
+ if (
+ aProposedItem.id == aExistingItem.id &&
+ aProposedItem.organizer &&
+ aExistingItem.organizer &&
+ aProposedItem.organizer.id == aExistingItem.organizer.id
+ ) {
+ let proposedSequence = aProposedItem.getProperty("SEQUENCE") || 0;
+ let existingSequence = aExistingItem.getProperty("SEQUENCE") || 0;
+ if (existingSequence >= proposedSequence) {
+ if (existingSequence > proposedSequence) {
+ // in this case we prompt the organizer with the additional information that the
+ // received proposal refers to an outdated version of the event
+ status.descr = "This is a counterproposal to an already rescheduled event.";
+ status.type = "OUTDATED";
+ } else if (aProposedItem.stampTime.compare(aExistingItem.stampTime) == -1) {
+ // now this is the same sequence but the proposal is not based on the latest
+ // update of the event - updated events may have minor changes, while for major
+ // ones there has been a rescheduling
+ status.descr = "This is a counterproposal not based on the latest event update.";
+ status.type = "NOTLATESTUPDATE";
+ }
+ for (let prop of properties) {
+ let newValue = aProposedItem.getProperty(prop) || null;
+ let oldValue = aExistingItem.getProperty(prop) || null;
+ if (
+ (["DTSTART", "DTEND"].includes(prop) && newValue.toString() != oldValue.toString()) ||
+ (!["DTSTART", "DTEND"].includes(prop) && newValue != oldValue)
+ ) {
+ diff.push({
+ property: prop,
+ proposed: newValue,
+ original: oldValue,
+ });
+ }
+ }
+ } else {
+ status.descr = "Invalid sequence number in counterproposal.";
+ status.type = "ERROR";
+ }
+ } else {
+ status.descr = "Mismatch of uid or organizer in counterproposal.";
+ status.type = "ERROR";
+ }
+ if (status.type != "ERROR" && !diff.length) {
+ status.descr = "No difference in counterproposal detected.";
+ status.type = "NODIFF";
+ }
+ return { result: status, differences: diff };
+ },
+
+ /**
+ * The HTML template used to format invitations for display.
+ * This used to be in a separate file (invitation-template.xhtml) and should
+ * probably be moved back there. But loading on-the-fly was causing a nasty
+ * C++ reentrancy issue (see bug 1679299).
+ */
+ htmlTemplate: `<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="chrome://messagebody/skin/imip.css" />
+ <link rel="stylesheet" href="chrome://messagebody/skin/calendar-attendees.css" />
+ </head>
+ <body>
+ <details id="imipHTMLDetails" class="invitation-details">
+ <summary id="imipHtml-header"></summary>
+ <div class="invitation-border">
+ <table class="invitation-table">
+ <tr id="imipHtml-summary-row" hidden="hidden">
+ <th id="imipHtml-summary-descr" class="description" scope="row"></th>
+ <td id="imipHtml-summary-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-location-row" hidden="hidden">
+ <th id="imipHtml-location-descr" class="description" scope="row"></th>
+ <td id="imipHtml-location-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-when-row" hidden="hidden">
+ <th id="imipHtml-when-descr" class="description" scope="row"></th>
+ <td id="imipHtml-when-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-canceledOccurrences-row" hidden="hidden">
+ <th id="imipHtml-canceledOccurrences-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-canceledOccurrences-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-modifiedOccurrences-row" hidden="hidden">
+ <th id="imipHtml-modifiedOccurrences-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-modifiedOccurrences-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-organizer-row" hidden="hidden">
+ <th id="imipHtml-organizer-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-organizer-cell" class="content"></td>
+ </tr>
+ <tr id="imipHtml-description-row" hidden="hidden">
+ <th id="imipHtml-description-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-description-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-attachments-row" hidden="hidden">
+ <th id="imipHtml-attachments-descr"
+ class="description"
+ scope="row"></th>
+ <td id="imipHtml-attachments-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-comment-row" hidden="hidden">
+ <th id="imipHtml-comment-descr" class="description" scope="row"></th>
+ <td id="imipHtml-comment-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-attendees-row" hidden="hidden">
+ <th id="imipHtml-attendees-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-attendees-cell" class="content"></td>
+ </tr>
+ <tr id="imipHtml-url-row" hidden="hidden">
+ <th id="imipHtml-url-descr" class="description" scope="row"></th>
+ <td id="imipHtml-url-content" class="content"></td>
+ </tr>
+ </table>
+ </div>
+ </details>
+ </body>
+</html>
+`,
+};
diff --git a/comm/calendar/base/modules/utils/calItemUtils.jsm b/comm/calendar/base/modules/utils/calItemUtils.jsm
new file mode 100644
index 0000000000..d121430fcc
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calItemUtils.jsm
@@ -0,0 +1,675 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calHashedArray.jsm");
+
+/*
+ * Calendar item related functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.item namespace.
+
+const EXPORTED_SYMBOLS = ["calitem"];
+
+var calitem = {
+ ItemDiff: (function () {
+ /**
+ * Given two sets of items, find out which items were added, changed or
+ * removed.
+ *
+ * The general flow is to first use load method to load the engine with
+ * the first set of items, then use difference to load the set of
+ * items to diff against. Afterwards, call the complete method to tell the
+ * engine that no more items are coming.
+ *
+ * You can then access the mAddedItems/mModifiedItems/mDeletedItems attributes to
+ * get the items that were changed during the process.
+ */
+ function ItemDiff() {
+ this.reset();
+ }
+
+ ItemDiff.prototype = {
+ STATE_INITIAL: 1,
+ STATE_LOADING: 2,
+ STATE_DIFFERING: 4,
+ STATE_COMPLETED: 8,
+
+ state: 1,
+ mInitialItems: null,
+
+ mModifiedItems: null,
+ mModifiedOldItems: null,
+ mAddedItems: null,
+ mDeletedItems: null,
+
+ /**
+ * Expect the difference engine to be in the given state.
+ *
+ * @param aState The state to be in
+ * @param aMethod The method name expecting the state
+ */
+ _expectState(aState, aMethod) {
+ if ((this.state & aState) == 0) {
+ throw new Error(
+ "ItemDiff method " + aMethod + " called while in unexpected state " + this.state
+ );
+ }
+ },
+
+ /**
+ * Loads an array of items. This step cannot be executed
+ * after calling the difference methods.
+ *
+ * @param items The array of items to load
+ */
+ load(items) {
+ this._expectState(this.STATE_INITIAL | this.STATE_LOADING, "load");
+
+ for (let item of items) {
+ this.mInitialItems[item.hashId] = item;
+ }
+
+ this.state = this.STATE_LOADING;
+ },
+
+ /**
+ * Calculate the difference for the array of items. This method should be
+ * called after all load methods and before the complete method.
+ *
+ * @param items The array of items to calculate difference with
+ */
+ difference(items) {
+ this._expectState(
+ this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING,
+ "difference"
+ );
+
+ this.mModifiedOldItems.startBatch();
+ this.mModifiedItems.startBatch();
+ this.mAddedItems.startBatch();
+
+ for (let item of items) {
+ if (item.hashId in this.mInitialItems) {
+ let oldItem = this.mInitialItems[item.hashId];
+ this.mModifiedOldItems.addItem(oldItem);
+ this.mModifiedItems.addItem(item);
+ } else {
+ this.mAddedItems.addItem(item);
+ }
+ delete this.mInitialItems[item.hashId];
+ }
+
+ this.mModifiedOldItems.endBatch();
+ this.mModifiedItems.endBatch();
+ this.mAddedItems.endBatch();
+
+ this.state = this.STATE_DIFFERING;
+ },
+
+ /**
+ * Tell the engine that all load and difference calls have been made, this
+ * makes sure that all item states are correctly returned.
+ */
+ complete() {
+ this._expectState(
+ this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING,
+ "complete"
+ );
+
+ this.mDeletedItems.startBatch();
+
+ for (let hashId in this.mInitialItems) {
+ let item = this.mInitialItems[hashId];
+ this.mDeletedItems.addItem(item);
+ }
+
+ this.mDeletedItems.endBatch();
+ this.mInitialItems = {};
+
+ this.state = this.STATE_COMPLETED;
+ },
+
+ /** @returns a HashedArray containing the new version of the modified items */
+ get modifiedItems() {
+ this._expectState(this.STATE_COMPLETED, "get modifiedItems");
+ return this.mModifiedItems;
+ },
+
+ /** @returns a HashedArray containing the old version of the modified items */
+ get modifiedOldItems() {
+ this._expectState(this.STATE_COMPLETED, "get modifiedOldItems");
+ return this.mModifiedOldItems;
+ },
+
+ /** @returns a HashedArray containing added items */
+ get addedItems() {
+ this._expectState(this.STATE_COMPLETED, "get addedItems");
+ return this.mAddedItems;
+ },
+
+ /** @returns a HashedArray containing deleted items */
+ get deletedItems() {
+ this._expectState(this.STATE_COMPLETED, "get deletedItems");
+ return this.mDeletedItems;
+ },
+
+ /** @returns the number of loaded items */
+ get count() {
+ return Object.keys(this.mInitialItems).length;
+ },
+
+ /**
+ * Resets the difference engine to its initial state.
+ */
+ reset() {
+ this.mInitialItems = {};
+ this.mModifiedItems = new cal.HashedArray();
+ this.mModifiedOldItems = new cal.HashedArray();
+ this.mAddedItems = new cal.HashedArray();
+ this.mDeletedItems = new cal.HashedArray();
+ this.state = this.STATE_INITIAL;
+ },
+ };
+ return ItemDiff;
+ })(),
+
+ /**
+ * Checks if an item is supported by a Calendar.
+ *
+ * @param aCalendar the calendar
+ * @param aItem the item either a task or an event
+ * @returns true or false
+ */
+ isItemSupported(aItem, aCalendar) {
+ if (aItem.isTodo()) {
+ return aCalendar.getProperty("capabilities.tasks.supported") !== false;
+ } else if (aItem.isEvent()) {
+ return aCalendar.getProperty("capabilities.events.supported") !== false;
+ }
+ return false;
+ },
+
+ /*
+ * Checks whether a calendar supports events
+ *
+ * @param aCalendar
+ */
+ isEventCalendar(aCalendar) {
+ return aCalendar.getProperty("capabilities.events.supported") !== false;
+ },
+
+ /*
+ * Checks whether a calendar supports tasks
+ *
+ * @param aCalendar
+ */
+ isTaskCalendar(aCalendar) {
+ return aCalendar.getProperty("capabilities.tasks.supported") !== false;
+ },
+
+ /**
+ * Checks whether the passed item fits into the demanded range.
+ *
+ * @param item the item
+ * @param rangeStart (inclusive) range start or null (open range)
+ * @param rangeStart (exclusive) range end or null (open range)
+ * @param returnDtstartOrDue returns item's start (or due) date in case
+ * the item is in the specified Range; null otherwise.
+ */
+ checkIfInRange(item, rangeStart, rangeEnd, returnDtstartOrDue) {
+ let startDate;
+ let endDate;
+ let queryStart = cal.dtz.ensureDateTime(rangeStart);
+ if (item.isEvent()) {
+ startDate = item.startDate;
+ if (!startDate) {
+ // DTSTART mandatory
+ // xxx todo: should we assert this case?
+ return null;
+ }
+ endDate = item.endDate || startDate;
+ } else {
+ let dueDate = item.dueDate;
+ startDate = item.entryDate || dueDate;
+ if (!item.entryDate) {
+ if (returnDtstartOrDue) {
+ // DTSTART or DUE mandatory
+ return null;
+ }
+ // 3.6.2. To-do Component
+ // A "VTODO" calendar component without the "DTSTART" and "DUE" (or
+ // "DURATION") properties specifies a to-do that will be associated
+ // with each successive calendar date, until it is completed.
+ let completedDate = cal.dtz.ensureDateTime(item.completedDate);
+ dueDate = cal.dtz.ensureDateTime(dueDate);
+ return (
+ !completedDate ||
+ !queryStart ||
+ completedDate.compare(queryStart) > 0 ||
+ (dueDate && dueDate.compare(queryStart) >= 0)
+ );
+ }
+ endDate = dueDate || startDate;
+ }
+
+ let start = cal.dtz.ensureDateTime(startDate);
+ let end = cal.dtz.ensureDateTime(endDate);
+ let queryEnd = cal.dtz.ensureDateTime(rangeEnd);
+
+ if (start.compare(end) == 0) {
+ if (
+ (!queryStart || start.compare(queryStart) >= 0) &&
+ (!queryEnd || start.compare(queryEnd) < 0)
+ ) {
+ return startDate;
+ }
+ } else if (
+ (!queryEnd || start.compare(queryEnd) < 0) &&
+ (!queryStart || end.compare(queryStart) > 0)
+ ) {
+ return startDate;
+ }
+ return null;
+ },
+
+ setItemProperty(item, propertyName, aValue, aCapability) {
+ let isSupported =
+ item.calendar.getProperty("capabilities." + aCapability + ".supported") !== false;
+ let value = aCapability && !isSupported ? null : aValue;
+
+ switch (propertyName) {
+ case "startDate":
+ if (
+ (value.isDate && !item.startDate.isDate) ||
+ (!value.isDate && item.startDate.isDate) ||
+ !cal.data.compareObjects(value.timezone, item.startDate.timezone) ||
+ value.compare(item.startDate) != 0
+ ) {
+ item.startDate = value;
+ }
+ break;
+ case "endDate":
+ if (
+ (value.isDate && !item.endDate.isDate) ||
+ (!value.isDate && item.endDate.isDate) ||
+ !cal.data.compareObjects(value.timezone, item.endDate.timezone) ||
+ value.compare(item.endDate) != 0
+ ) {
+ item.endDate = value;
+ }
+ break;
+ case "entryDate":
+ if (value == item.entryDate) {
+ break;
+ }
+ if (
+ (value && !item.entryDate) ||
+ (!value && item.entryDate) ||
+ value.isDate != item.entryDate.isDate ||
+ !cal.data.compareObjects(value.timezone, item.entryDate.timezone) ||
+ value.compare(item.entryDate) != 0
+ ) {
+ item.entryDate = value;
+ }
+ break;
+ case "dueDate":
+ if (value == item.dueDate) {
+ break;
+ }
+ if (
+ (value && !item.dueDate) ||
+ (!value && item.dueDate) ||
+ value.isDate != item.dueDate.isDate ||
+ !cal.data.compareObjects(value.timezone, item.dueDate.timezone) ||
+ value.compare(item.dueDate) != 0
+ ) {
+ item.dueDate = value;
+ }
+ break;
+ case "isCompleted":
+ if (value != item.isCompleted) {
+ item.isCompleted = value;
+ }
+ break;
+ case "PERCENT-COMPLETE": {
+ let perc = parseInt(item.getProperty(propertyName), 10);
+ if (isNaN(perc)) {
+ perc = 0;
+ }
+ if (perc != value) {
+ item.setProperty(propertyName, value);
+ }
+ break;
+ }
+ case "title":
+ if (value != item.title) {
+ item.title = value;
+ }
+ break;
+ default:
+ if (!value || value == "") {
+ item.deleteProperty(propertyName);
+ } else if (item.getProperty(propertyName) != value) {
+ item.setProperty(propertyName, value);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Returns the default transparency to apply for an event depending on whether its an all-day event
+ *
+ * @param aIsAllDay If true, the default transparency for all-day events is returned
+ */
+ getEventDefaultTransparency(aIsAllDay) {
+ let transp = null;
+ if (aIsAllDay) {
+ transp = Services.prefs.getBoolPref(
+ "calendar.events.defaultTransparency.allday.transparent",
+ false
+ )
+ ? "TRANSPARENT"
+ : "OPAQUE";
+ } else {
+ transp = Services.prefs.getBoolPref(
+ "calendar.events.defaultTransparency.standard.transparent",
+ false
+ )
+ ? "TRANSPARENT"
+ : "OPAQUE";
+ }
+ return transp;
+ },
+
+ /**
+ * Compare two items by *content*, leaving out any revision information such as
+ * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED.
+
+ * The format for the parameters to ignore object is:
+ * { "PROPERTY-NAME": ["PARAM-NAME", ...] }
+ *
+ * If aIgnoreProps is not passed, these properties are ignored:
+ * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED, X-MOZ-SEND-INVITATIONS
+ *
+ * If aIgnoreParams is not passed, these parameters are ignored:
+ * ATTENDEE: CN
+ * ORGANIZER: CN
+ *
+ * @param aFirstItem The item to compare.
+ * @param aSecondItem The item to compare to.
+ * @param aIgnoreProps (optional) An array of parameters to ignore.
+ * @param aIgnoreParams (optional) An object describing which parameters to
+ * ignore.
+ * @returns True, if items match.
+ */
+ compareContent(aFirstItem, aSecondItem, aIgnoreProps, aIgnoreParams) {
+ let ignoreProps = arr2hash(
+ aIgnoreProps || [
+ "SEQUENCE",
+ "DTSTAMP",
+ "LAST-MODIFIED",
+ "X-MOZ-GENERATION",
+ "X-MICROSOFT-DISALLOW-COUNTER",
+ "X-MOZ-SEND-INVITATIONS",
+ "X-MOZ-SEND-INVITATIONS-UNDISCLOSED",
+ ]
+ );
+
+ let ignoreParams = aIgnoreParams || { ATTENDEE: ["CN"], ORGANIZER: ["CN"] };
+ for (let x in ignoreParams) {
+ ignoreParams[x] = arr2hash(ignoreParams[x]);
+ }
+
+ function arr2hash(arr) {
+ let hash = {};
+ for (let x of arr) {
+ hash[x] = true;
+ }
+ return hash;
+ }
+
+ // This doesn't have to be super correct rfc5545, it just needs to be
+ // in the same order
+ function normalizeComponent(comp) {
+ let props = [];
+ for (let prop of cal.iterate.icalProperty(comp)) {
+ if (!(prop.propertyName in ignoreProps)) {
+ props.push(normalizeProperty(prop));
+ }
+ }
+ props = props.sort();
+
+ let comps = [];
+ for (let subcomp of cal.iterate.icalSubcomponent(comp)) {
+ comps.push(normalizeComponent(subcomp));
+ }
+ comps = comps.sort();
+
+ return comp.componentType + props.join("\r\n") + comps.join("\r\n");
+ }
+
+ function normalizeProperty(prop) {
+ let params = [...cal.iterate.icalParameter(prop)]
+ .filter(
+ ([k, v]) =>
+ !(prop.propertyName in ignoreParams) || !(k in ignoreParams[prop.propertyName])
+ )
+ .map(([k, v]) => k + "=" + v)
+ .sort();
+
+ return prop.propertyName + ";" + params.join(";") + ":" + prop.valueAsIcalString;
+ }
+
+ return (
+ normalizeComponent(aFirstItem.icalComponent) == normalizeComponent(aSecondItem.icalComponent)
+ );
+ },
+
+ /**
+ * Shifts an item by the given timely offset.
+ *
+ * @param item an item
+ * @param offset an offset (calIDuration)
+ */
+ shiftOffset(item, offset) {
+ // When modifying dates explicitly using the setters is important
+ // since those may triggers e.g. calIRecurrenceInfo::onStartDateChange
+ // or invalidate other properties. Moreover don't modify the date-time objects
+ // without cloning, because changes cannot be calculated if doing so.
+ if (item.isEvent()) {
+ let date = item.startDate.clone();
+ date.addDuration(offset);
+ item.startDate = date;
+ date = item.endDate.clone();
+ date.addDuration(offset);
+ item.endDate = date;
+ } else {
+ /* isToDo */
+ if (item.entryDate) {
+ let date = item.entryDate.clone();
+ date.addDuration(offset);
+ item.entryDate = date;
+ }
+ if (item.dueDate) {
+ let date = item.dueDate.clone();
+ date.addDuration(offset);
+ item.dueDate = date;
+ }
+ }
+ },
+
+ /**
+ * moves an item to another startDate
+ *
+ * @param aOldItem The Item to be modified
+ * @param aNewDate The date at which the new item is going to start
+ * @returns The modified item
+ */
+ moveToDate(aOldItem, aNewDate) {
+ let newItem = aOldItem.clone();
+ let start = (
+ aOldItem[cal.dtz.startDateProp(aOldItem)] || aOldItem[cal.dtz.endDateProp(aOldItem)]
+ ).clone();
+ let isDate = start.isDate;
+ start.resetTo(
+ aNewDate.year,
+ aNewDate.month,
+ aNewDate.day,
+ start.hour,
+ start.minute,
+ start.second,
+ start.timezone
+ );
+ start.isDate = isDate;
+ if (newItem[cal.dtz.startDateProp(newItem)]) {
+ newItem[cal.dtz.startDateProp(newItem)] = start;
+ let oldDuration = aOldItem.duration;
+ if (oldDuration) {
+ let oldEnd = aOldItem[cal.dtz.endDateProp(aOldItem)];
+ let newEnd = start.clone();
+ newEnd.addDuration(oldDuration);
+ newEnd = newEnd.getInTimezone(oldEnd.timezone);
+ newItem[cal.dtz.endDateProp(newItem)] = newEnd;
+ }
+ } else if (newItem[cal.dtz.endDateProp(newItem)]) {
+ newItem[cal.dtz.endDateProp(newItem)] = start;
+ }
+ return newItem;
+ },
+
+ /**
+ * Shortcut function to serialize an item (including all overridden items).
+ */
+ serialize(aItem) {
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem]);
+ return serializer.serializeToString();
+ },
+
+ /**
+ * Centralized functions for accessing prodid and version
+ */
+ get productId() {
+ return "-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN";
+ },
+ get productVersion() {
+ return "2.0";
+ },
+
+ /**
+ * This is a centralized function for setting the prodid and version on an
+ * ical component. This should be used whenever you need to set the prodid
+ * and version on a calIcalComponent object.
+ *
+ * @param aIcalComponent The ical component to set the prodid and
+ * version on.
+ */
+ setStaticProps(aIcalComponent) {
+ // Throw for an invalid parameter
+ if (!aIcalComponent) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ // Set the prodid and version
+ aIcalComponent.prodid = calitem.productId;
+ aIcalComponent.version = calitem.productVersion;
+ },
+
+ /**
+ * Search for already open item dialog.
+ *
+ * @param aItem The item of the dialog to search for.
+ */
+ findWindow(aItem) {
+ // check for existing dialog windows
+ for (let dlg of Services.wm.getEnumerator("Calendar:EventDialog")) {
+ if (
+ dlg.arguments[0] &&
+ dlg.arguments[0].mode == "modify" &&
+ dlg.arguments[0].calendarEvent &&
+ dlg.arguments[0].calendarEvent.hashId == aItem.hashId
+ ) {
+ return dlg;
+ }
+ }
+ // check for existing summary windows
+ for (let dlg of Services.wm.getEnumerator("Calendar:EventSummaryDialog")) {
+ if (dlg.calendarItem && dlg.calendarItem.hashId == aItem.hashId) {
+ return dlg;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * sets the 'isDate' property of an item
+ *
+ * @param aItem The Item to be modified
+ * @param aIsDate True or false indicating the new value of 'isDate'
+ * @returns The modified item
+ */
+ setToAllDay(aItem, aIsDate) {
+ let start = aItem[cal.dtz.startDateProp(aItem)];
+ let end = aItem[cal.dtz.endDateProp(aItem)];
+ if (start || end) {
+ let item = aItem.clone();
+ if (start && start.isDate != aIsDate) {
+ start = start.clone();
+ start.isDate = aIsDate;
+ item[cal.dtz.startDateProp(item)] = start;
+ }
+ if (end && end.isDate != aIsDate) {
+ end = end.clone();
+ end.isDate = aIsDate;
+ item[cal.dtz.endDateProp(item)] = end;
+ }
+ return item;
+ }
+ return aItem;
+ },
+
+ /**
+ * This function return the progress state of a task:
+ * completed, overdue, duetoday, inprogress, future
+ *
+ * @param aTask The task to check.
+ * @returns The progress atom.
+ */
+ getProgressAtom(aTask) {
+ let nowdate = new Date();
+
+ if (aTask.recurrenceInfo) {
+ return "repeating";
+ }
+
+ if (aTask.isCompleted) {
+ return "completed";
+ }
+
+ if (aTask.dueDate && aTask.dueDate.isValid) {
+ if (cal.dtz.dateTimeToJsDate(aTask.dueDate).getTime() < nowdate.getTime()) {
+ return "overdue";
+ } else if (
+ aTask.dueDate.year == nowdate.getFullYear() &&
+ aTask.dueDate.month == nowdate.getMonth() &&
+ aTask.dueDate.day == nowdate.getDate()
+ ) {
+ return "duetoday";
+ }
+ }
+
+ if (
+ aTask.entryDate &&
+ aTask.entryDate.isValid &&
+ cal.dtz.dateTimeToJsDate(aTask.entryDate).getTime() < nowdate.getTime()
+ ) {
+ return "inprogress";
+ }
+
+ return "future";
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calIteratorUtils.jsm b/comm/calendar/base/modules/utils/calIteratorUtils.jsm
new file mode 100644
index 0000000000..2ccae6b042
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calIteratorUtils.jsm
@@ -0,0 +1,279 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Iterators for various data structures
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.iterate namespace.
+
+const EXPORTED_SYMBOLS = ["caliterate"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var caliterate = {
+ /**
+ * Iterates an array of items, i.e. the passed item including all
+ * overridden instances of a recurring series.
+ *
+ * @param {calIItemBase[]} items - array of items to iterate
+ * @yields {calIItemBase}
+ */
+ *items(items) {
+ for (let item of items) {
+ yield item;
+ let rec = item.recurrenceInfo;
+ if (rec) {
+ for (let exid of rec.getExceptionIds()) {
+ yield rec.getExceptionFor(exid);
+ }
+ }
+ }
+ },
+
+ /**
+ * Runs the body() function once for each item in the iterator using the event queue to make
+ * sure other actions could run in between. When all iterations are done (and also when
+ * cal.iterate.forEach.BREAK is returned), calls the completed() function if passed.
+ *
+ * If you would like to break or continue inside the body(), return either
+ * cal.iterate.forEach.BREAK or cal.iterate.forEach.CONTINUE
+ *
+ * Note since the event queue is used, this function will return immediately, before the
+ * iteration is complete. If you need to run actions after the real for each loop, use the
+ * optional completed() function.
+ *
+ * @param {Iterable} iterable - The Iterator or the plain Object to go through in this loop.
+ * @param {Function} body - The function called for each iteration. Its parameter is the
+ * single item from the iterator.
+ * @param {?Function} completed - [optional] The function called after the loop completes.
+ */
+ forEach: (() => {
+ // eslint-disable-next-line require-jsdoc
+ function forEach(iterable, body, completed = null) {
+ // This should be a const one day, lets keep it a pref for now though until we
+ // find a sane value.
+ let LATENCY = Services.prefs.getIntPref("calendar.threading.latency", 250);
+
+ if (typeof iterable == "object" && !iterable[Symbol.iterator]) {
+ iterable = Object.entries(iterable);
+ }
+
+ let ourIter = iterable[Symbol.iterator]();
+ let currentThread = Services.tm.currentThread;
+
+ // This is our dispatcher, it will be used for the iterations
+ let dispatcher = {
+ run() {
+ let startTime = new Date().getTime();
+ while (new Date().getTime() - startTime < LATENCY) {
+ let next = ourIter.next();
+ let done = next.done;
+
+ if (!done) {
+ let rc = body(next.value);
+ if (rc == lazy.cal.iterate.forEach.BREAK) {
+ done = true;
+ }
+ }
+
+ if (done) {
+ if (completed) {
+ completed();
+ }
+ return;
+ }
+ }
+
+ currentThread.dispatch(this, currentThread.DISPATCH_NORMAL);
+ },
+ };
+
+ currentThread.dispatch(dispatcher, currentThread.DISPATCH_NORMAL);
+ }
+ forEach.CONTINUE = 1;
+ forEach.BREAK = 2;
+
+ return forEach;
+ })(),
+
+ /**
+ * Yields all subcomponents in all calendars in the passed component.
+ * - If the passed component is an XROOT (contains multiple calendars), then go through all
+ * VCALENDARs in it and get their subcomponents.
+ * - If the passed component is a VCALENDAR, iterate through its direct subcomponents.
+ * - Otherwise assume the passed component is the item itself and yield only the passed
+ * component.
+ *
+ * This iterator can only be used in a for..of block:
+ * for (let component of cal.iterate.icalComponent(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent The component to iterate given the above rules.
+ * @param {string} aCompType The type of item to iterate.
+ * @yields {calIIcalComponent} The iterator that yields all items.
+ */
+ *icalComponent(aComponent, aCompType = "ANY") {
+ if (aComponent && aComponent.componentType == "VCALENDAR") {
+ yield* lazy.cal.iterate.icalSubcomponent(aComponent, aCompType);
+ } else if (aComponent && aComponent.componentType == "XROOT") {
+ for (let calComp of lazy.cal.iterate.icalSubcomponent(aComponent, "VCALENDAR")) {
+ yield* lazy.cal.iterate.icalSubcomponent(calComp, aCompType);
+ }
+ } else if (aComponent && (aCompType == "ANY" || aCompType == aComponent.componentType)) {
+ yield aComponent;
+ }
+ },
+
+ /**
+ * Use to iterate through all subcomponents of a calIIcalComponent. This iterators depth is 1,
+ * this means no sub-sub-components will be iterated.
+ *
+ * This iterator can only be used in a for() block:
+ * for (let component of cal.iterate.icalSubcomponent(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent - The component who's subcomponents to iterate.
+ * @param {?string} aSubcomp - (optional) the specific subcomponent to enumerate.
+ * If not given, "ANY" will be used.
+ * @yields {calIIcalComponent} An iterator object to iterate the properties.
+ */
+ *icalSubcomponent(aComponent, aSubcomp = "ANY") {
+ for (
+ let subcomp = aComponent.getFirstSubcomponent(aSubcomp);
+ subcomp;
+ subcomp = aComponent.getNextSubcomponent(aSubcomp)
+ ) {
+ yield subcomp;
+ }
+ },
+
+ /**
+ * Use to iterate through all properties of a calIIcalComponent.
+ * This iterator can only be used in a for() block:
+ * for (let property of cal.iterate.icalProperty(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent - The component to iterate.
+ * @param {?string} aProperty - (optional) the specific property to enumerate.
+ * If not given, "ANY" will be used.
+ * @yields {calIIcalProperty} An iterator object to iterate the properties.
+ */
+ *icalProperty(aComponent, aProperty = "ANY") {
+ for (
+ let prop = aComponent.getFirstProperty(aProperty);
+ prop;
+ prop = aComponent.getNextProperty(aProperty)
+ ) {
+ yield prop;
+ }
+ },
+
+ /**
+ * Use to iterate through all parameters of a calIIcalProperty.
+ * This iterator behaves similar to the object iterator. Possible uses:
+ * for (let paramName in cal.iterate.icalParameter(prop)) { ... }
+ * or:
+ * for (let [paramName, paramValue] of cal.iterate.icalParameter(prop)) { ... }
+ *
+ * @param {calIIcalProperty} aProperty - The property to iterate.
+ * @yields {[String, String]} An iterator object to iterate the properties.
+ */
+ *icalParameter(aProperty) {
+ let paramSet = new Set();
+ for (
+ let paramName = aProperty.getFirstParameterName();
+ paramName;
+ paramName = aProperty.getNextParameterName()
+ ) {
+ // Workaround to avoid infinite loop when the property
+ // contains duplicate parameters (bug 875739 for libical)
+ if (!paramSet.has(paramName)) {
+ yield [paramName, aProperty.getParameter(paramName)];
+ paramSet.add(paramName);
+ }
+ }
+ },
+
+ /**
+ * A function used to transform items received from a ReadableStream of
+ * calIItemBase instances.
+ *
+ * @callback MapStreamFunction
+ * @param {calIItemBase[]} chunk
+ *
+ * @returns {*[]|Promise<*[]>}
+ */
+
+ /**
+ * Applies the provided MapStreamFunction to each chunk received from a
+ * ReadableStream of calIItemBase instances providing the results as a single
+ * array.
+ *
+ * @param {ReadableStream} stream
+ * @param {MapStreamFunction} func
+ *
+ * @returns {*[]}
+ */
+ async mapStream(stream, func) {
+ let buffer = [];
+ for await (let value of caliterate.streamValues(stream)) {
+ buffer.push.apply(buffer, await func(value));
+ }
+ return buffer;
+ },
+ /**
+ * Converts a ReadableStream of calIItemBase into an array.
+ *
+ * @param {ReadableStream} stream
+ *
+ * @returns {calIItemBase[]}
+ */
+ async streamToArray(stream) {
+ return caliterate.mapStream(stream, chunk => chunk);
+ },
+
+ /**
+ * Provides an async iterator for the target stream allowing its values to
+ * be extracted in a for of loop.
+ *
+ * @param {ReadableStream} stream
+ *
+ * @returns {CalReadableStreamIterator}
+ */
+ streamValues(stream) {
+ return new CalReadableStreamIterator(stream);
+ },
+};
+
+/**
+ * An async iterator implementation for streams returned from getItems() and
+ * similar calls. This class can be used in a for await ... loop to extract
+ * the values of a stream.
+ */
+class CalReadableStreamIterator {
+ _stream = null;
+ _reader = null;
+
+ /**
+ * @param {ReadableStream} stream
+ */
+ constructor(stream) {
+ this._stream = stream;
+ }
+
+ [Symbol.asyncIterator]() {
+ this._reader = this._stream.getReader();
+ return this;
+ }
+
+ /**
+ * Cancels the reading of values from the underlying stream's reader.
+ */
+ async cancel() {
+ return this._reader && this._reader.cancel();
+ }
+ async next() {
+ return this._reader.read();
+ }
+}
diff --git a/comm/calendar/base/modules/utils/calItipUtils.jsm b/comm/calendar/base/modules/utils/calItipUtils.jsm
new file mode 100644
index 0000000000..fdfe8750c6
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calItipUtils.jsm
@@ -0,0 +1,2181 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Scheduling and iTIP helper code
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.itip namespace.
+
+const EXPORTED_SYMBOLS = ["calitip"];
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { calendarDeactivator } = ChromeUtils.import(
+ "resource:///modules/calendar/calCalendarDeactivator.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalItipDefaultEmailTransport: "resource:///modules/CalItipEmailTransport.jsm",
+ CalItipMessageSender: "resource:///modules/CalItipMessageSender.jsm",
+ CalItipOutgoingMessage: "resource:///modules/CalItipOutgoingMessage.jsm",
+});
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calitip = {
+ /**
+ * Gets the sequence/revision number, either of the passed item or the last received one of an
+ * attendee; see <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.1>.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - The item or attendee to get the sequence info
+ * from.
+ * @returns {number} The sequence number
+ */
+ getSequence(aItem) {
+ let seq = null;
+
+ if (calitip.isAttendee(aItem)) {
+ seq = aItem.getProperty("RECEIVED-SEQUENCE");
+ } else if (aItem) {
+ // Unless the below is standardized, we store the last original
+ // REQUEST/PUBLISH SEQUENCE in X-MOZ-RECEIVED-SEQUENCE to test against it
+ // when updates come in:
+ seq = aItem.getProperty("X-MOZ-RECEIVED-SEQUENCE");
+ if (seq === null) {
+ seq = aItem.getProperty("SEQUENCE");
+ }
+
+ // Make sure we don't have a pre Outlook 2007 appointment, but if we do
+ // use Microsoft's Sequence number. I <3 MS
+ if (seq === null || seq == "0") {
+ seq = aItem.getProperty("X-MICROSOFT-CDO-APPT-SEQUENCE");
+ }
+ }
+
+ if (seq === null) {
+ return 0;
+ }
+ seq = parseInt(seq, 10);
+ return isNaN(seq) ? 0 : seq;
+ },
+
+ /**
+ * Gets the stamp date-time, either of the passed item or the last received one of an attendee;
+ * see <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.2>.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - The item or attendee to retrieve the stamp from
+ * @returns {calIDateTime} The timestamp for the item
+ */
+ getStamp(aItem) {
+ let dtstamp = null;
+
+ if (calitip.isAttendee(aItem)) {
+ let stamp = aItem.getProperty("RECEIVED-DTSTAMP");
+ if (stamp) {
+ dtstamp = lazy.cal.createDateTime(stamp);
+ }
+ } else if (aItem) {
+ // Unless the below is standardized, we store the last original
+ // REQUEST/PUBLISH DTSTAMP in X-MOZ-RECEIVED-DTSTAMP to test against it
+ // when updates come in:
+ let stamp = aItem.getProperty("X-MOZ-RECEIVED-DTSTAMP");
+ if (stamp) {
+ dtstamp = lazy.cal.createDateTime(stamp);
+ } else {
+ // xxx todo: are there similar X-MICROSOFT-CDO properties to be considered here?
+ dtstamp = aItem.stampTime;
+ }
+ }
+
+ return dtstamp;
+ },
+
+ /**
+ * Compares sequences and/or stamps of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compare(aItem1, aItem2) {
+ let comp = calitip.compareSequence(aItem1, aItem2);
+ if (comp == 0) {
+ comp = calitip.compareStamp(aItem1, aItem2);
+ }
+ return comp;
+ },
+
+ /**
+ * Compares sequences of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compareSequence(aItem1, aItem2) {
+ let seq1 = calitip.getSequence(aItem1);
+ let seq2 = calitip.getSequence(aItem2);
+ if (seq1 > seq2) {
+ return 1;
+ } else if (seq1 < seq2) {
+ return -1;
+ }
+ return 0;
+ },
+
+ /**
+ * Compares stamp of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compareStamp(aItem1, aItem2) {
+ let st1 = calitip.getStamp(aItem1);
+ let st2 = calitip.getStamp(aItem2);
+ if (st1 && st2) {
+ return st1.compare(st2);
+ } else if (!st1 && st2) {
+ return -1;
+ } else if (st1 && !st2) {
+ return 1;
+ }
+ return 0;
+ },
+
+ /**
+ * Creates an organizer calIAttendee object based on the calendar's configured organizer id.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get the organizer id from
+ * @returns {calIAttendee} The organizer attendee
+ */
+ createOrganizer(aCalendar) {
+ let orgId = aCalendar.getProperty("organizerId");
+ if (!orgId) {
+ return null;
+ }
+ let organizer = new lazy.CalAttendee();
+ organizer.id = orgId;
+ organizer.commonName = aCalendar.getProperty("organizerCN");
+ organizer.role = "REQ-PARTICIPANT";
+ organizer.participationStatus = "ACCEPTED";
+ organizer.isOrganizer = true;
+ return organizer;
+ },
+
+ /**
+ * Checks if the given calendar is a scheduling calendar. This means it
+ * needs an organizer id and an itip transport. It should also be writable.
+ *
+ * @param {calICalendar} aCalendar - The calendar to check
+ * @returns {boolean} True, if its a scheduling calendar.
+ */
+ isSchedulingCalendar(aCalendar) {
+ return (
+ lazy.cal.acl.isCalendarWritable(aCalendar) &&
+ aCalendar.getProperty("organizerId") &&
+ aCalendar.getProperty("itip.transport")
+ );
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Given an nsIMsgDBHdr and an imipMethod, set up the given itip item.
+ *
+ * @param {calIItemBase} itipItem - The item to set up
+ * @param {string} imipMethod - The received imip method
+ * @param {nsIMsgDBHdr} aMsgHdr - Information about the received email
+ */
+ initItemFromMsgData(itipItem, imipMethod, aMsgHdr) {
+ // set the sender of the itip message
+ itipItem.sender = calitip.getMessageSender(aMsgHdr);
+
+ // Get the recipient identity and save it with the itip item.
+ itipItem.identity = calitip.getMessageRecipient(aMsgHdr);
+
+ // We are only called upon receipt of an invite, so ensure that isSend
+ // is false.
+ itipItem.isSend = false;
+
+ // XXX Get these from preferences
+ itipItem.autoResponse = Ci.calIItipItem.USER;
+
+ if (imipMethod && imipMethod.length != 0 && imipMethod.toLowerCase() != "nomethod") {
+ itipItem.receivedMethod = imipMethod.toUpperCase();
+ } else {
+ // There is no METHOD in the content-type header (spec violation).
+ // Fall back to using the one from the itipItem's ICS.
+ imipMethod = itipItem.receivedMethod;
+ }
+ lazy.cal.LOG("iTIP method: " + imipMethod);
+
+ let isWritableCalendar = function (aCalendar) {
+ /* TODO: missing ACL check for existing items (require callback API) */
+ return (
+ calitip.isSchedulingCalendar(aCalendar) && lazy.cal.acl.userCanAddItemsToCalendar(aCalendar)
+ );
+ };
+
+ let writableCalendars = lazy.cal.manager.getCalendars().filter(isWritableCalendar);
+ if (writableCalendars.length > 0) {
+ let compCal = Cc["@mozilla.org/calendar/calendar;1?type=composite"].createInstance(
+ Ci.calICompositeCalendar
+ );
+ writableCalendars.forEach(compCal.addCalendar, compCal);
+ itipItem.targetCalendar = compCal;
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets the suggested text to be shown when an imip item has been processed.
+ * This text is ready localized and can be displayed to the user.
+ *
+ * @param {number} aStatus - The status of the processing (i.e NS_OK, an error code)
+ * @param {number} aOperationType - An operation type from calIOperationListener
+ * @returns {string} The suggested text.
+ */
+ getCompleteText(aStatus, aOperationType) {
+ let text = "";
+ const cIOL = Ci.calIOperationListener;
+ if (Components.isSuccessCode(aStatus)) {
+ switch (aOperationType) {
+ case cIOL.ADD:
+ text = lazy.cal.l10n.getLtnString("imipAddedItemToCal2");
+ break;
+ case cIOL.MODIFY:
+ text = lazy.cal.l10n.getLtnString("imipUpdatedItem2");
+ break;
+ case cIOL.DELETE:
+ text = lazy.cal.l10n.getLtnString("imipCanceledItem2");
+ break;
+ }
+ } else {
+ text = lazy.cal.l10n.getLtnString("imipBarProcessingFailed", [aStatus.toString(16)]);
+ }
+ return text;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets a text describing the given itip method. The text is of the form
+ * "This Message contains a ... ".
+ *
+ * @param {string} method - The method to describe.
+ * @returns {string} The localized text about the method.
+ */
+ getMethodText(method) {
+ switch (method) {
+ case "REFRESH":
+ return lazy.cal.l10n.getLtnString("imipBarRefreshText");
+ case "REQUEST":
+ return lazy.cal.l10n.getLtnString("imipBarRequestText");
+ case "PUBLISH":
+ return lazy.cal.l10n.getLtnString("imipBarPublishText");
+ case "CANCEL":
+ return lazy.cal.l10n.getLtnString("imipBarCancelText");
+ case "REPLY":
+ return lazy.cal.l10n.getLtnString("imipBarReplyText");
+ case "COUNTER":
+ return lazy.cal.l10n.getLtnString("imipBarCounterText");
+ case "DECLINECOUNTER":
+ return lazy.cal.l10n.getLtnString("imipBarDeclineCounterText");
+ default:
+ lazy.cal.ERROR("Unknown iTIP method: " + method);
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ return lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets localized toolbar label about the message state and triggers buttons to show.
+ * This returns a JS object with the following structure:
+ *
+ * {
+ * label: "This is a desciptive text about the itip item",
+ * showItems: ["imipXXXButton", ...],
+ * hideItems: ["imipXXXButton_Option", ...]
+ * }
+ *
+ * @see processItipItem This takes the same parameters as its optionFunc.
+ * @param {calIItipItem} itipItem - The itipItem to query.
+ * @param {number} rc - The result of retrieving the item
+ * @param {Function} actionFunc - The action function.
+ * @param {calIItemBase[]} foundItems - An array of items found while searching for the item
+ * in subscribed calendars
+ * @returns {object} Return information about the options
+ */
+ getOptionsText(itipItem, rc, actionFunc, foundItems) {
+ let imipLabel = null;
+ if (itipItem.receivedMethod) {
+ imipLabel = calitip.getMethodText(itipItem.receivedMethod);
+ }
+ let data = { label: imipLabel, showItems: [], hideItems: [] };
+ let separateButtons = Services.prefs.getBoolPref(
+ "calendar.itip.separateInvitationButtons",
+ false
+ );
+
+ let disallowedCounter = false;
+ if (foundItems && foundItems.length) {
+ let disallow = foundItems[0].getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ disallowedCounter = disallow && disallow == "TRUE";
+ }
+ if (!calendarDeactivator.isCalendarActivated) {
+ // Calendar is deactivated (no calendars are enabled).
+ data.label = lazy.cal.l10n.getLtnString("imipBarCalendarDeactivated");
+ data.showItems.push("imipGoToCalendarButton", "imipMoreButton");
+ data.hideItems.push("imipMoreButton_SaveCopy");
+ } else if (rc == Ci.calIErrors.CAL_IS_READONLY) {
+ // No writable calendars, tell the user about it
+ data.label = lazy.cal.l10n.getLtnString("imipBarNotWritable");
+ data.showItems.push("imipGoToCalendarButton", "imipMoreButton");
+ data.hideItems.push("imipMoreButton_SaveCopy");
+ } else if (Components.isSuccessCode(rc) && !actionFunc) {
+ // This case, they clicked on an old message that has already been
+ // added/updated, we want to tell them that.
+ data.label = lazy.cal.l10n.getLtnString("imipBarAlreadyProcessedText");
+ if (foundItems && foundItems.length) {
+ data.showItems.push("imipDetailsButton");
+ if (itipItem.receivedMethod == "COUNTER" && itipItem.sender) {
+ if (disallowedCounter) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDisallowedCounterText");
+ } else {
+ let comparison;
+ for (let item of itipItem.getItemList()) {
+ let attendees = lazy.cal.itip.getAttendeesBySender(
+ item.getAttendees(),
+ itipItem.sender
+ );
+ if (attendees.length == 1) {
+ comparison = calitip.compareSequence(item, foundItems[0]);
+ if (comparison == 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarCounterErrorText");
+ break;
+ } else if (comparison == -1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarCounterPreviousVersionText");
+ }
+ }
+ }
+ }
+ }
+ } else if (itipItem.receivedMethod == "REPLY") {
+ // The item has been previously removed from the available calendars or the calendar
+ // containing the item is not available
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ );
+ let delTime = null;
+ let items = itipItem.getItemList();
+ if (items && items.length) {
+ delTime = delmgr.getDeletedDate(items[0].id);
+ }
+ if (delTime) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarReplyToRecentlyRemovedItem", [
+ lazy.cal.dtz.formatter.formatTime(delTime),
+ ]);
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarReplyToNotExistingItem");
+ }
+ } else if (itipItem.receivedMethod == "DECLINECOUNTER") {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDeclineCounterText");
+ }
+ } else if (Components.isSuccessCode(rc)) {
+ lazy.cal.LOG("iTIP options on: " + actionFunc.method);
+ switch (actionFunc.method) {
+ case "PUBLISH:UPDATE":
+ case "REQUEST:UPDATE-MINOR":
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateText");
+ // falls through
+ case "REPLY":
+ data.showItems.push("imipUpdateButton");
+ break;
+ case "PUBLISH":
+ data.showItems.push("imipAddButton");
+ break;
+ case "REQUEST:UPDATE":
+ case "REQUEST:NEEDS-ACTION":
+ case "REQUEST": {
+ let isRecurringMaster = false;
+ for (let item of itipItem.getItemList()) {
+ if (item.recurrenceInfo) {
+ isRecurringMaster = true;
+ }
+ }
+
+ if (actionFunc.method == "REQUEST:UPDATE") {
+ if (isRecurringMaster) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateSeriesText");
+ } else if (itipItem.getItemList().length > 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateMultipleText");
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateText");
+ }
+ } else if (actionFunc.method == "REQUEST:NEEDS-ACTION") {
+ if (isRecurringMaster) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedSeriesNeedsAction");
+ } else if (itipItem.getItemList().length > 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedMultipleNeedsAction");
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedNeedsAction");
+ }
+ }
+
+ if (itipItem.getItemList().length > 1 || isRecurringMaster) {
+ data.showItems.push("imipAcceptRecurrencesButton");
+ if (separateButtons) {
+ data.showItems.push("imipTentativeRecurrencesButton");
+ data.hideItems.push("imipAcceptRecurrencesButton_AcceptLabel");
+ data.hideItems.push("imipAcceptRecurrencesButton_TentativeLabel");
+ data.hideItems.push("imipAcceptRecurrencesButton_Tentative");
+ data.hideItems.push("imipAcceptRecurrencesButton_TentativeDontSend");
+ } else {
+ data.hideItems.push("imipTentativeRecurrencesButton");
+ data.showItems.push("imipAcceptRecurrencesButton_AcceptLabel");
+ data.showItems.push("imipAcceptRecurrencesButton_TentativeLabel");
+ data.showItems.push("imipAcceptRecurrencesButton_Tentative");
+ data.showItems.push("imipAcceptRecurrencesButton_TentativeDontSend");
+ }
+ data.showItems.push("imipDeclineRecurrencesButton");
+ } else {
+ data.showItems.push("imipAcceptButton");
+ if (separateButtons) {
+ data.showItems.push("imipTentativeButton");
+ data.hideItems.push("imipAcceptButton_AcceptLabel");
+ data.hideItems.push("imipAcceptButton_TentativeLabel");
+ data.hideItems.push("imipAcceptButton_Tentative");
+ data.hideItems.push("imipAcceptButton_TentativeDontSend");
+ } else {
+ data.hideItems.push("imipTentativeButton");
+ data.showItems.push("imipAcceptButton_AcceptLabel");
+ data.showItems.push("imipAcceptButton_TentativeLabel");
+ data.showItems.push("imipAcceptButton_Tentative");
+ data.showItems.push("imipAcceptButton_TentativeDontSend");
+ }
+ data.showItems.push("imipDeclineButton");
+ }
+ data.showItems.push("imipMoreButton");
+ // Use data.hideItems.push("idOfMenuItem") to hide specific menuitems
+ // from the dropdown menu of a button. This might be useful to remove
+ // a generally available option for a specific invitation, because the
+ // respective feature is not available for the calendar, the invitation
+ // is in or the feature is prohibited by the organizer
+ break;
+ }
+ case "CANCEL": {
+ data.showItems.push("imipDeleteButton");
+ break;
+ }
+ case "REFRESH": {
+ data.showItems.push("imipReconfirmButton");
+ break;
+ }
+ case "COUNTER": {
+ if (disallowedCounter) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDisallowedCounterText");
+ }
+ data.showItems.push("imipDeclineCounterButton");
+ data.showItems.push("imipRescheduleButton");
+ break;
+ }
+ default:
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ data.label = lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ break;
+ }
+ } else {
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ data.label = lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ }
+
+ return data;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ * Retrieves the message sender.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message header to check.
+ * @returns {string} The email address of the intended recipient.
+ */
+ getMessageSender(aMsgHdr) {
+ let author = (aMsgHdr && aMsgHdr.author) || "";
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ let addresses = compFields.splitRecipients(author, true);
+ if (addresses.length != 1) {
+ lazy.cal.LOG("No unique email address for lookup in message.\r\n" + lazy.cal.STACK(20));
+ }
+ return addresses[0] || null;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Retrieves the intended recipient for this message.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message to check.
+ * @returns {string} The email of the intended recipient.
+ */
+ getMessageRecipient(aMsgHdr) {
+ if (!aMsgHdr) {
+ return null;
+ }
+
+ let identities;
+ if (aMsgHdr.accountKey) {
+ // First, check if the message has an account key. If so, we can use the
+ // account identities to find the correct recipient
+ identities = MailServices.accounts.getAccount(aMsgHdr.accountKey).identities;
+ } else if (aMsgHdr.folder) {
+ // Without an account key, we have to revert back to using the server
+ identities = MailServices.accounts.getIdentitiesForServer(aMsgHdr.folder.server);
+ }
+
+ let emailMap = {};
+ if (!identities || identities.length == 0) {
+ let identity;
+ // If we were not able to retrieve identities above, then we have no
+ // choice but to revert to the default identity.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+ if (!identity) {
+ // If there isn't a default identity (i.e Local Folders is your
+ // default identity), then go ahead and use the first available
+ // identity.
+ let allIdentities = MailServices.accounts.allIdentities;
+ if (allIdentities.length > 0) {
+ identity = allIdentities[0];
+ } else {
+ // If there are no identities at all, we cannot get a recipient.
+ return null;
+ }
+ }
+ emailMap[identity.email.toLowerCase()] = true;
+ } else {
+ // Build a map of usable email addresses
+ for (let identity of identities) {
+ emailMap[identity.email.toLowerCase()] = true;
+ }
+ }
+
+ // First check the recipient list
+ let toList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.recipients || "");
+ for (let recipient of toList) {
+ if (recipient.email.toLowerCase() in emailMap) {
+ // Return the first found recipient
+ return recipient;
+ }
+ }
+
+ // Maybe we are in the CC list?
+ let ccList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.ccList || "");
+ for (let recipient of ccList) {
+ if (recipient.email.toLowerCase() in emailMap) {
+ // Return the first found recipient
+ return recipient;
+ }
+ }
+
+ // Hrmpf. Looks like delegation or maybe Bcc.
+ return null;
+ },
+
+ /**
+ * Executes an action from a calandar message.
+ *
+ * @param {nsIWindow} aWindow - The current window
+ * @param {string} aParticipantStatus - A partstat string as per RfC 5545
+ * @param {string} aResponse - Either 'AUTO', 'NONE' or 'USER', see
+ * calItipItem interface
+ * @param {Function} aActionFunc - The function to call to do the scheduling
+ * operation
+ * @param {calIItipItem} aItipItem - Scheduling item
+ * @param {array} aFoundItems - The items found when looking for the calendar item
+ * @param {Function} aUpdateFunction - A function to call which will update the UI
+ * @returns {boolean} true, if the action succeeded
+ */
+ executeAction(
+ aWindow,
+ aParticipantStatus,
+ aResponse,
+ aActionFunc,
+ aItipItem,
+ aFoundItems,
+ aUpdateFunction
+ ) {
+ // control to avoid processing _execAction on later user changes on the item
+ let isFirstProcessing = true;
+
+ /**
+ * Internal function to trigger an scheduling operation
+ *
+ * @param {Function} aActionFunc - The function to call to do the
+ * scheduling operation
+ * @param {calIItipItem} aItipItem - Scheduling item
+ * @param {nsIWindow} aWindow - The current window
+ * @param {string} aPartStat - partstat string as per RFC 5545
+ * @param {object} aExtResponse - JS object containing at least an responseMode
+ * property
+ * @returns {boolean} true, if the action succeeded
+ */
+ function _execAction(aActionFunc, aItipItem, aWindow, aPartStat, aExtResponse) {
+ let method = aActionFunc.method;
+ if (lazy.cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) {
+ if (
+ method == "REQUEST" &&
+ !lazy.cal.itip.promptInvitedAttendee(aWindow, aItipItem, Ci.calIItipItem[aResponse])
+ ) {
+ return false;
+ }
+
+ let isDeclineCounter = aPartStat == "X-DECLINECOUNTER";
+ // filter out fake partstats
+ if (aPartStat.startsWith("X-")) {
+ aParticipantStatus = "";
+ }
+ // hide the buttons now, to disable pressing them twice...
+ if (aPartStat == aParticipantStatus) {
+ aUpdateFunction({ resetButtons: true });
+ }
+
+ let opListener = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) {
+ isFirstProcessing = false;
+ if (Components.isSuccessCode(aStatus) && isDeclineCounter) {
+ // TODO: move the DECLINECOUNTER stuff to actionFunc
+ aItipItem.getItemList().forEach(aItem => {
+ // we can rely on the received itipItem to reply at this stage
+ // already, the checks have been done in cal.itip.processFoundItems
+ // when setting up the respective aActionFunc
+ let attendees = lazy.cal.itip.getAttendeesBySender(
+ aItem.getAttendees(),
+ aItipItem.sender
+ );
+ let status = true;
+ if (attendees.length == 1 && aFoundItems?.length) {
+ // we must return a message with the same sequence number as the
+ // counterproposal - to make it easy, we simply use the received
+ // item and just remove a comment, if any
+ try {
+ let item = aItem.clone();
+ item.calendar = aFoundItems[0].calendar;
+ item.deleteProperty("COMMENT");
+ // once we have full support to deal with for multiple items
+ // in a received invitation message, we should send this
+ // from outside outside of the forEach context
+ status = lazy.cal.itip.sendDeclineCounterMessage(
+ item,
+ "DECLINECOUNTER",
+ attendees,
+ {
+ value: false,
+ }
+ );
+ } catch (e) {
+ lazy.cal.ERROR(e);
+ status = false;
+ }
+ } else {
+ status = false;
+ }
+ if (!status) {
+ lazy.cal.ERROR("Failed to send DECLINECOUNTER reply!");
+ }
+ });
+ }
+ // For now, we just state the status for the user something very simple
+ let label = lazy.cal.itip.getCompleteText(aStatus, aOperationType);
+ aUpdateFunction({ label });
+
+ if (!Components.isSuccessCode(aStatus)) {
+ lazy.cal.showError(label);
+ return;
+ }
+
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ aWindow.dispatchEvent(
+ new CustomEvent("onItipItemActionFinished", { detail: aItipItem })
+ );
+ }
+ },
+ onGetResult(calendar, status, itemType, detail, items) {},
+ };
+
+ try {
+ aActionFunc(opListener, aParticipantStatus, aExtResponse);
+ } catch (exc) {
+ console.error(exc);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ if (aParticipantStatus == null) {
+ aParticipantStatus = "";
+ }
+ if (aParticipantStatus == "X-SHOWDETAILS" || aParticipantStatus == "X-RESCHEDULE") {
+ let counterProposal;
+ if (aFoundItems?.length) {
+ let item = aFoundItems[0].isMutable ? aFoundItems[0] : aFoundItems[0].clone();
+
+ if (aParticipantStatus == "X-RESCHEDULE") {
+ // TODO most of the following should be moved to the actionFunc defined in
+ // calItipUtils
+ let proposedItem = aItipItem.getItemList()[0];
+ let proposedRID = proposedItem.getProperty("RECURRENCE-ID");
+ if (proposedRID) {
+ // if this is a counterproposal for a specific occurrence, we use
+ // that to compare with
+ item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone();
+ }
+ let parsedProposal = lazy.cal.invitation.parseCounter(proposedItem, item);
+ let potentialProposers = lazy.cal.itip.getAttendeesBySender(
+ proposedItem.getAttendees(),
+ aItipItem.sender
+ );
+ let proposingAttendee = potentialProposers.length == 1 ? potentialProposers[0] : null;
+ if (
+ proposingAttendee &&
+ ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type)
+ ) {
+ counterProposal = {
+ attendee: proposingAttendee,
+ proposal: parsedProposal.differences,
+ oldVersion:
+ parsedProposal.result == "OLDVERSION" || parsedProposal.result == "NOTLATESTUPDATE",
+ onReschedule: () => {
+ aUpdateFunction({
+ label: lazy.cal.l10n.getLtnString("imipBarCounterPreviousVersionText"),
+ });
+ // TODO: should we hide the buttons in this case, too?
+ },
+ };
+ } else {
+ aUpdateFunction({
+ label: lazy.cal.l10n.getLtnString("imipBarCounterErrorText"),
+ resetButtons: true,
+ });
+ if (proposingAttendee) {
+ lazy.cal.LOG(parsedProposal.result.descr);
+ } else {
+ lazy.cal.LOG("Failed to identify the sending attendee of the counterproposal.");
+ }
+
+ return false;
+ }
+ }
+ // if this a rescheduling operation, we suppress the occurrence
+ // prompt here
+ aWindow.modifyEventWithDialog(
+ item,
+ aParticipantStatus != "X-RESCHEDULE",
+ null,
+ counterProposal
+ );
+ }
+ } else {
+ let response;
+ if (aResponse) {
+ if (aResponse == "AUTO" || aResponse == "NONE" || aResponse == "USER") {
+ response = { responseMode: Ci.calIItipItem[aResponse] };
+ }
+ // Open an extended response dialog to enable the user to add a comment, make a
+ // counterproposal, delegate the event or interact in another way.
+ // Instead of a dialog, this might be implemented as a separate container inside the
+ // imip-overlay as proposed in bug 458578
+ }
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ );
+ let items = aItipItem.getItemList();
+ if (items && items.length) {
+ let delTime = delmgr.getDeletedDate(items[0].id);
+ let dialogText = lazy.cal.l10n.getLtnString("confirmProcessInvitation");
+ let dialogTitle = lazy.cal.l10n.getLtnString("confirmProcessInvitationTitle");
+ if (delTime && !Services.prompt.confirm(aWindow, dialogTitle, dialogText)) {
+ return false;
+ }
+ }
+
+ if (aParticipantStatus == "X-SAVECOPY") {
+ // we create and adopt copies of the respective events
+ let saveitems = aItipItem
+ .getItemList()
+ .map(lazy.cal.itip.getPublishLikeItemCopy.bind(lazy.cal));
+ if (saveitems.length > 0) {
+ let methods = { receivedMethod: "PUBLISH", responseMethod: "PUBLISH" };
+ let newItipItem = lazy.cal.itip.getModifiedItipItem(aItipItem, saveitems, methods);
+ // setup callback and trigger re-processing
+ let storeCopy = function (aItipItem, aRc, aActionFunc, aFoundItems) {
+ if (isFirstProcessing && aActionFunc && Components.isSuccessCode(aRc)) {
+ _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus);
+ }
+ };
+ lazy.cal.itip.processItipItem(newItipItem, storeCopy);
+ }
+ // we stop here to not process the original item
+ return false;
+ }
+ return _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus, response);
+ }
+ return false;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Prompt for the target calendar, if needed for the given method. This calendar will be set on
+ * the passed itip item.
+ *
+ * @param {string} aMethod - The method to check.
+ * @param {calIItipItem} aItipItem - The itip item to set the target calendar on.
+ * @param {DOMWindpw} aWindow - The window to open the dialog on.
+ * @returns {boolean} True, if a calendar was selected or no selection is needed.
+ */
+ promptCalendar(aMethod, aItipItem, aWindow) {
+ let needsCalendar = false;
+ let targetCalendar = null;
+ switch (aMethod) {
+ // methods that don't require the calendar chooser:
+ case "REFRESH":
+ case "REQUEST:UPDATE":
+ case "REQUEST:UPDATE-MINOR":
+ case "PUBLISH:UPDATE":
+ case "REPLY":
+ case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ needsCalendar = false;
+ break;
+ default:
+ needsCalendar = true;
+ break;
+ }
+
+ if (needsCalendar) {
+ let calendars = lazy.cal.manager.getCalendars().filter(calitip.isSchedulingCalendar);
+
+ if (aItipItem.receivedMethod == "REQUEST") {
+ // try to further limit down the list to those calendars that
+ // are configured to a matching attendee;
+ let item = aItipItem.getItemList()[0];
+ let matchingCals = calendars.filter(
+ calendar => calitip.getInvitedAttendee(item, calendar) != null
+ );
+ // if there's none, we will show the whole list of calendars:
+ if (matchingCals.length > 0) {
+ calendars = matchingCals;
+ }
+ }
+
+ if (calendars.length == 0) {
+ let msg = lazy.cal.l10n.getLtnString("imipNoCalendarAvailable");
+ aWindow.alert(msg);
+ } else if (calendars.length == 1) {
+ // There's only one calendar, so it's silly to ask what calendar
+ // the user wants to import into.
+ targetCalendar = calendars[0];
+ } else {
+ // Ask what calendar to import into
+ let args = {};
+ args.calendars = calendars;
+ args.onOk = aCal => {
+ targetCalendar = aCal;
+ };
+ args.promptText = lazy.cal.l10n.getCalString("importPrompt");
+ aWindow.openDialog(
+ "chrome://calendar/content/chooseCalendarDialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ }
+
+ if (targetCalendar) {
+ aItipItem.targetCalendar = targetCalendar;
+ }
+ }
+
+ return !needsCalendar || targetCalendar != null;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Prompt for the invited attendee if we cannot automatically determine one.
+ * This will modify the items of the passed calIItipItem to ensure an invited
+ * attendee is available.
+ *
+ * Note: This is intended for the REQUEST/COUNTER methods.
+ *
+ * @param {Window} window - Used to prompt the user.
+ * @param {calIItipItem} itipItem - The itip item to ensure.
+ * @param {number} responseMode - One of the calIITipItem response mode
+ * constants indicating whether a response
+ * will be sent or not.
+ *
+ * @returns {boolean} True if an invited attendee is available for all
+ * items, false if otherwise.
+ */
+ promptInvitedAttendee(window, itipItem, responseMode) {
+ let cancelled = false;
+ for (let item of itipItem.getItemList()) {
+ let att = calitip.getInvitedAttendee(item, itipItem.targetCalendar);
+ if (!att) {
+ window.openDialog(
+ "chrome://calendar/content/calendar-itip-identity-dialog.xhtml",
+ "_blank",
+ "chrome,modal,resizable=no,centerscreen",
+ {
+ responseMode,
+ identities: MailServices.accounts.allIdentities.slice().sort((a, b) => {
+ if (a.email == itipItem.identity && b.email != itipItem.identity) {
+ return -1;
+ }
+ if (b.email == itipItem.identity && a.email != itipItem.identity) {
+ return 1;
+ }
+ return 0;
+ }),
+ onCancel() {
+ cancelled = true;
+ },
+ onOk(identity) {
+ att = new lazy.CalAttendee();
+ att.id = `mailto:${identity.email}`;
+ att.commonName = identity.fullName;
+ att.isOrganizer = false;
+ item.addAttendee(att);
+ },
+ }
+ );
+ }
+
+ if (cancelled) {
+ break;
+ }
+
+ if (att) {
+ let { stampTime, lastModifiedTime } = item;
+
+ // Set this so we know who accepted the event.
+ item.setProperty("X-MOZ-INVITED-ATTENDEE", att.id);
+
+ // Remove the dirty flag from the item.
+ item.setProperty("DTSTAMP", stampTime);
+ item.setProperty("LAST-MODIFIED", lastModifiedTime);
+ }
+ }
+
+ return !cancelled;
+ },
+
+ /**
+ * Clean up after the given iTIP item. This needs to be called once for each time
+ * processItipItem is called. May be called with a null itipItem in which case it will do
+ * nothing.
+ *
+ * @param {calIItipItem} itipItem - The iTIP item to clean up for.
+ */
+ cleanupItipItem(itipItem) {
+ if (itipItem) {
+ let itemList = itipItem.getItemList();
+ if (itemList.length > 0) {
+ // Again, we can assume the id is the same over all items per spec
+ ItipItemFinderFactory.cleanup(itemList[0].id);
+ }
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Checks the passed iTIP item and calls the passed function with options offered. Be sure to
+ * call cleanupItipItem at least once after calling this function.
+ *
+ * The action func has a property |method| showing the options:
+ * REFRESH -- send the latest item (sent by attendee(s))
+ * PUBLISH -- initial publish, no reply (sent by organizer)
+ * PUBLISH:UPDATE -- update of a published item (sent by organizer)
+ * REQUEST -- initial invitation (sent by organizer)
+ * REQUEST:UPDATE -- rescheduling invitation, has major change (sent by organizer)
+ * REQUEST:UPDATE-MINOR -- update of invitation, minor change (sent by organizer)
+ * REPLY -- invitation reply (sent by attendee(s))
+ * CANCEL -- invitation cancel (sent by organizer)
+ * COUNTER -- counterproposal (sent by attendee)
+ * DECLINECOUNTER -- denial of a counterproposal (sent by organizer)
+ *
+ * @param {calIItipItem} itipItem - The iTIP item
+ * @param {Function} optionsFunc - The function being called with parameters: itipItem,
+ * resultCode, actionFunc
+ */
+ processItipItem(itipItem, optionsFunc) {
+ switch (itipItem.receivedMethod.toUpperCase()) {
+ case "REFRESH":
+ case "PUBLISH":
+ case "REQUEST":
+ case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ case "REPLY": {
+ // Per iTIP spec (new Draft 4), multiple items in an iTIP message MUST have
+ // same ID, this simplifies our searching, we can just look for Item[0].id
+ let itemList = itipItem.getItemList();
+ if (!itipItem.targetCalendar) {
+ optionsFunc(itipItem, Ci.calIErrors.CAL_IS_READONLY);
+ } else if (itemList.length > 0) {
+ ItipItemFinderFactory.findItem(itemList[0].id, itipItem, optionsFunc);
+ } else if (optionsFunc) {
+ optionsFunc(itipItem, Cr.NS_OK);
+ }
+ break;
+ }
+ default: {
+ if (optionsFunc) {
+ optionsFunc(itipItem, Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Scope: iTIP message sender
+ *
+ * Checks to see if e.g. attendees were added/removed or an item has been deleted and sends out
+ * appropriate iTIP messages.
+ *
+ * @param {number} aOpType - Type of operation - (e.g. ADD, MODIFY or DELETE)
+ * @param {calIItemBase} aItem - The updated item
+ * @param {calIItemBase} aOriginalItem - The original item
+ * @param {?object} aExtResponse - An object to provide additional
+ * parameters for sending itip messages as response
+ * mode, comments or a subset of recipients. Currently
+ * implemented attributes are:
+ * responseMode Response mode (long) as defined for autoResponse
+ * of calIItipItem. The default mode is USER (which
+ * will trigger displaying the previously known popup
+ * to ask the user whether to send)
+ */
+ checkAndSend(aOpType, aItem, aOriginalItem, aExtResponse = null) {
+ // `CalItipMessageSender` uses the presence of an "invited attendee"
+ // (representation of the current user) as an indication that this is an
+ // incoming invitation, so we need to avoid passing it if the current user
+ // is the event organizer.
+ let currentUserAsAttendee = null;
+ const itemCalendar = aItem.calendar;
+ if (
+ itemCalendar?.supportsScheduling &&
+ itemCalendar.getSchedulingSupport().isInvitation(aItem)
+ ) {
+ currentUserAsAttendee = this.getInvitedAttendee(aItem, itemCalendar);
+ }
+
+ const sender = new lazy.CalItipMessageSender(aOriginalItem, currentUserAsAttendee);
+ if (sender.buildOutgoingMessages(aOpType, aItem, aExtResponse)) {
+ sender.send(calitip.getImipTransport(aItem));
+ }
+ },
+
+ /**
+ * Bumps the SEQUENCE in case of a major change; XXX todo may need more fine-tuning.
+ *
+ * @param {calIItemBase} newItem - The new item to set the sequence on
+ * @param {calIItemBase} oldItem - The old item to get the previous version from.
+ * @returns {calIItemBase} The newly changed item
+ */
+ prepareSequence(newItem, oldItem) {
+ if (calitip.isInvitation(newItem)) {
+ return newItem; // invitation copies don't bump the SEQUENCE
+ }
+
+ if (newItem.recurrenceId && !oldItem.recurrenceId && oldItem.recurrenceInfo) {
+ // XXX todo: there's still the bug that modifyItem is called with mixed occurrence/parent,
+ // find original occurrence
+ oldItem = oldItem.recurrenceInfo.getOccurrenceFor(newItem.recurrenceId);
+ lazy.cal.ASSERT(oldItem, "unexpected!");
+ if (!oldItem) {
+ return newItem;
+ }
+ }
+
+ let hashMajorProps = function (aItem) {
+ const majorProps = {
+ DTSTART: true,
+ DTEND: true,
+ DURATION: true,
+ DUE: true,
+ RDATE: true,
+ RRULE: true,
+ EXDATE: true,
+ STATUS: true,
+ LOCATION: true,
+ };
+
+ let propStrings = [];
+ for (let item of lazy.cal.iterate.items([aItem])) {
+ for (let prop of lazy.cal.iterate.icalProperty(item.icalComponent)) {
+ if (prop.propertyName in majorProps) {
+ propStrings.push(item.recurrenceId + "#" + prop.icalString);
+ }
+ }
+ }
+ propStrings.sort();
+ return propStrings.join("");
+ };
+
+ let hash1 = hashMajorProps(newItem);
+ let hash2 = hashMajorProps(oldItem);
+ if (hash1 != hash2) {
+ newItem = newItem.clone();
+ // bump SEQUENCE, it never decreases (mind undo scenario here)
+ newItem.setProperty(
+ "SEQUENCE",
+ String(Math.max(calitip.getSequence(oldItem), calitip.getSequence(newItem)) + 1)
+ );
+ }
+
+ return newItem;
+ },
+
+ /**
+ * Returns a copy of an itipItem with modified properties and items build from scratch Use
+ * itipItem.clone() instead if only a simple copy is required
+ *
+ * @param {calIItipItem} aItipItem ItipItem to derive a new one from
+ * @param {calIItemBase[]} aItems calIEvent or calITodo items to be contained in the new itipItem
+ * @param {object} aProps Properties to be different in the new itipItem
+ * @returns {calIItipItem} The copied and modified item
+ */
+ getModifiedItipItem(aItipItem, aItems = [], aProps = {}) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ let serializedItems = "";
+ for (let item of aItems) {
+ serializedItems += lazy.cal.item.serialize(item);
+ }
+ itipItem.init(serializedItems);
+
+ itipItem.autoResponse = "autoResponse" in aProps ? aProps.autoResponse : aItipItem.autoResponse;
+ itipItem.identity = "identity" in aProps ? aProps.identity : aItipItem.identity;
+ itipItem.isSend = "isSend" in aProps ? aProps.isSend : aItipItem.isSend;
+ itipItem.localStatus = "localStatus" in aProps ? aProps.localStatus : aItipItem.localStatus;
+ itipItem.receivedMethod =
+ "receivedMethod" in aProps ? aProps.receivedMethod : aItipItem.receivedMethod;
+ itipItem.responseMethod =
+ "responseMethod" in aProps ? aProps.responseMethod : aItipItem.responseMethod;
+ itipItem.targetCalendar =
+ "targetCalendar" in aProps ? aProps.targetCalendar : aItipItem.targetCalendar;
+
+ return itipItem;
+ },
+
+ /**
+ * A shortcut to send DECLINECOUNTER messages - for everything else use calitip.checkAndSend
+ *
+ * @param {calIItipItem} aItem - item to be sent
+ * @param {string} aMethod - iTIP method
+ * @param {calIAttendee[]} aRecipientsList - array of calIAttendee objects the message should be sent to
+ * @param {object} aAutoResponse - JS object whether the transport should ask before sending
+ * @returns {boolean} True
+ */
+ sendDeclineCounterMessage(aItem, aMethod, aRecipientsList, aAutoResponse) {
+ if (aMethod == "DECLINECOUNTER") {
+ return sendMessage(aItem, aMethod, aRecipientsList, aAutoResponse);
+ }
+ return false;
+ },
+
+ /**
+ * Returns a copy of an event that
+ * - has a relation set to the original event
+ * - has the same organizer but
+ * - has any attendee removed
+ * Intended to get a copy of a normal event invitation that behaves as if the PUBLISH method was
+ * chosen instead.
+ *
+ * @param {calIItemBase} aItem - Original item
+ * @param {?string} aUid - UID to use for the new item
+ * @returns {calIItemBase} The copied item for publishing
+ */
+ getPublishLikeItemCopy(aItem, aUid) {
+ // avoid changing aItem
+ let item = aItem.clone();
+ // reset to a new UUID if applicable
+ item.id = aUid || lazy.cal.getUUID();
+ // add a relation to the original item
+ let relation = new lazy.CalRelation();
+ relation.relId = aItem.id;
+ relation.relType = "SIBLING";
+ item.addRelation(relation);
+ // remove attendees
+ item.removeAllAttendees();
+ if (!aItem.isMutable) {
+ item = item.makeImmutable();
+ }
+ return item;
+ },
+
+ /**
+ * Tests whether the passed object is a calIAttendee instance. This function
+ * takes into consideration that the object may be be unwrapped and thus a
+ * CalAttendee instance
+ *
+ * @param {object} val - The object to test.
+ *
+ * @returns {boolean}
+ */
+ isAttendee(val) {
+ return val && (val instanceof Ci.calIAttendee || val instanceof lazy.CalAttendee);
+ },
+
+ /**
+ * Shortcut function to check whether an item is an invitation copy.
+ *
+ * @param {calIItemBase} aItem - The item to check for an invitation.
+ * @returns {boolean} True, if the item is an invitation.
+ */
+ isInvitation(aItem) {
+ let isInvitation = false;
+ let calendar = aItem.calendar;
+ if (calendar && calendar.supportsScheduling) {
+ isInvitation = calendar.getSchedulingSupport().isInvitation(aItem);
+ }
+ return isInvitation;
+ },
+
+ /**
+ * Shortcut function to check whether an item is an invitation copy and has a participation
+ * status of either NEEDS-ACTION or TENTATIVE.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - either calIAttendee or calIItemBase
+ * @returns {boolean} True, if the attendee partstat is NEEDS-ACTION
+ * or TENTATIVE
+ */
+ isOpenInvitation(aItem) {
+ if (!calitip.isAttendee(aItem)) {
+ aItem = calitip.getInvitedAttendee(aItem);
+ }
+ if (aItem) {
+ switch (aItem.participationStatus) {
+ case "NEEDS-ACTION":
+ case "TENTATIVE":
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Resolves delegated-to/delegated-from calusers for a given attendee to also include the
+ * respective CNs if available in a given set of attendees
+ *
+ * @param {calIAttendee} aAttendee - The attendee to resolve the delegation information for
+ * @param {calIAttendee[]} aAttendees - An array of calIAttendee objects to look up
+ * @returns {object} An object with string attributes for delegators and delegatees
+ */
+ resolveDelegation(aAttendee, aAttendees) {
+ let attendees = aAttendees || [aAttendee];
+
+ // this will be replaced by a direct property getter in calIAttendee
+ let delegators = [];
+ let delegatees = [];
+ let delegatorProp = aAttendee.getProperty("DELEGATED-FROM");
+ if (delegatorProp) {
+ delegators = typeof delegatorProp == "string" ? [delegatorProp] : delegatorProp;
+ }
+ let delegateeProp = aAttendee.getProperty("DELEGATED-TO");
+ if (delegateeProp) {
+ delegatees = typeof delegateeProp == "string" ? [delegateeProp] : delegateeProp;
+ }
+
+ for (let att of attendees) {
+ let resolveDelegation = function (e, i, a) {
+ if (e == att.id) {
+ a[i] = att.toString();
+ }
+ };
+ delegators.forEach(resolveDelegation);
+ delegatees.forEach(resolveDelegation);
+ }
+ return {
+ delegatees: delegatees.join(", "),
+ delegators: delegators.join(", "),
+ };
+ },
+
+ /**
+ * Shortcut function to get the invited attendee of an item.
+ *
+ * @param {calIItemBase} aItem - Event or task to get the invited attendee for
+ * @param {?calICalendar} aCalendar - The calendar to use for checking, defaults to the item
+ * calendar
+ * @returns {?calIAttendee} The attendee that was invited
+ */
+ getInvitedAttendee(aItem, aCalendar) {
+ let id = aItem.getProperty("X-MOZ-INVITED-ATTENDEE");
+ if (id) {
+ return aItem.getAttendeeById(id);
+ }
+ if (!aCalendar) {
+ aCalendar = aItem.calendar;
+ }
+ let invitedAttendee = null;
+ if (aCalendar && aCalendar.supportsScheduling) {
+ invitedAttendee = aCalendar.getSchedulingSupport().getInvitedAttendee(aItem);
+ }
+ return invitedAttendee;
+ },
+
+ /**
+ * Returns all attendees from given set of attendees matching based on the attendee id
+ * or a sent-by parameter compared to the specified email address
+ *
+ * @param {calIAttendee[]} aAttendees - An array of calIAttendee objects
+ * @param {string} aEmailAddress - A string containing the email address for lookup
+ * @returns {calIAttendee[]} Returns an array of matching attendees
+ */
+ getAttendeesBySender(aAttendees, aEmailAddress) {
+ let attendees = [];
+ // we extract the email address to make it work also for a raw header value
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ let addresses = compFields.splitRecipients(aEmailAddress, true);
+ if (addresses.length == 1) {
+ let searchFor = lazy.cal.email.prependMailTo(addresses[0]);
+ aAttendees.forEach(aAttendee => {
+ if ([aAttendee.id, aAttendee.getProperty("SENT-BY")].includes(searchFor)) {
+ attendees.push(aAttendee);
+ }
+ });
+ } else {
+ lazy.cal.WARN("No unique email address for lookup!");
+ }
+ return attendees;
+ },
+
+ /**
+ * Provides the transport to be used for an item based on the invited attendee
+ * or calendar.
+ *
+ * @param {calIItemBase} item
+ */
+ getImipTransport(item) {
+ let id = item.getProperty("X-MOZ-INVITED-ATTENDEE");
+
+ if (id) {
+ let email = id.split("mailto:").join("");
+ let identity = MailServices.accounts.allIdentities.find(identity => identity.email == email);
+
+ if (identity) {
+ let [server] = MailServices.accounts.getServersForIdentity(identity);
+
+ if (server) {
+ let account = MailServices.accounts.FindAccountForServer(server);
+ return new lazy.CalItipDefaultEmailTransport(account, identity);
+ }
+ }
+
+ // We did not find the identity or associated account
+ return null;
+ }
+
+ return item.calendar.getProperty("itip.transport");
+ },
+};
+
+/** local to this module file
+ * Sets the received info either on the passed attendee or item object.
+ *
+ * @param {calIItemBase|calIAttendee} item - The item to set info on
+ * @param {calIItipItem} itipItemItem - The received iTIP item
+ */
+function setReceivedInfo(item, itipItemItem) {
+ let isAttendee = calitip.isAttendee(item);
+ item.setProperty(
+ isAttendee ? "RECEIVED-SEQUENCE" : "X-MOZ-RECEIVED-SEQUENCE",
+ String(calitip.getSequence(itipItemItem))
+ );
+ let dtstamp = calitip.getStamp(itipItemItem);
+ if (dtstamp) {
+ item.setProperty(
+ isAttendee ? "RECEIVED-DTSTAMP" : "X-MOZ-RECEIVED-DTSTAMP",
+ dtstamp.getInTimezone(lazy.cal.dtz.UTC).icalString
+ );
+ }
+}
+
+/** local to this module file
+ * Takes over relevant item information from iTIP item and sets received info.
+ *
+ * @param {calIItemBase} item - The stored calendar item to update
+ * @param {calIItipItem} itipItemItem - The received item
+ * @returns {calIItemBase} A copy of the item with correct received info
+ */
+function updateItem(item, itipItemItem) {
+ /**
+ * Migrates some user data from the old to new item
+ *
+ * @param {calIItemBase} newItem - The new item to copy to
+ * @param {calIItemBase} oldItem - The old item to copy from
+ */
+ function updateUserData(newItem, oldItem) {
+ // preserve user settings:
+ newItem.generation = oldItem.generation;
+ newItem.clearAlarms();
+ for (let alarm of oldItem.getAlarms()) {
+ newItem.addAlarm(alarm);
+ }
+ newItem.alarmLastAck = oldItem.alarmLastAck;
+ let cats = oldItem.getCategories();
+ newItem.setCategories(cats);
+ }
+
+ let newItem = item.clone();
+ newItem.icalComponent = itipItemItem.icalComponent;
+ setReceivedInfo(newItem, itipItemItem);
+ updateUserData(newItem, item);
+
+ let recInfo = itipItemItem.recurrenceInfo;
+ if (recInfo) {
+ // keep care of installing all overridden items, and mind existing alarms, categories:
+ for (let rid of recInfo.getExceptionIds()) {
+ let excItem = recInfo.getExceptionFor(rid).clone();
+ lazy.cal.ASSERT(excItem, "unexpected!");
+ let newExc = newItem.recurrenceInfo.getOccurrenceFor(rid).clone();
+ newExc.icalComponent = excItem.icalComponent;
+ setReceivedInfo(newExc, itipItemItem);
+ let existingExcItem = item.recurrenceInfo && item.recurrenceInfo.getExceptionFor(rid);
+ if (existingExcItem) {
+ updateUserData(newExc, existingExcItem);
+ }
+ newItem.recurrenceInfo.modifyException(newExc, true);
+ }
+ }
+
+ return newItem;
+}
+
+/** local to this module file
+ * Copies the provider-specified properties from the itip item to the passed
+ * item. Special case property "METHOD" uses the itipItem's receivedMethod.
+ *
+ * @param {calIItipItem} itipItem - The itip item containing the receivedMethod.
+ * @param {calIItemBase} itipItemItem - The calendar item inside the itip item.
+ * @param {calIItemBase} item - The target item to copy to.
+ */
+function copyProviderProperties(itipItem, itipItemItem, item) {
+ // Copy over itip properties to the item if requested by the provider
+ let copyProps = item.calendar.getProperty("itip.copyProperties") || [];
+ for (let prop of copyProps) {
+ if (prop == "METHOD") {
+ // Special case, this copies over the received method
+ item.setProperty("METHOD", itipItem.receivedMethod.toUpperCase());
+ } else if (itipItemItem.hasProperty(prop)) {
+ // Otherwise just copy from the item contained in the itipItem
+ item.setProperty(prop, itipItemItem.getProperty(prop));
+ }
+ }
+}
+
+/** local to this module file
+ * Sends an iTIP message using the passed item's calendar transport.
+ *
+ * @param {calIEvent} aItem - item to be sent
+ * @param {string} aMethod - iTIP method
+ * @param {calIAttendee[]} aRecipientsList - array of calIAttendee objects the message should be sent to
+ * @param {object} autoResponse - inout object whether the transport should ask before sending
+ * @returns {boolean} True, if the message could be sent
+ */
+function sendMessage(aItem, aMethod, aRecipientsList, autoResponse) {
+ new lazy.CalItipOutgoingMessage(
+ aMethod,
+ aRecipientsList,
+ aItem,
+ calitip.getInvitedAttendee(aItem),
+ autoResponse
+ ).send(calitip.getImipTransport(aItem));
+}
+
+/** local to this module file
+ * An operation listener that is used on calendar operations which checks and sends further iTIP
+ * messages based on the calendar action.
+ *
+ * @param {object} aOpListener - operation listener to forward
+ * @param {calIItemBase} aOldItem - The previous item before modification (if any)
+ * @param {?object} aExtResponse - An object to provide additional parameters for sending itip
+ * messages as response mode, comments or a subset of
+ * recipients.
+ */
+function ItipOpListener(aOpListener, aOldItem, aExtResponse = null) {
+ this.mOpListener = aOpListener;
+ this.mOldItem = aOldItem;
+ this.mExtResponse = aExtResponse;
+}
+ItipOpListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+
+ mOpListener: null,
+ mOldItem: null,
+ mExtResponse: null,
+
+ onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) {
+ lazy.cal.ASSERT(Components.isSuccessCode(aStatus), "error on iTIP processing");
+ if (Components.isSuccessCode(aStatus)) {
+ calitip.checkAndSend(aOperationType, aDetail, this.mOldItem, this.mExtResponse);
+ }
+ if (this.mOpListener) {
+ this.mOpListener.onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail);
+ }
+ },
+ onGetResult(calendar, status, itemType, detail, items) {},
+};
+
+/** local to this module file
+ * Add a parameter SCHEDULE-AGENT=CLIENT to the item before it is
+ * created or updated so that the providers knows scheduling will
+ * be handled by the client.
+ *
+ * @param {calIItemBase} item - item about to be added or updated
+ * @param {calICalendar} calendar - calendar into which the item is about to be added or updated
+ */
+function addScheduleAgentClient(item, calendar) {
+ if (calendar.getProperty("capabilities.autoschedule.supported") === true) {
+ if (item.organizer) {
+ item.organizer.setProperty("SCHEDULE-AGENT", "CLIENT");
+ }
+ }
+}
+
+var ItipItemFinderFactory = {
+ /** Map to save finder instances for given ids */
+ _findMap: {},
+
+ /**
+ * Create an item finder and track its progress. Be sure to clean up the
+ * finder for this id at some point.
+ *
+ * @param {string} aId - The item id to search for
+ * @param {calIIipItem} aItipItem - The iTIP item used for processing
+ * @param {Function} aOptionsFunc - The options function used for processing the found item
+ */
+ async findItem(aId, aItipItem, aOptionsFunc) {
+ this.cleanup(aId);
+ let finder = new ItipItemFinder(aId, aItipItem, aOptionsFunc);
+ this._findMap[aId] = finder;
+ return finder.findItem();
+ },
+
+ /**
+ * Clean up tracking for the given id. This needs to be called once for
+ * every time findItem is called.
+ *
+ * @param {string} aId - The item id to clean up for
+ */
+ cleanup(aId) {
+ if (aId in this._findMap) {
+ let finder = this._findMap[aId];
+ finder.destroy();
+ delete this._findMap[aId];
+ }
+ },
+};
+
+/** local to this module file
+ * An operation listener triggered by cal.itip.processItipItem() for lookup of the sent iTIP item's UID.
+ *
+ * @param {string} aId - The search identifier for the item to find
+ * @param {calIItipItem} itipItem - Sent iTIP item
+ * @param {Function} optionsFunc - Options func, see cal.itip.processItipItem()
+ */
+function ItipItemFinder(aId, itipItem, optionsFunc) {
+ this.mItipItem = itipItem;
+ this.mOptionsFunc = optionsFunc;
+ this.mSearchId = aId;
+}
+
+ItipItemFinder.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ mSearchId: null,
+ mItipItem: null,
+ mOptionsFunc: null,
+ mFoundItems: null,
+
+ async findItem() {
+ this.mFoundItems = [];
+ this._unobserveChanges();
+
+ let foundItem = await this.mItipItem.targetCalendar.getItem(this.mSearchId);
+ if (foundItem) {
+ this.mFoundItems.push(foundItem);
+ }
+ this.processFoundItems();
+ },
+
+ _observeChanges(aCalendar) {
+ this._unobserveChanges();
+ this.mObservedCalendar = aCalendar;
+
+ if (this.mObservedCalendar) {
+ this.mObservedCalendar.addObserver(this);
+ }
+ },
+ _unobserveChanges() {
+ if (this.mObservedCalendar) {
+ this.mObservedCalendar.removeObserver(this);
+ this.mObservedCalendar = null;
+ }
+ },
+
+ onStartBatch() {},
+ onEndBatch() {},
+ onError() {},
+ onPropertyChanged() {},
+ onPropertyDeleting() {},
+ onLoad(aCalendar) {
+ // Its possible that the item was updated. We need to re-retrieve the
+ // items now.
+ this.findItem();
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ let refItem = aOldItem || aNewItem;
+ if (refItem.id == this.mSearchId) {
+ // Check existing found items to see if it already exists
+ let found = false;
+ for (let [idx, item] of Object.entries(this.mFoundItems)) {
+ if (item.id == refItem.id && item.calendar.id == refItem.calendar.id) {
+ if (aNewItem) {
+ this.mFoundItems.splice(idx, 1, aNewItem);
+ } else {
+ this.mFoundItems.splice(idx, 1);
+ }
+ found = true;
+ break;
+ }
+ }
+
+ // If it hasn't been found and there is to add a item, add it to the end
+ if (!found && aNewItem) {
+ this.mFoundItems.push(aNewItem);
+ }
+ this.processFoundItems();
+ }
+ },
+
+ onAddItem(aItem) {
+ // onModifyItem is set up to also handle additions
+ this.onModifyItem(aItem, null);
+ },
+
+ onDeleteItem(aItem) {
+ // onModifyItem is set up to also handle deletions
+ this.onModifyItem(null, aItem);
+ },
+
+ destroy() {
+ this._unobserveChanges();
+ },
+
+ processFoundItems() {
+ let rc = Cr.NS_OK;
+ const method = this.mItipItem.receivedMethod.toUpperCase();
+ let actionMethod = method;
+ let operations = [];
+
+ if (this.mFoundItems.length > 0) {
+ // Save the target calendar on the itip item
+ this.mItipItem.targetCalendar = this.mFoundItems[0].calendar;
+ this._observeChanges(this.mItipItem.targetCalendar);
+
+ lazy.cal.LOG("iTIP on " + method + ": found " + this.mFoundItems.length + " items.");
+ switch (method) {
+ // XXX todo: there's still a potential flaw, if multiple PUBLISH/REPLY/REQUEST on
+ // occurrences happen at once; those lead to multiple
+ // occurrence modifications. Since those modifications happen
+ // implicitly on the parent (ics/memory/storage calls modifyException),
+ // the generation check will fail. We should really consider to allow
+ // deletion/modification/addition of occurrences directly on the providers,
+ // which would ease client code a lot.
+ case "REFRESH":
+ case "PUBLISH":
+ case "REQUEST":
+ case "REPLY":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ for (let item of this.mFoundItems) {
+ let rid = itipItemItem.recurrenceId; // XXX todo support multiple
+ if (rid) {
+ // actually applies to individual occurrence(s)
+ if (item.recurrenceInfo) {
+ item = item.recurrenceInfo.getOccurrenceFor(rid);
+ if (!item) {
+ continue;
+ }
+ } else {
+ // the item has been rescheduled with master:
+ itipItemItem = itipItemItem.parentItem;
+ }
+ }
+
+ switch (method) {
+ case "REFRESH": {
+ // xxx todo test
+ let attendees = itipItemItem.getAttendees();
+ lazy.cal.ASSERT(attendees.length == 1, "invalid number of attendees in REFRESH!");
+ if (attendees.length > 0) {
+ let action = function (opListener, partStat, extResponse) {
+ if (!item.organizer) {
+ let org = calitip.createOrganizer(item.calendar);
+ if (org) {
+ item = item.clone();
+ item.organizer = org;
+ }
+ }
+ sendMessage(
+ item,
+ "REQUEST",
+ attendees,
+ { responseMode: Ci.calIItipItem.AUTO } /* don't ask */
+ );
+ };
+ operations.push(action);
+ }
+ break;
+ }
+ case "PUBLISH":
+ lazy.cal.ASSERT(
+ itipItemItem.getAttendees().length == 0,
+ "invalid number of attendees in PUBLISH!"
+ );
+ if (
+ item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) > 0
+ ) {
+ let newItem = updateItem(item, itipItemItem);
+ let action = function (opListener, partStat, extResponse) {
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ };
+ actionMethod = method + ":UPDATE";
+ operations.push(action);
+ }
+ break;
+ case "REQUEST": {
+ let newItem = updateItem(item, itipItemItem);
+ let att = calitip.getInvitedAttendee(newItem);
+ if (!att) {
+ // fall back to using configured organizer
+ att = calitip.createOrganizer(newItem.calendar);
+ if (att) {
+ att.isOrganizer = false;
+ }
+ }
+ if (att) {
+ let firstFoundItem = this.mFoundItems[0];
+
+ // Where the server automatically adds events to the calendar
+ // we may end up with a recurring invitation in the "NEEDS-ACTION"
+ // state. Upon receiving an exception for these, processFoundItems()
+ // will query the calendar and determine the actionMethod to
+ // be "REQUEST:NEEDS-ACTION" but process the entire series. To avoid
+ // that, we detect here if the itip item's item was indeed for
+ // the whole series or an exception.
+ if (firstFoundItem.recurrenceInfo && rid) {
+ firstFoundItem = firstFoundItem.recurrenceInfo.getOccurrenceFor(rid);
+ }
+
+ // again, fall back to using configured organizer if not found
+ let foundAttendee = firstFoundItem.getAttendeeById(att.id) || att;
+
+ // If the user hasn't responded to the invitation yet and we
+ // are viewing the current representation of the item, show the
+ // accept/decline buttons. This means newer events will show the
+ // "Update" button and older events will show the "already
+ // processed" text.
+ if (
+ foundAttendee.participationStatus == "NEEDS-ACTION" &&
+ (item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) == 0)
+ ) {
+ actionMethod = "REQUEST:NEEDS-ACTION";
+ operations.push((opListener, partStat, extResponse) => {
+ let changedItem = firstFoundItem.clone();
+ changedItem.removeAttendee(foundAttendee);
+ foundAttendee = foundAttendee.clone();
+ if (partStat) {
+ foundAttendee.participationStatus = partStat;
+ }
+ changedItem.addAttendee(foundAttendee);
+
+ let listener = new ItipOpListener(opListener, firstFoundItem, extResponse);
+ return changedItem.calendar.modifyItem(changedItem, firstFoundItem).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ });
+ } else if (
+ item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) > 0
+ ) {
+ addScheduleAgentClient(newItem, item.calendar);
+
+ let isMinorUpdate = calitip.getSequence(newItem) == calitip.getSequence(item);
+ actionMethod = isMinorUpdate ? method + ":UPDATE-MINOR" : method + ":UPDATE";
+ operations.push((opListener, partStat, extResponse) => {
+ if (!partStat) {
+ // keep PARTSTAT
+ let att_ = calitip.getInvitedAttendee(item);
+ partStat = att_ ? att_.participationStatus : "NEEDS-ACTION";
+ }
+ newItem.removeAttendee(att);
+ att = att.clone();
+ att.participationStatus = partStat;
+ newItem.addAttendee(att);
+
+ let listener = new ItipOpListener(opListener, item, extResponse);
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ });
+ }
+ }
+ break;
+ }
+ case "DECLINECOUNTER":
+ // nothing to do right now, but once countering is implemented,
+ // we probably need some action here to remove the proposal from
+ // the countering attendee's calendar
+ break;
+ case "COUNTER":
+ case "REPLY": {
+ let attendees = itipItemItem.getAttendees();
+ if (method == "REPLY") {
+ lazy.cal.ASSERT(attendees.length == 1, "invalid number of attendees in REPLY!");
+ } else {
+ attendees = lazy.cal.itip.getAttendeesBySender(
+ attendees,
+ this.mItipItem.sender
+ );
+ lazy.cal.ASSERT(
+ attendees.length == 1,
+ "ambiguous resolution of replying attendee in COUNTER!"
+ );
+ }
+ // we get the attendee from the event stored in the calendar
+ let replyer = item.getAttendeeById(attendees[0].id);
+ if (!replyer && method == "REPLY") {
+ // We accepts REPLYs also from previously uninvited
+ // attendees, so we always have one for REPLY
+ replyer = attendees[0];
+ }
+ let noCheck = item.calendar.getProperty("itip.disableRevisionChecks");
+ let revCheck = false;
+ if (replyer && !noCheck) {
+ revCheck = calitip.compare(itipItemItem, replyer) > 0;
+ if (revCheck && method == "COUNTER") {
+ revCheck = calitip.compareSequence(itipItemItem, item) == 0;
+ }
+ }
+
+ if (replyer && (noCheck || revCheck)) {
+ let newItem = item.clone();
+ newItem.removeAttendee(replyer);
+ replyer = replyer.clone();
+ setReceivedInfo(replyer, itipItemItem);
+ let newPS = itipItemItem.getAttendeeById(replyer.id).participationStatus;
+ replyer.participationStatus = newPS;
+ newItem.addAttendee(replyer);
+
+ // Make sure the provider-specified properties are copied over
+ copyProviderProperties(this.mItipItem, itipItemItem, newItem);
+
+ let action = function (opListener, partStat, extResponse) {
+ // n.b.: this will only be processed in case of reply or
+ // declining the counter request - of sending the
+ // appropriate reply will be taken care within the
+ // opListener (defined in imip-bar.js)
+ // TODO: move that from imip-bar.js to here
+
+ let listener = newItem.calendar.getProperty("itip.notify-replies")
+ ? new ItipOpListener(opListener, item, extResponse)
+ : opListener;
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ };
+ operations.push(action);
+ }
+ break;
+ }
+ }
+ }
+ }
+ break;
+ case "CANCEL": {
+ let modifiedItems = {};
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ for (let item of this.mFoundItems) {
+ let rid = itipItemItem.recurrenceId; // XXX todo support multiple
+ if (rid) {
+ // actually a CANCEL of occurrence(s)
+ if (item.recurrenceInfo) {
+ // collect all occurrence deletions into a single parent modification:
+ let newItem = modifiedItems[item.id];
+ if (!newItem) {
+ newItem = item.clone();
+ modifiedItems[item.id] = newItem;
+
+ // Make sure the provider-specified properties are copied over
+ copyProviderProperties(this.mItipItem, itipItemItem, newItem);
+
+ operations.push((opListener, partStat, extResponse) =>
+ newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ )
+ );
+ }
+ newItem.recurrenceInfo.removeOccurrenceAt(rid);
+ } else if (item.recurrenceId && item.recurrenceId.compare(rid) == 0) {
+ // parentless occurrence to be deleted (future)
+ operations.push((opListener, partStat, extResponse) =>
+ item.calendar.deleteItem(item).then(
+ () =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ item.calendar,
+ e.result,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ e
+ )
+ )
+ );
+ }
+ } else {
+ operations.push((opListener, partStat, extResponse) =>
+ item.calendar.deleteItem(item).then(
+ () =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ item.calendar,
+ e.result,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ e
+ )
+ )
+ );
+ }
+ }
+ }
+ break;
+ }
+ default:
+ rc = Cr.NS_ERROR_NOT_IMPLEMENTED;
+ break;
+ }
+ } else {
+ // not found:
+ lazy.cal.LOG("iTIP on " + method + ": no existing items.");
+ // If the item was not found, observe the target calendar anyway.
+ // It will likely be the composite calendar, so we should update
+ // if an item was added or removed
+ this._observeChanges(this.mItipItem.targetCalendar);
+
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ switch (method) {
+ case "REQUEST":
+ case "PUBLISH": {
+ let action = (opListener, partStat, extResponse) => {
+ let newItem = itipItemItem.clone();
+ setReceivedInfo(newItem, itipItemItem);
+ newItem.parentItem.calendar = this.mItipItem.targetCalendar;
+ addScheduleAgentClient(newItem, this.mItipItem.targetCalendar);
+
+ if (partStat) {
+ if (partStat != "DECLINED") {
+ lazy.cal.alarms.setDefaultValues(newItem);
+ }
+
+ let att = calitip.getInvitedAttendee(newItem);
+ if (!att) {
+ lazy.cal.WARN(
+ `Encountered item without invited attendee! id=${newItem.id}, method=${method} Exiting...`
+ );
+ return null;
+ }
+ att.participationStatus = partStat;
+ } else {
+ lazy.cal.ASSERT(
+ itipItemItem.getAttendees().length == 0,
+ "invalid number of attendees in PUBLISH!"
+ );
+ lazy.cal.alarms.setDefaultValues(newItem);
+ }
+
+ let listener =
+ method == "REQUEST"
+ ? new ItipOpListener(opListener, null, extResponse)
+ : opListener;
+ return newItem.calendar.addItem(newItem).then(
+ item =>
+ listener.onOperationComplete(
+ newItem.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.ADD,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ newItem.calendar,
+ e.result,
+ Ci.calIOperationListener.ADD,
+ newItem.id,
+ e
+ )
+ );
+ };
+ operations.push(action);
+ break;
+ }
+ case "CANCEL": // has already been processed
+ case "REPLY": // item has been previously removed from the calendar
+ case "COUNTER": // the item has been previously removed form the calendar
+ break;
+ default:
+ rc = Cr.NS_ERROR_NOT_IMPLEMENTED;
+ break;
+ }
+ }
+ }
+
+ lazy.cal.LOG("iTIP operations: " + operations.length);
+ let actionFunc = null;
+ if (operations.length > 0) {
+ actionFunc = function (opListener, partStat = null, extResponse = null) {
+ for (let operation of operations) {
+ try {
+ operation(opListener, partStat, extResponse);
+ } catch (exc) {
+ lazy.cal.ERROR(exc);
+ }
+ }
+ };
+ actionFunc.method = actionMethod;
+ }
+
+ this.mOptionsFunc(this.mItipItem, rc, actionFunc, this.mFoundItems);
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calL10NUtils.jsm b/comm/calendar/base/modules/utils/calL10NUtils.jsm
new file mode 100644
index 0000000000..8897bbdc40
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calL10NUtils.jsm
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Localization and locale functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.l10n namespace.
+
+const EXPORTED_SYMBOLS = ["call10n"];
+
+/**
+ * Gets the value of a string in a .properties file.
+ *
+ * @param {string} aComponent - Stringbundle component name
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+function _getString(aComponent, aBundleName, aStringName, aParams = []) {
+ let propName = `chrome://${aComponent}/locale/${aBundleName}.properties`;
+
+ try {
+ if (!(propName in _getString._bundleCache)) {
+ _getString._bundleCache[propName] = Services.strings.createBundle(propName);
+ }
+ let props = _getString._bundleCache[propName];
+
+ if (aParams && aParams.length) {
+ return props.formatStringFromName(aStringName, aParams);
+ }
+ return props.GetStringFromName(aStringName);
+ } catch (ex) {
+ let msg = `Failed to read '${aStringName}' from ${propName}.`;
+ console.error(`${msg} Error: ${ex}`);
+ return aStringName;
+ }
+}
+_getString._bundleCache = {};
+
+/**
+ * Provides locale dependent parameters for displaying calendar views
+ *
+ * @param {string} aLocale The locale to get the info for, e.g. "en-US",
+ * "de-DE" or null for the current locale
+ * @param {Bollean} aResetCache - Whether to reset the internal cache - for test
+ * purposes only don't use it otherwise atm
+ * @returns {object} The getCalendarInfo object from mozIMozIntl
+ */
+function _calendarInfo(aLocale = null, aResetCache = false) {
+ if (aResetCache) {
+ _calendarInfo._startup = {};
+ }
+ // we cache the result to prevent updates at runtime except for test
+ // purposes since changing intl.regional_prefs.use_os_locales preference
+ // would provide different result when called without aLocale and we
+ // need to investigate whether this is wanted or chaching more selctively.
+ // when starting to use it to determine the first week of a year, we would
+ // need to at least reset that cached properties on pref change.
+ if (!("firstDayOfWeek" in _calendarInfo._startup) || aLocale) {
+ let info = Services.intl.getCalendarInfo(aLocale || Services.locale.regionalPrefsLocales[0]);
+ if (aLocale) {
+ return info;
+ }
+ _calendarInfo._startup = info;
+ }
+ return _calendarInfo._startup;
+}
+_calendarInfo._startup = {};
+
+var call10n = {
+ /**
+ * Gets the value of a string in a .properties file.
+ *
+ * @param {string} aComponent - Stringbundle component name
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getAnyString: _getString,
+
+ /**
+ * Gets a string from a bundle from chrome://calendar/
+ *
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getString: _getString.bind(undefined, "calendar"),
+
+ /**
+ * Gets a string from chrome://calendar/locale/calendar.properties bundle
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getCalString: _getString.bind(undefined, "calendar", "calendar"),
+
+ /**
+ * Gets a string from chrome://lightning/locale/lightning.properties
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getLtnString: _getString.bind(undefined, "lightning", "lightning"),
+
+ /**
+ * Gets a date format string from chrome://calendar/locale/dateFormat.properties bundle
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getDateFmtString: _getString.bind(undefined, "calendar", "dateFormat"),
+
+ /**
+ * Gets the month name string in the right form depending on a base string.
+ *
+ * @param {number} aMonthNum - The month number to get, 1-based.
+ * @param {string} aBundleName - The Bundle to get the string from
+ * @param {string} aStringBase - The base string name, .monthFormat will be appended
+ * @returns {string} The formatted month name
+ */
+ formatMonth(aMonthNum, aBundleName, aStringBase) {
+ let monthForm = call10n.getString(aBundleName, aStringBase + ".monthFormat") || "nominative";
+
+ if (monthForm == "nominative") {
+ // Fall back to the default name format
+ monthForm = "name";
+ }
+
+ return call10n.getDateFmtString(`month.${aMonthNum}.${monthForm}`);
+ },
+
+ /**
+ * Sort an array of strings in place, according to the current locale.
+ *
+ * @param {string[]} aStringArray - The strings to sort
+ * @returns {string[]} The sorted strings, more specifically aStringArray
+ */
+ sortArrayByLocaleCollator(aStringArray) {
+ const collator = new Intl.Collator();
+ aStringArray.sort(collator.compare);
+ return aStringArray;
+ },
+
+ /**
+ * Provides locale dependent parameters for displaying calendar views
+ *
+ * @param {string} aLocale - The locale to get the info for, e.g. "en-US",
+ * "de-DE" or null for the current locale
+ * @returns {object} The getCalendarInfo object from mozIMozIntl
+ */
+ calendarInfo: _calendarInfo,
+};
diff --git a/comm/calendar/base/modules/utils/calPrintUtils.jsm b/comm/calendar/base/modules/utils/calPrintUtils.jsm
new file mode 100644
index 0000000000..ad7b022f3c
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calPrintUtils.jsm
@@ -0,0 +1,616 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helpers for printing.
+ *
+ * This file detects when printing starts, and if it's the calendar that is
+ * being printed, injects calendar-print.js into the printing UI.
+ *
+ * Also contains the code for formatting the to-be-printed document as chosen
+ * by the user.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.print namespace.
+
+const EXPORTED_SYMBOLS = ["calprint"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calprint = {
+ ensureInitialized() {
+ // Deliberate no-op. By calling this function from outside, you've ensured
+ // the observer has been added.
+ },
+
+ async draw(document, type, startDate, endDate, filter, notDueTasks) {
+ lazy.cal.view.colorTracker.addColorsToDocument(document);
+
+ let listContainer = document.getElementById("list-container");
+ while (listContainer.lastChild) {
+ listContainer.lastChild.remove();
+ }
+ let monthContainer = document.getElementById("month-container");
+ while (monthContainer.lastChild) {
+ monthContainer.lastChild.remove();
+ }
+ let weekContainer = document.getElementById("week-container");
+ while (weekContainer.lastChild) {
+ weekContainer.lastChild.remove();
+ }
+
+ let taskContainer = document.getElementById("task-container");
+ while (taskContainer.lastChild) {
+ taskContainer.lastChild.remove();
+ }
+ document.getElementById("tasks-list-box").hidden = true;
+
+ switch (type) {
+ case "list":
+ await listView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ case "monthGrid":
+ await monthGridView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ case "weekPlanner":
+ await weekPlannerView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ }
+ },
+};
+
+/**
+ * Serializes the given item by setting marked nodes to the item's content.
+ * Has some expectations about the DOM document (in CSS-selector-speak), all
+ * following nodes MUST exist.
+ *
+ * - #item-template will be cloned and filled, and modified:
+ * - .item-interval gets the time interval of the item.
+ * - .item-title gets the item title
+ * - .category-color-box gets a 2px solid border in category color
+ * - .calendar-color-box gets background color of the calendar
+ *
+ * @param document The DOM Document to set things on
+ * @param item The item to serialize
+ * @param dayContainer The DOM Node to insert the container in
+ */
+function addItemToDaybox(document, item, boxDate, dayContainer) {
+ // Clone our template
+ let itemNode = document.getElementById("item-template").content.firstElementChild.cloneNode(true);
+ itemNode.removeAttribute("id");
+ itemNode.item = item;
+
+ // Fill in details of the item
+ let itemInterval = getItemIntervalString(item, boxDate);
+ itemNode.querySelector(".item-interval").textContent = itemInterval;
+ itemNode.querySelector(".item-title").textContent = item.title;
+
+ // Fill in category details
+ let categoriesArray = item.getCategories();
+ if (categoriesArray.length > 0) {
+ let cssClassesArray = categoriesArray.map(lazy.cal.view.formatStringForCSSRule);
+ itemNode.style.borderInlineEnd = `2px solid var(--category-${cssClassesArray[0]}-color)`;
+ }
+
+ // Fill in calendar color
+ let cssSafeId = lazy.cal.view.formatStringForCSSRule(item.calendar.id);
+ itemNode.style.color = `var(--calendar-${cssSafeId}-forecolor)`;
+ itemNode.style.backgroundColor = `var(--calendar-${cssSafeId}-backcolor)`;
+
+ // Add it to the day container in the right order
+ lazy.cal.data.binaryInsertNode(dayContainer, itemNode, item, lazy.cal.view.compareItems);
+}
+
+/**
+ * Serializes the given item by setting marked nodes to the item's
+ * content. Should be used for tasks with no start and due date. Has
+ * some expectations about the DOM document (in CSS-selector-speak),
+ * all following nodes MUST exist.
+ *
+ * - Nodes will be added to #task-container.
+ * - #task-list-box will have the "hidden" attribute removed.
+ * - #task-template will be cloned and filled, and modified:
+ * - .task-checkbox gets the "checked" attribute set, if completed
+ * - .task-title gets the item title.
+ *
+ * @param document The DOM Document to set things on
+ * @param item The item to serialize
+ */
+function addItemToDayboxNodate(document, item) {
+ let taskContainer = document.getElementById("task-container");
+ let taskNode = document.getElementById("task-template").content.firstElementChild.cloneNode(true);
+ taskNode.item = item;
+
+ let taskListBox = document.getElementById("tasks-list-box");
+ if (taskListBox.hasAttribute("hidden")) {
+ let tasksTitle = document.getElementById("tasks-title");
+ taskListBox.removeAttribute("hidden");
+ tasksTitle.textContent = lazy.cal.l10n.getCalString("tasksWithNoDueDate");
+ }
+
+ // Fill in details of the task
+ if (item.isCompleted) {
+ taskNode.querySelector(".task-checkbox").setAttribute("checked", "checked");
+ }
+
+ taskNode.querySelector(".task-title").textContent = item.title;
+
+ const collator = new Intl.Collator();
+ lazy.cal.data.binaryInsertNode(
+ taskContainer,
+ taskNode,
+ item,
+ collator.compare,
+ node => node.item.title
+ );
+}
+
+/**
+ * Get time interval string for the given item. Returns an empty string for all-day items.
+ *
+ * @param aItem The item providing the interval
+ * @returns The string describing the interval
+ */
+function getItemIntervalString(aItem, aBoxDate) {
+ // omit time label for all-day items
+ let formatter = lazy.cal.dtz.formatter;
+ let startDate = aItem[lazy.cal.dtz.startDateProp(aItem)];
+ let endDate = aItem[lazy.cal.dtz.endDateProp(aItem)];
+ if ((startDate && startDate.isDate) || (endDate && endDate.isDate)) {
+ return "";
+ }
+
+ // check for tasks without start and/or due date
+ if (!startDate || !endDate) {
+ return formatter.formatItemTimeInterval(aItem);
+ }
+
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ startDate = startDate.getInTimezone(defaultTimezone);
+ endDate = endDate.getInTimezone(defaultTimezone);
+ let start = startDate.clone();
+ let end = endDate.clone();
+ start.isDate = true;
+ end.isDate = true;
+ if (start.compare(end) == 0) {
+ // Events that start and end in the same day.
+ return formatter.formatTimeInterval(startDate, endDate);
+ }
+ // Events that span two or more days.
+ let compareStart = aBoxDate.compare(start);
+ let compareEnd = aBoxDate.compare(end);
+ if (compareStart == 0) {
+ return "\u21e4 " + formatter.formatTime(startDate); // unicode '⇀'
+ } else if (compareStart > 0 && compareEnd < 0) {
+ return "\u21ff"; // unicode '↔'
+ } else if (compareEnd == 0) {
+ return "\u21e5 " + formatter.formatTime(endDate); // unicode 'β‡₯'
+ }
+ return "";
+}
+
+/**
+ * Gets items from the composite calendar for printing.
+ *
+ * @param {calIDateTime} startDate
+ * @param {calIDateTime} endDate
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ * @returns {Promise<calIItemBase[]>}
+ */
+async function getItems(startDate, endDate, filter, notDueTasks) {
+ let window = Services.wm.getMostRecentWindow("mail:3pane");
+ let compositeCalendar = lazy.cal.view.getCompositeCalendar(window);
+
+ let itemList = [];
+ for await (let items of lazy.cal.iterate.streamValues(
+ compositeCalendar.getItems(filter, 0, startDate, endDate)
+ )) {
+ if (!notDueTasks) {
+ items = items.filter(i => !i.isTodo() || i.entryDate || i.dueDate);
+ }
+ itemList = itemList.concat(items);
+ }
+ return itemList;
+}
+
+/**
+ * A simple list of calendar items.
+ */
+let listView = {
+ /**
+ * Create the list view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the first day of the months to be displayed
+ * @param {calIDateTime} endDate - the first day of the month AFTER the
+ * months to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("list-container");
+ let listItemTemplate = document.getElementById("list-item-template");
+
+ // Get and sort items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ items.sort((a, b) => {
+ let start_a = a[lazy.cal.dtz.startDateProp(a)];
+ if (!start_a) {
+ return -1;
+ }
+ let start_b = b[lazy.cal.dtz.startDateProp(b)];
+ if (!start_b) {
+ return 1;
+ }
+ return start_a.compare(start_b);
+ });
+
+ // Display the items.
+ for (let item of items) {
+ let itemNode = listItemTemplate.content.firstElementChild.cloneNode(true);
+
+ let setupTextRow = function (classKey, propValue, prefixKey) {
+ if (propValue) {
+ let prefix = lazy.cal.l10n.getCalString(prefixKey);
+ itemNode.querySelector("." + classKey + "key").textContent = prefix;
+ itemNode.querySelector("." + classKey).textContent = propValue;
+ } else {
+ let row = itemNode.querySelector("." + classKey + "row");
+ if (
+ row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE
+ ) {
+ row.nextSibling.remove();
+ }
+ row.remove();
+ }
+ };
+
+ let itemStartDate = item[lazy.cal.dtz.startDateProp(item)];
+ let itemEndDate = item[lazy.cal.dtz.endDateProp(item)];
+ if (itemStartDate || itemEndDate) {
+ // This is a task with a start or due date, format accordingly
+ let prefixWhen = lazy.cal.l10n.getCalString("htmlPrefixWhen");
+ itemNode.querySelector(".intervalkey").textContent = prefixWhen;
+
+ let startNode = itemNode.querySelector(".dtstart");
+ let dateString = lazy.cal.dtz.formatter.formatItemInterval(item);
+ startNode.setAttribute("title", itemStartDate ? itemStartDate.icalString : "none");
+ startNode.textContent = dateString;
+ } else {
+ let row = itemNode.querySelector(".intervalrow");
+ row.remove();
+ if (
+ row.nextSibling &&
+ (row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE)
+ ) {
+ row.nextSibling.remove();
+ }
+ }
+
+ let itemTitle = item.isCompleted
+ ? lazy.cal.l10n.getCalString("htmlTaskCompleted", [item.title])
+ : item.title;
+ setupTextRow("summary", itemTitle, "htmlPrefixTitle");
+
+ setupTextRow("location", item.getProperty("LOCATION"), "htmlPrefixLocation");
+ setupTextRow("description", item.getProperty("DESCRIPTION"), "htmlPrefixDescription");
+
+ container.appendChild(itemNode);
+ }
+
+ // Set the page title.
+ endDate.day--;
+ document.title = lazy.cal.dtz.formatter.formatInterval(startDate, endDate);
+ },
+};
+
+/**
+ * A layout with one calendar month per page.
+ */
+let monthGridView = {
+ dayTable: {},
+
+ /**
+ * Create the month grid view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the first day of the months to be displayed
+ * @param {calIDateTime} endDate - the first day of the month AFTER the
+ * months to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("month-container");
+
+ // Draw the month grid(s).
+ let current = startDate.clone();
+ do {
+ container.appendChild(this.drawMonth(document, current));
+ current.month += 1;
+ } while (current.compare(endDate) < 0);
+
+ // Extend the date range to include adjacent days that will be printed.
+ startDate = lazy.cal.weekInfoService.getStartOfWeek(startDate);
+ // Get the end of the week containing the last day of the month, not the
+ // week containing the first day of the next month.
+ endDate.day--;
+ endDate = lazy.cal.weekInfoService.getEndOfWeek(endDate);
+ endDate.day++; // Add a day to include items from the last day.
+
+ // Get and display the items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ for (let item of items) {
+ let itemStartDate =
+ item[lazy.cal.dtz.startDateProp(item)] || item[lazy.cal.dtz.endDateProp(item)];
+ let itemEndDate =
+ item[lazy.cal.dtz.endDateProp(item)] || item[lazy.cal.dtz.startDateProp(item)];
+
+ if (!itemStartDate && !itemEndDate) {
+ addItemToDayboxNodate(document, item);
+ continue;
+ }
+ itemStartDate = itemStartDate.getInTimezone(defaultTimezone);
+ itemEndDate = itemEndDate.getInTimezone(defaultTimezone);
+
+ let boxDate = itemStartDate.clone();
+ boxDate.isDate = true;
+ for (boxDate; boxDate.compare(itemEndDate) < (itemEndDate.isDate ? 0 : 1); boxDate.day++) {
+ let boxDateString = boxDate.icalString;
+ if (boxDateString in this.dayTable) {
+ for (let dayBox of this.dayTable[boxDateString]) {
+ addItemToDaybox(document, item, boxDate, dayBox.querySelector(".items"));
+ }
+ }
+ }
+ }
+
+ // Set the page title.
+ let months = container.querySelectorAll("table");
+ if (months.length == 1) {
+ document.title = months[0].querySelector(".month-title").textContent;
+ } else {
+ document.title =
+ months[0].querySelector(".month-title").textContent +
+ " – " +
+ months[months.length - 1].querySelector(".month-title").textContent;
+ }
+ },
+
+ /**
+ * Create one month from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startOfMonth - the first day of the month
+ */
+ drawMonth(document, startOfMonth) {
+ let monthTemplate = document.getElementById("month-template");
+ let month = monthTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up the month title
+ let monthName = lazy.cal.l10n.formatMonth(startOfMonth.month + 1, "calendar", "monthInYear");
+ let monthTitle = lazy.cal.l10n.getCalString("monthInYear", [monthName, startOfMonth.year]);
+ month.rows[0].cells[0].firstElementChild.textContent = monthTitle;
+
+ // Set up the weekday titles
+ let weekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ for (let i = 0; i < 7; i++) {
+ let dayNumber = ((i + weekStart) % 7) + 1;
+ month.rows[1].cells[i].firstElementChild.textContent = lazy.cal.l10n.getDateFmtString(
+ `day.${dayNumber}.Mmm`
+ );
+ }
+
+ // Set up each week
+ let endOfMonthView = lazy.cal.weekInfoService.getEndOfWeek(startOfMonth.endOfMonth);
+ let startOfMonthView = lazy.cal.weekInfoService.getStartOfWeek(startOfMonth);
+ let mainMonth = startOfMonth.month;
+
+ for (
+ let weekStart = startOfMonthView;
+ weekStart.compare(endOfMonthView) < 0;
+ weekStart.day += 7
+ ) {
+ month.tBodies[0].appendChild(this.drawWeek(document, weekStart, mainMonth));
+ }
+
+ return month;
+ },
+
+ /**
+ * Create one week from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startOfWeek - the first day of the week
+ * @param {number} mainMonth - the month that this week is being added to
+ * (for marking days that are in adjacent months)
+ */
+ drawWeek(document, startOfWeek, mainMonth) {
+ const weekdayMap = [
+ "d0sundaysoff",
+ "d1mondaysoff",
+ "d2tuesdaysoff",
+ "d3wednesdaysoff",
+ "d4thursdaysoff",
+ "d5fridaysoff",
+ "d6saturdaysoff",
+ ];
+
+ let weekTemplate = document.getElementById("month-week-template");
+ let week = weekTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up day numbers for all days in this week
+ let date = startOfWeek.clone();
+ for (let i = 0; i < 7; i++) {
+ let dayBox = week.cells[i];
+ dayBox.querySelector(".day-title").textContent = date.day;
+
+ let weekDay = date.weekday;
+ let dayOffPrefName = "calendar.week." + weekdayMap[weekDay];
+ if (Services.prefs.getBoolPref(dayOffPrefName, false)) {
+ dayBox.classList.add("day-off");
+ }
+
+ if (date.month != mainMonth) {
+ dayBox.classList.add("out-of-month");
+ }
+
+ if (date.icalString in this.dayTable) {
+ this.dayTable[date.icalString].push(dayBox);
+ } else {
+ this.dayTable[date.icalString] = [dayBox];
+ }
+ date.day++;
+ }
+
+ return week;
+ },
+};
+
+/**
+ * A layout with seven days per page. The week layout is NOT aware of the
+ * start-of-week preferences. It always begins on a Monday.
+ */
+let weekPlannerView = {
+ dayTable: {},
+
+ /**
+ * Create the week planner view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the Monday of the first week to be displayed
+ * @param {calIDateTime} endDate - the Monday AFTER the last week to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("week-container");
+
+ // Draw the week grid(s).
+ for (let current = startDate.clone(); current.compare(endDate) < 0; current.day += 7) {
+ container.appendChild(this.drawWeek(document, current));
+ }
+
+ // Get and display the items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ for (let item of items) {
+ let itemStartDate =
+ item[lazy.cal.dtz.startDateProp(item)] || item[lazy.cal.dtz.endDateProp(item)];
+ let itemEndDate =
+ item[lazy.cal.dtz.endDateProp(item)] || item[lazy.cal.dtz.startDateProp(item)];
+
+ if (!itemStartDate && !itemEndDate) {
+ addItemToDayboxNodate(document, item);
+ continue;
+ }
+ itemStartDate = itemStartDate.getInTimezone(defaultTimezone);
+ itemEndDate = itemEndDate.getInTimezone(defaultTimezone);
+
+ let boxDate = itemStartDate.clone();
+ boxDate.isDate = true;
+ for (boxDate; boxDate.compare(itemEndDate) < (itemEndDate.isDate ? 0 : 1); boxDate.day++) {
+ let boxDateString = boxDate.icalString;
+ if (boxDateString in this.dayTable) {
+ addItemToDaybox(document, item, boxDate, this.dayTable[boxDateString]);
+ }
+ }
+ }
+
+ // Set the page title.
+ let weeks = container.querySelectorAll("table");
+ if (weeks.length == 1) {
+ document.title = lazy.cal.l10n.getCalString("singleLongCalendarWeek", [weeks[0].number]);
+ } else {
+ document.title = lazy.cal.l10n.getCalString("severalLongCalendarWeeks", [
+ weeks[0].number,
+ weeks[weeks.length - 1].number,
+ ]);
+ }
+ },
+
+ /**
+ * Create one week from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} monday - the Monday of the week
+ */
+ drawWeek(document, monday) {
+ // In the order they appear on the page.
+ const weekdayMap = [
+ "d1mondaysoff",
+ "d2tuesdaysoff",
+ "d3wednesdaysoff",
+ "d4thursdaysoff",
+ "d5fridaysoff",
+ "d6saturdaysoff",
+ "d0sundaysoff",
+ ];
+
+ let weekTemplate = document.getElementById("week-template");
+ let week = weekTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up the week number title
+ week.number = lazy.cal.weekInfoService.getWeekTitle(monday);
+ week.querySelector(".week-title").textContent = lazy.cal.l10n.getCalString("WeekTitle", [
+ week.number,
+ ]);
+
+ // Set up the day boxes
+ let currentDate = monday.clone();
+ for (let i = 0; i < 7; i++) {
+ let day = week.rows[1].cells[i];
+
+ let titleNode = day.querySelector(".day-title");
+ titleNode.textContent = lazy.cal.dtz.formatter.formatDateLong(currentDate);
+
+ this.dayTable[currentDate.icalString] = day.querySelector(".items");
+
+ if (Services.prefs.getBoolPref("calendar.week." + weekdayMap[i], false)) {
+ day.classList.add("day-off");
+ }
+
+ currentDate.day++;
+ }
+
+ return week;
+ },
+};
+
+Services.obs.addObserver(
+ {
+ async observe(subDialogWindow) {
+ if (!subDialogWindow.location.href.startsWith("chrome://global/content/print.html?")) {
+ return;
+ }
+
+ await new Promise(resolve =>
+ subDialogWindow.document.addEventListener("print-settings", resolve, { once: true })
+ );
+
+ if (
+ subDialogWindow.PrintEventHandler.activeCurrentURI !=
+ "chrome://calendar/content/printing-template.html"
+ ) {
+ return;
+ }
+
+ Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/widgets/calendar-minimonth.js",
+ subDialogWindow
+ );
+ Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/calendar-print.js",
+ subDialogWindow
+ );
+ },
+ },
+ "subdialog-loaded"
+);
diff --git a/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm b/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm
new file mode 100644
index 0000000000..ce76dbdd01
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["calproviderdetection"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Code to call the calendar provider detection mechanism.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.detection namespace.
+
+/**
+ * The base class marker for detection errors. Useful in instanceof checks.
+ */
+class DetectionError extends Error {}
+
+/**
+ * Creates an error class that extends the base detection error.
+ *
+ * @param {string} aName - The name of the constructor, used for the base error class.
+ * @returns {DetectionError} A class extending DetectionError.
+ */
+function DetectionErrorClass(aName) {
+ return class extends DetectionError {
+ constructor(message) {
+ super(message);
+ this.name = aName;
+ }
+ };
+}
+
+/**
+ * The exported `calproviderdetection` object.
+ */
+var calproviderdetection = {
+ /**
+ * A map of providers that implement detection. Maps the type identifier
+ * (e.g. "ics", "caldav") to the provider object.
+ *
+ * @type {Map<string, calICalendarProvider>}
+ */
+ get providers() {
+ let providers = new Map();
+ for (let [type, provider] of cal.provider.providers) {
+ if (provider.detectCalendars) {
+ providers.set(type, provider);
+ }
+ }
+ return providers;
+ },
+
+ /**
+ * Known domains for Google OAuth. This is just to catch the most common case,
+ * MX entries should be checked for remaining cases.
+ *
+ * @type {Set<string>}
+ */
+ googleOAuthDomains: new Set(["gmail.com", "googlemail.com", "apidata.googleusercontent.com"]),
+
+ /**
+ * Translate location and username to an uri. If the location is empty, the
+ * domain part of the username is taken. If the location is a hostname it is
+ * converted to a https:// uri, if it is an uri string then use that.
+ *
+ * @param {string} aLocation - The location string.
+ * @param {string} aUsername - The username string.
+ * @returns {nsIURI} The resulting location uri.
+ */
+ locationToUri(aLocation, aUsername) {
+ let uri = null;
+ if (!aLocation) {
+ let match = aUsername.match(/[^@]+@([^.]+\..*)/);
+ if (match) {
+ uri = Services.io.newURI("https://" + match[1]);
+ }
+ } else if (aLocation.includes("://")) {
+ // Try to parse it as an uri
+ uri = Services.io.newURI(aLocation);
+ } else {
+ // Maybe its just a simple hostname
+ uri = Services.io.newURI("https://" + aLocation);
+ }
+ return uri;
+ },
+
+ /**
+ * Detect calendars using the given information. The location can be a number
+ * of things and handling this is up to the provider. It could be a hostname,
+ * a specific URL, the origin URL, etc.
+ *
+ * @param {string} aUsername - The username for logging in.
+ * @param {string} aPassword - The password for logging in.
+ * @param {string} aLocation - The location information.
+ * @param {boolean} aSavePassword - If true, the credentials will be saved
+ * in the password manager if used.
+ * @param {ProviderFilter[]} aPreDetectFilters - Functions for filtering out providers.
+ * @param {object} aExtraProperties - Extra properties to pass on to the
+ * providers.
+ * @returns {Promise<Map<string, calICalendar[]>>} A promise resolving with a Map of
+ * provider type to calendars found.
+ */
+ async detect(
+ aUsername,
+ aPassword,
+ aLocation,
+ aSavePassword,
+ aPreDetectFilters,
+ aExtraProperties
+ ) {
+ let providers = this.providers;
+
+ if (!providers.size) {
+ throw new calproviderdetection.NoneFoundError(
+ "No providers available that implement calendar detection"
+ );
+ }
+
+ // Filter out the providers that should not be used (for the location, username, etc.).
+ for (let func of aPreDetectFilters) {
+ let typesToFilterOut = func(providers.keys(), aLocation, aUsername);
+ typesToFilterOut.forEach(type => providers.delete(type));
+ }
+
+ let resolutions = await Promise.allSettled(
+ [...providers.values()].map(provider => {
+ let detectionResult = provider.detectCalendars(
+ aUsername,
+ aPassword,
+ aLocation,
+ aSavePassword,
+ aExtraProperties
+ );
+ return detectionResult.then(
+ result => ({ provider, status: Cr.NS_OK, detail: result }),
+ failure => ({ provider, status: Cr.NS_ERROR_FAILURE, detail: failure })
+ );
+ })
+ );
+
+ let failCount = 0;
+ let lastError;
+ let results = new Map(
+ resolutions.reduce((res, resolution) => {
+ let { provider, status, detail } = resolution.value || resolution.reason;
+
+ if (Components.isSuccessCode(status) && detail && detail.length) {
+ res.push([provider, detail]);
+ } else {
+ failCount++;
+ if (detail instanceof DetectionError) {
+ lastError = detail;
+ }
+ }
+
+ return res;
+ }, [])
+ );
+
+ // If everything failed due to one of the detection errors, then pass that on.
+ if (failCount == resolutions.length) {
+ throw lastError || new calproviderdetection.NoneFoundError();
+ }
+
+ return results;
+ },
+
+ /** The base detection error class */
+ Error: DetectionError,
+
+ /** An error that can be thrown if authentication failed */
+ AuthFailedError: DetectionErrorClass("AuthFailedError"),
+
+ /** An error that can be thrown if the location is invalid or has no calendars */
+ NoneFoundError: DetectionErrorClass("NoneFoundError"),
+
+ /** An error that can be thrown if the user canceled the operation */
+ CanceledError: DetectionErrorClass("CanceledError"),
+};
diff --git a/comm/calendar/base/modules/utils/calProviderUtils.jsm b/comm/calendar/base/modules/utils/calProviderUtils.jsm
new file mode 100644
index 0000000000..5e78aad37b
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calProviderUtils.jsm
@@ -0,0 +1,907 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+/**
+ * Helpers and base class for calendar providers
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider namespace.
+
+const EXPORTED_SYMBOLS = ["calprovider"];
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CalPeriod: "resource:///modules/CalPeriod.jsm",
+ CalReadableStreamFactory: "resource:///modules/CalReadableStreamFactory.jsm",
+});
+
+var calprovider = {
+ /**
+ * Prepare HTTP channel with standard request headers and upload data/content-type if needed.
+ *
+ * @param {nsIURI} aUri - The channel URI, only used for a new channel.
+ * @param {nsIInputStream | string} aUploadData - Data to be uploaded, if any. If a string,
+ * it will be converted to an nsIInputStream.
+ * @param {string} aContentType - Value for Content-Type header, if any.
+ * @param {nsIInterfaceRequestor} aNotificationCallbacks - Typically a CalDavRequestBase which
+ * implements nsIInterfaceRequestor and nsIChannelEventSink, and provides access to the
+ * calICalendar associated with the channel.
+ * @param {nsIChannel} [aExistingChannel] - An existing channel to modify (optional).
+ * @param {boolean} [aForceNewAuth=false] - If true, use a new user context to avoid cached
+ * authentication (see code comments). Optional, ignored if aExistingChannel is passed.
+ * @returns {nsIChannel} - The prepared channel.
+ */
+ prepHttpChannel(
+ aUri,
+ aUploadData,
+ aContentType,
+ aNotificationCallbacks,
+ aExistingChannel = null,
+ aForceNewAuth = false
+ ) {
+ let originAttributes = {};
+
+ // The current nsIHttpChannel implementation separates connections only
+ // by hosts, which causes issues with cookies and password caching for
+ // two or more simultaneous connections to the same host and different
+ // authenticated users. This can be solved by providing the additional
+ // userContextId, which also separates connections (a.k.a. containers).
+ // Connections for userA @ server1 and userA @ server2 can exist in the
+ // same container, as nsIHttpChannel will separate them. Connections
+ // for userA @ server1 and userB @ server1 however must be placed into
+ // different containers. It is therefore sufficient to add individual
+ // userContextIds per username.
+
+ if (aForceNewAuth) {
+ // A random "username" that won't be the same as any existing one.
+ // The value is not used for any other reason, so a UUID will do.
+ originAttributes.userContextId = lazy.cal.auth.containerMap.getUserContextIdForUsername(
+ lazy.cal.getUUID()
+ );
+ } else if (!aExistingChannel) {
+ try {
+ // Use a try/catch because there may not be a calICalendar interface.
+ // For example, when there is no calendar associated with a request,
+ // as in calendar detection.
+ let calendar = aNotificationCallbacks.getInterface(Ci.calICalendar);
+ if (calendar && calendar.getProperty("capabilities.username.supported") === true) {
+ originAttributes.userContextId = lazy.cal.auth.containerMap.getUserContextIdForUsername(
+ calendar.getProperty("username")
+ );
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NO_INTERFACE) {
+ throw e;
+ }
+ }
+ }
+
+ // We cannot use a system principal here since the connection setup will fail if
+ // same-site cookie protection is enabled in TB and server-side.
+ let principal = aExistingChannel
+ ? null
+ : Services.scriptSecurityManager.createContentPrincipal(aUri, originAttributes);
+ let channel =
+ aExistingChannel ||
+ Services.io.newChannelFromURI(
+ aUri,
+ null,
+ principal,
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let httpchannel = channel.QueryInterface(Ci.nsIHttpChannel);
+
+ httpchannel.setRequestHeader("Accept", "text/xml", false);
+ httpchannel.setRequestHeader("Accept-Charset", "utf-8,*;q=0.1", false);
+ httpchannel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ httpchannel.notificationCallbacks = aNotificationCallbacks;
+
+ if (aUploadData) {
+ httpchannel = httpchannel.QueryInterface(Ci.nsIUploadChannel);
+ let stream;
+ if (aUploadData instanceof Ci.nsIInputStream) {
+ // Make sure the stream is reset
+ stream = aUploadData.QueryInterface(Ci.nsISeekableStream);
+ stream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+ } else {
+ // Otherwise its something that should be a string, convert it.
+ stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setUTF8Data(aUploadData, aUploadData.length);
+ }
+
+ httpchannel.setUploadStream(stream, aContentType, -1);
+ }
+
+ return httpchannel;
+ },
+
+ /**
+ * Send prepared HTTP request asynchronously
+ *
+ * @param {nsIStreamLoader} aStreamLoader - Stream loader for request
+ * @param {nsIChannel} aChannel - Channel for request
+ * @param {nsIStreamLoaderObserver} aListener - Listener for method completion
+ */
+ sendHttpRequest(aStreamLoader, aChannel, aListener) {
+ aStreamLoader.init(aListener);
+ aChannel.asyncOpen(aStreamLoader);
+ },
+
+ /**
+ * Shortcut to create an nsIStreamLoader
+ *
+ * @returns {nsIStreamLoader} A fresh streamloader
+ */
+ createStreamLoader() {
+ return Cc["@mozilla.org/network/stream-loader;1"].createInstance(Ci.nsIStreamLoader);
+ },
+
+ /**
+ * getInterface method for providers. This should be called in the context of
+ * the respective provider, i.e
+ *
+ * return cal.provider.InterfaceRequestor_getInterface.apply(this, arguments);
+ *
+ * or
+ * ...
+ * getInterface: cal.provider.InterfaceRequestor_getInterface,
+ * ...
+ *
+ * NOTE: If the server only provides one realm for all calendars, be sure that
+ * the |this| object implements calICalendar. In this case the calendar name
+ * will be appended to the realm. If you need that feature disabled, see the
+ * capabilities section of calICalendar.idl
+ *
+ * @param {nsIIDRef} aIID - The interface ID to return
+ * @returns {nsISupports} The requested interface
+ */
+ InterfaceRequestor_getInterface(aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ // Support Auth Prompt Interfaces
+ if (aIID.equals(Ci.nsIAuthPrompt2)) {
+ if (!this.calAuthPrompt) {
+ this.calAuthPrompt = new lazy.cal.auth.Prompt();
+ }
+ return this.calAuthPrompt;
+ } else if (aIID.equals(Ci.nsIAuthPromptProvider) || aIID.equals(Ci.nsIPrompt)) {
+ return Services.ww.getNewPrompter(null);
+ }
+ throw e;
+ }
+ },
+
+ /**
+ * Bad Certificate Handler for Network Requests. Shows the Network Exception
+ * Dialog if a certificate Problem occurs.
+ */
+ BadCertHandler: class {
+ /**
+ * @param {calICalendar} [calendar] - A calendar associated with the request, may be null.
+ */
+ constructor(calendar) {
+ this.calendar = calendar;
+ this.timer = null;
+ }
+
+ notifyCertProblem(secInfo, targetSite) {
+ // Unfortunately we can't pass js objects using the window watcher, so
+ // we'll just take the first available calendar window. We also need to
+ // do this on a timer so that the modal window doesn't block the
+ // network request.
+ let calWindow = lazy.cal.window.getCalendarWindow();
+
+ let timerCallback = {
+ calendar: this.calendar,
+ notify(timer) {
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location: targetSite,
+ };
+ calWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ if (this.calendar && this.calendar.canRefresh && params.exceptionAdded) {
+ // Refresh the calendar if the exception certificate was added
+ this.calendar.refresh();
+ }
+ },
+ };
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this.timer.initWithCallback(timerCallback, 0, Ci.nsITimer.TYPE_ONE_SHOT);
+ return true;
+ }
+ },
+
+ /**
+ * Check for bad server certificates on SSL/TLS connections.
+ *
+ * @param {nsIRequest} request - request from the Stream loader.
+ * @param {number} status - A Components.results result.
+ * @param {calICalendar} [calendar] - A calendar associated with the request, may be null.
+ */
+ checkBadCertStatus(request, status, calendar) {
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let isCertError = false;
+ try {
+ let errorType = nssErrorsService.getErrorClass(status);
+ if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ isCertError = true;
+ }
+ } catch (e) {
+ // nsINSSErrorsService.getErrorClass throws if given a non-TLS, non-cert error, so ignore this.
+ }
+
+ if (isCertError && request.securityInfo) {
+ let secInfo = request.securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+ let badCertHandler = new calprovider.BadCertHandler(calendar);
+ badCertHandler.notifyCertProblem(secInfo, request.originalURI.displayHostPort);
+ }
+ },
+
+ /**
+ * Freebusy interval implementation. All parameters are optional.
+ *
+ * @param aCalId The calendar id to set up with.
+ * @param aFreeBusyType The type from calIFreeBusyInterval.
+ * @param aStart The start of the interval.
+ * @param aEnd The end of the interval.
+ * @returns The fresh calIFreeBusyInterval.
+ */
+ FreeBusyInterval: class {
+ QueryInterface() {
+ return ChromeUtils.generateQI(["calIFreeBusyInterval"]);
+ }
+
+ constructor(aCalId, aFreeBusyType, aStart, aEnd) {
+ this.calId = aCalId;
+ this.interval = new lazy.CalPeriod();
+ this.interval.start = aStart;
+ this.interval.end = aEnd;
+
+ this.freeBusyType = aFreeBusyType || Ci.calIFreeBusyInterval.UNKNOWN;
+ }
+ },
+
+ /**
+ * Gets the iTIP/iMIP transport if the passed calendar has configured email.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get the transport for
+ * @returns {?calIItipTransport} The email transport, or null if no identity configured
+ */
+ getImipTransport(aCalendar) {
+ // assure an identity is configured for the calendar
+ if (aCalendar && aCalendar.getProperty("imip.identity")) {
+ return this.defaultImipTransport;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the configured identity and account of a particular calendar instance, or null.
+ *
+ * @param {calICalendar} aCalendar - Calendar instance
+ * @param {?object} outAccount - Optional out value for account
+ * @returns {nsIMsgIdentity} The configured identity
+ */
+ getEmailIdentityOfCalendar(aCalendar, outAccount) {
+ lazy.cal.ASSERT(aCalendar, "no calendar!", Cr.NS_ERROR_INVALID_ARG);
+ let key = aCalendar.getProperty("imip.identity.key");
+ if (key === null) {
+ // take default account/identity:
+ let findIdentity = function (account) {
+ if (account && account.identities.length) {
+ return account.defaultIdentity || account.identities[0];
+ }
+ return null;
+ };
+
+ let foundAccount = MailServices.accounts.defaultAccount;
+ let foundIdentity = findIdentity(foundAccount);
+
+ if (!foundAccount || !foundIdentity) {
+ for (let account of MailServices.accounts.accounts) {
+ let identity = findIdentity(account);
+
+ if (account && identity) {
+ foundAccount = account;
+ foundIdentity = identity;
+ break;
+ }
+ }
+ }
+
+ if (outAccount) {
+ outAccount.value = foundIdentity ? foundAccount : null;
+ }
+ return foundIdentity;
+ }
+ if (key.length == 0) {
+ // i.e. "None"
+ return null;
+ }
+ let identity = null;
+ lazy.cal.email.iterateIdentities((identity_, account) => {
+ if (identity_.key == key) {
+ identity = identity_;
+ if (outAccount) {
+ outAccount.value = account;
+ }
+ }
+ return identity_.key != key;
+ });
+
+ if (!identity) {
+ // dangling identity:
+ lazy.cal.WARN(
+ "Calendar " +
+ (aCalendar.uri ? aCalendar.uri.spec : aCalendar.id) +
+ " has a dangling E-Mail identity configured."
+ );
+ }
+ return identity;
+ },
+
+ /**
+ * Opens the calendar conflict dialog
+ *
+ * @param {string} aMode - The conflict mode, either "modify" or "delete"
+ * @param {calIItemBase} aItem - The item to raise a conflict for
+ * @returns {boolean} True, if the item should be overwritten
+ */
+ promptOverwrite(aMode, aItem) {
+ let window = lazy.cal.window.getCalendarWindow();
+ let args = {
+ item: aItem,
+ mode: aMode,
+ overwrite: false,
+ };
+
+ window.openDialog(
+ "chrome://calendar/content/calendar-conflicts-dialog.xhtml",
+ "calendarConflictsDialog",
+ "chrome,titlebar,modal",
+ args
+ );
+
+ return args.overwrite;
+ },
+
+ /**
+ * Gets the calendar directory, defaults to <profile-dir>/calendar-data
+ *
+ * @returns {nsIFile} The calendar-data directory as nsIFile
+ */
+ getCalendarDirectory() {
+ if (calprovider.getCalendarDirectory.mDir === undefined) {
+ let dir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ dir.append("calendar-data");
+ if (!dir.exists()) {
+ try {
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700);
+ } catch (exc) {
+ lazy.cal.ASSERT(false, exc);
+ throw exc;
+ }
+ }
+ calprovider.getCalendarDirectory.mDir = dir;
+ }
+ return calprovider.getCalendarDirectory.mDir.clone();
+ },
+
+ /**
+ * Base prototype to be used implementing a calICalendar.
+ */
+ BaseClass: class {
+ /**
+ * The transient properties that are not pesisted to storage
+ */
+ static get mTransientProperties() {
+ return {
+ "cache.uncachedCalendar": true,
+ currentStatus: true,
+ "itip.transport": true,
+ "imip.identity": true,
+ "imip.account": true,
+ "imip.identity.disabled": true,
+ organizerId: true,
+ organizerCN: true,
+ };
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["calICalendar", "calISchedulingSupport"]);
+
+ /**
+ * Initialize the base class, this should be migrated to an ES6 constructor once all
+ * subclasses are also es6 classes. Call this from the constructor.
+ */
+ initProviderBase() {
+ this.wrappedJSObject = this;
+ this.mID = null;
+ this.mUri = null;
+ this.mACLEntry = null;
+ this.mBatchCount = 0;
+ this.transientProperties = false;
+ this.mObservers = new lazy.cal.data.ObserverSet(Ci.calIObserver);
+ this.mProperties = {};
+ this.mProperties.currentStatus = Cr.NS_OK;
+ }
+
+ /**
+ * Returns the calIObservers for this calendar
+ */
+ get observers() {
+ return this.mObservers;
+ }
+
+ // attribute AUTF8String id;
+ get id() {
+ return this.mID;
+ }
+ set id(aValue) {
+ if (this.mID) {
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+ this.mID = aValue;
+
+ // make all properties persistent that have been set so far:
+ for (let aName in this.mProperties) {
+ if (!this.constructor.mTransientProperties[aName]) {
+ let value = this.mProperties[aName];
+ if (value !== null) {
+ lazy.cal.manager.setCalendarPref_(this, aName, value);
+ }
+ }
+ }
+ }
+
+ // attribute AUTF8String name;
+ get name() {
+ return this.getProperty("name");
+ }
+ set name(aValue) {
+ this.setProperty("name", aValue);
+ }
+
+ // readonly attribute calICalendarACLManager aclManager;
+ get aclManager() {
+ const defaultACLProviderClass = "@mozilla.org/calendar/acl-manager;1?type=default";
+ let providerClass = this.getProperty("aclManagerClass");
+ if (!providerClass || !Cc[providerClass]) {
+ providerClass = defaultACLProviderClass;
+ }
+ return Cc[providerClass].getService(Ci.calICalendarACLManager);
+ }
+
+ // readonly attribute calICalendarACLEntry aclEntry;
+ get aclEntry() {
+ return this.mACLEntry;
+ }
+
+ // attribute calICalendar superCalendar;
+ get superCalendar() {
+ // If we have a superCalendar, check this calendar for a superCalendar.
+ // This will make sure the topmost calendar is returned
+ return this.mSuperCalendar ? this.mSuperCalendar.superCalendar : this;
+ }
+ set superCalendar(val) {
+ this.mSuperCalendar = val;
+ }
+
+ // attribute nsIURI uri;
+ get uri() {
+ return this.mUri;
+ }
+ set uri(aValue) {
+ this.mUri = aValue;
+ }
+
+ // attribute boolean readOnly;
+ get readOnly() {
+ return this.getProperty("readOnly");
+ }
+ set readOnly(aValue) {
+ this.setProperty("readOnly", aValue);
+ }
+
+ // readonly attribute boolean canRefresh;
+ get canRefresh() {
+ return false;
+ }
+
+ // void startBatch();
+ startBatch() {
+ if (this.mBatchCount++ == 0) {
+ this.mObservers.notify("onStartBatch", [this]);
+ }
+ }
+
+ // void endBatch();
+ endBatch() {
+ if (this.mBatchCount > 0) {
+ if (--this.mBatchCount == 0) {
+ this.mObservers.notify("onEndBatch", [this]);
+ }
+ } else {
+ lazy.cal.ASSERT(this.mBatchCount > 0, "unexpected endBatch!");
+ }
+ }
+
+ /**
+ * Implementation of calICalendar.getItems(). This should be overridden by
+ * all child classes.
+ *
+ * @param {number} itemFilter
+ * @param {number} count
+ * @param {calIDateTime} rangeStart
+ * @param {calIDateTime} rangeEnd
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ return lazy.CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ /**
+ * Implementation of calICalendar.getItemsAsArray().
+ *
+ * @param {number} itemFilter
+ * @param {number} count
+ * @param {calIDateTime} rangeStart
+ * @param {calIDateTime} rangeEnd
+ *
+ * @returns {calIItemBase[]}
+ */
+ async getItemsAsArray(itemFilter, count, rangeStart, rangeEnd) {
+ return lazy.cal.iterate.streamToArray(this.getItems(itemFilter, count, rangeStart, rangeEnd));
+ }
+
+ /**
+ * Notifies the given listener for onOperationComplete, ignoring (but logging) any
+ * exceptions that occur. If no listener is passed the function is a no-op.
+ *
+ * @param {?calIOperationListener} aListener - The listener to notify
+ * @param {number} aStatus - A Components.results result
+ * @param {number} aOperationType - The operation type component
+ * @param {string} aId - The item id
+ * @param {*} aDetail - The item detail for the listener
+ */
+ notifyPureOperationComplete(aListener, aStatus, aOperationType, aId, aDetail) {
+ if (aListener) {
+ try {
+ aListener.onOperationComplete(this.superCalendar, aStatus, aOperationType, aId, aDetail);
+ } catch (exc) {
+ lazy.cal.ERROR(exc);
+ }
+ }
+ }
+
+ /**
+ * Notifies the given listener for onOperationComplete, also setting various calendar status
+ * variables and notifying about the error.
+ *
+ * @param {?calIOperationListener} aListener - The listener to notify
+ * @param {number} aStatus - A Components.results result
+ * @param {number} aOperationType - The operation type component
+ * @param {string} aId - The item id
+ * @param {*} aDetail - The item detail for the listener
+ * @param {string} aExtraMessage - An extra message to pass to notifyError
+ */
+ notifyOperationComplete(aListener, aStatus, aOperationType, aId, aDetail, aExtraMessage) {
+ this.notifyPureOperationComplete(aListener, aStatus, aOperationType, aId, aDetail);
+
+ if (aStatus == Ci.calIErrors.OPERATION_CANCELLED) {
+ return; // cancellation doesn't change current status, no notification
+ }
+ if (Components.isSuccessCode(aStatus)) {
+ this.setProperty("currentStatus", aStatus);
+ } else {
+ if (aDetail instanceof Ci.nsIException) {
+ this.notifyError(aDetail); // will set currentStatus
+ } else {
+ this.notifyError(aStatus, aDetail); // will set currentStatus
+ }
+ this.notifyError(
+ aOperationType == Ci.calIOperationListener.GET
+ ? Ci.calIErrors.READ_FAILED
+ : Ci.calIErrors.MODIFICATION_FAILED,
+ aExtraMessage || ""
+ );
+ }
+ }
+
+ /**
+ * Notify observers using the onError notification with a readable error message
+ *
+ * @param {number | nsIException} aErrNo The error number from Components.results, or
+ * the exception which contains the error number
+ * @param {?string} aMessage - The message to show for the error
+ */
+ notifyError(aErrNo, aMessage = null) {
+ if (aErrNo == Ci.calIErrors.OPERATION_CANCELLED) {
+ return; // cancellation doesn't change current status, no notification
+ }
+ if (aErrNo instanceof Ci.nsIException) {
+ if (!aMessage) {
+ aMessage = aErrNo.message;
+ }
+ aErrNo = aErrNo.result;
+ }
+ this.setProperty("currentStatus", aErrNo);
+ this.observers.notify("onError", [this.superCalendar, aErrNo, aMessage]);
+ }
+
+ // nsIVariant getProperty(in AUTF8String aName);
+ getProperty(aName) {
+ switch (aName) {
+ case "itip.transport": // iTIP/iMIP default:
+ return calprovider.getImipTransport(this);
+ case "itip.notify-replies": // iTIP/iMIP default:
+ return Services.prefs.getBoolPref("calendar.itip.notify-replies", false);
+ // temporary hack to get the uncached calendar instance:
+ case "cache.uncachedCalendar":
+ return this;
+ }
+
+ let ret = this.mProperties[aName];
+ if (ret === undefined) {
+ ret = null;
+ switch (aName) {
+ case "imip.identity": // we want to cache the identity object a little, because
+ // it is heavily used by the invitation checks
+ ret = calprovider.getEmailIdentityOfCalendar(this);
+ break;
+ case "imip.account": {
+ let outAccount = {};
+ if (calprovider.getEmailIdentityOfCalendar(this, outAccount)) {
+ ret = outAccount.value;
+ }
+ break;
+ }
+ case "organizerId": {
+ // itip/imip default: derived out of imip.identity
+ let identity = this.getProperty("imip.identity");
+ ret = identity ? "mailto:" + identity.QueryInterface(Ci.nsIMsgIdentity).email : null;
+ break;
+ }
+ case "organizerCN": {
+ // itip/imip default: derived out of imip.identity
+ let identity = this.getProperty("imip.identity");
+ ret = identity ? identity.QueryInterface(Ci.nsIMsgIdentity).fullName : null;
+ break;
+ }
+ }
+ if (
+ ret === null &&
+ !this.constructor.mTransientProperties[aName] &&
+ !this.transientProperties
+ ) {
+ if (this.id) {
+ ret = lazy.cal.manager.getCalendarPref_(this, aName);
+ }
+ switch (aName) {
+ case "suppressAlarms":
+ if (this.getProperty("capabilities.alarms.popup.supported") === false) {
+ // If popup alarms are not supported,
+ // automatically suppress alarms
+ ret = true;
+ }
+ break;
+ }
+ }
+ this.mProperties[aName] = ret;
+ }
+ return ret;
+ }
+
+ // void setProperty(in AUTF8String aName, in nsIVariant aValue);
+ setProperty(aName, aValue) {
+ let oldValue = this.getProperty(aName);
+ if (oldValue != aValue) {
+ this.mProperties[aName] = aValue;
+ switch (aName) {
+ case "imip.identity.key": // invalidate identity and account object if key is set:
+ delete this.mProperties["imip.identity"];
+ delete this.mProperties["imip.account"];
+ delete this.mProperties.organizerId;
+ delete this.mProperties.organizerCN;
+ break;
+ }
+ if (!this.transientProperties && !this.constructor.mTransientProperties[aName] && this.id) {
+ lazy.cal.manager.setCalendarPref_(this, aName, aValue);
+ }
+ this.mObservers.notify("onPropertyChanged", [this.superCalendar, aName, aValue, oldValue]);
+ }
+ return aValue;
+ }
+
+ // void deleteProperty(in AUTF8String aName);
+ deleteProperty(aName) {
+ this.mObservers.notify("onPropertyDeleting", [this.superCalendar, aName]);
+ delete this.mProperties[aName];
+ lazy.cal.manager.deleteCalendarPref_(this, aName);
+ }
+
+ // calIOperation refresh
+ refresh() {
+ return null;
+ }
+
+ // void addObserver( in calIObserver observer );
+ addObserver(aObserver) {
+ this.mObservers.add(aObserver);
+ }
+
+ // void removeObserver( in calIObserver observer );
+ removeObserver(aObserver) {
+ this.mObservers.delete(aObserver);
+ }
+
+ // calISchedulingSupport: Implementation corresponding to our iTIP/iMIP support
+ isInvitation(aItem) {
+ if (!this.mACLEntry || !this.mACLEntry.hasAccessControl) {
+ // No ACL support - fallback to the old method
+ let id = aItem.getProperty("X-MOZ-INVITED-ATTENDEE") || this.getProperty("organizerId");
+ if (id) {
+ let org = aItem.organizer;
+ if (!org || !org.id || org.id.toLowerCase() == id.toLowerCase()) {
+ return false;
+ }
+ return aItem.getAttendeeById(id) != null;
+ }
+ return false;
+ }
+
+ let org = aItem.organizer;
+ if (!org || !org.id) {
+ // HACK
+ // if we don't have an organizer, this is perhaps because it's an exception
+ // to a recurring event. We check the parent item.
+ if (aItem.parentItem) {
+ org = aItem.parentItem.organizer;
+ if (!org || !org.id) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ // We check if :
+ // - the organizer of the event is NOT within the owner's identities of this calendar
+ // - if the one of the owner's identities of this calendar is in the attendees
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ for (let i = 0; i < ownerIdentities.length; i++) {
+ let identity = "mailto:" + ownerIdentities[i].email.toLowerCase();
+ if (org.id.toLowerCase() == identity) {
+ return false;
+ }
+
+ if (aItem.getAttendeeById(identity) != null) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // calIAttendee getInvitedAttendee(in calIItemBase aItem);
+ getInvitedAttendee(aItem) {
+ let id = this.getProperty("organizerId");
+ let attendee = id ? aItem.getAttendeeById(id) : null;
+
+ if (!attendee && this.mACLEntry && this.mACLEntry.hasAccessControl) {
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ if (ownerIdentities.length > 0) {
+ let identity;
+ for (let i = 0; !attendee && i < ownerIdentities.length; i++) {
+ identity = "mailto:" + ownerIdentities[i].email.toLowerCase();
+ attendee = aItem.getAttendeeById(identity);
+ }
+ }
+ }
+
+ return attendee;
+ }
+
+ // boolean canNotify(in AUTF8String aMethod, in calIItemBase aItem);
+ canNotify(aMethod, aItem) {
+ return false; // use outbound iTIP for all
+ }
+ },
+
+ // Provider Registration
+
+ /**
+ * Register a provider.
+ *
+ * @param {calICalendarProvider} provider - The provider object.
+ */
+ register(provider) {
+ this.providers.set(provider.type, provider);
+ },
+
+ /**
+ * Unregister a provider.
+ *
+ * @param {string} type - The type of the provider to unregister.
+ * @returns {boolean} True if the provider was unregistered, false if
+ * it was not registered in the first place.
+ */
+ unregister(type) {
+ return this.providers.delete(type);
+ },
+
+ /**
+ * Get a provider by its type property, e.g. "ics", "caldav".
+ *
+ * @param {string} type - Type of the provider to get.
+ * @returns {calICalendarProvider | undefined} Provider or undefined if none
+ * is registered for the type.
+ */
+ byType(type) {
+ return this.providers.get(type);
+ },
+
+ /**
+ * The built-in "ics" provider.
+ *
+ * @type {calICalendarProvider}
+ */
+ get ics() {
+ return this.byType("ics");
+ },
+
+ /**
+ * The built-in "caldav" provider.
+ *
+ * @type {calICalendarProvider}
+ */
+ get caldav() {
+ return this.byType("caldav");
+ },
+};
+
+// Initialize `cal.provider.providers` with the built-in providers.
+XPCOMUtils.defineLazyGetter(calprovider, "providers", () => {
+ const { CalICSProvider } = ChromeUtils.import("resource:///modules/CalICSProvider.jsm");
+ const { CalDavProvider } = ChromeUtils.import("resource:///modules/CalDavProvider.jsm");
+ return new Map([
+ ["ics", CalICSProvider],
+ ["caldav", CalDavProvider],
+ ]);
+});
+
+// This is the transport returned by getImipTransport().
+XPCOMUtils.defineLazyGetter(calprovider, "defaultImipTransport", () => {
+ const { CalItipEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+ );
+ return CalItipEmailTransport.createInstance();
+});
+
+// Set up the `cal.provider.detection` module.
+XPCOMUtils.defineLazyModuleGetter(
+ calprovider,
+ "detection",
+ "resource:///modules/calendar/utils/calProviderDetectionUtils.jsm",
+ "calproviderdetection"
+);
diff --git a/comm/calendar/base/modules/utils/calUnifinderUtils.jsm b/comm/calendar/base/modules/utils/calUnifinderUtils.jsm
new file mode 100644
index 0000000000..aeaf8d24ed
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calUnifinderUtils.jsm
@@ -0,0 +1,206 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helpers for the unifinder
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.unifinder namespace.
+
+const EXPORTED_SYMBOLS = ["calunifinder"];
+
+var calunifinder = {
+ /**
+ * Retrieves the value that is used for comparison for the item with the given
+ * property.
+ *
+ * @param {calIItemBaes} aItem - The item to retrieve the sort key for
+ * @param {string} aKey - The property name that should be sorted
+ * @returns {*} The value used in sort comparison
+ */
+ getItemSortKey(aItem, aKey) {
+ const taskStatus = ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"];
+ const eventStatus = ["TENTATIVE", "CONFIRMED", "CANCELLED"];
+
+ switch (aKey) {
+ case "priority":
+ return aItem.priority || 5;
+
+ case "title":
+ return aItem.title || "";
+
+ case "entryDate":
+ case "startDate":
+ case "dueDate":
+ case "endDate":
+ case "completedDate":
+ if (aItem[aKey] == null) {
+ return -62168601600000000; // ns value for (0000/00/00 00:00:00)
+ }
+ return aItem[aKey].nativeTime;
+
+ case "percentComplete":
+ return aItem.percentComplete;
+
+ case "categories":
+ return aItem.getCategories().join(", ");
+
+ case "location":
+ return aItem.getProperty("LOCATION") || "";
+
+ case "status": {
+ let statusSet = aItem.isEvent() ? eventStatus : taskStatus;
+ return statusSet.indexOf(aItem.status);
+ }
+ case "calendar":
+ return aItem.calendar.name || "";
+
+ default:
+ return null;
+ }
+ },
+
+ /**
+ * Returns a sort function for the given sort type.
+ *
+ * @param {string} aSortKey - The sort key to get the compare function for
+ * @returns {Function} The function to be used for sorting values of the type
+ */
+ sortEntryComparer(aSortKey) {
+ switch (aSortKey) {
+ case "title":
+ case "categories":
+ case "location":
+ case "calendar":
+ return sortCompare.string;
+
+ // All dates use "date_filled"
+ case "completedDate":
+ case "startDate":
+ case "endDate":
+ case "dueDate":
+ case "entryDate":
+ return sortCompare.date_filled;
+
+ case "priority":
+ case "percentComplete":
+ case "status":
+ return sortCompare.number;
+ default:
+ return sortCompare.unknown;
+ }
+ },
+
+ /**
+ * Sort the unifinder items by the given sort key, using the modifier to flip direction. The
+ * items are sorted in place.
+ *
+ * @param {calIItemBase[]} aItems - The items to sort
+ * @param {string} aSortKey - The item sort key
+ * @param {?number} aModifier - Either 1 or -1, to indicate sort direction
+ */
+ sortItems(aItems, aSortKey, aModifier = 1) {
+ let comparer = calunifinder.sortEntryComparer(aSortKey);
+ aItems.sort((a, b) => {
+ let sortvalA = calunifinder.getItemSortKey(a, aSortKey);
+ let sortvalB = calunifinder.getItemSortKey(b, aSortKey);
+ return comparer(sortvalA, sortvalB, aModifier);
+ });
+ },
+};
+
+/**
+ * Sort compare functions that can be used with Array sort(). The modifier can flip the sort
+ * direction by passing -1 or 1.
+ */
+const sortCompare = (calunifinder.sortEntryComparer._sortCompare = {
+ /**
+ * Compare two things as if they were numbers.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ number(a, b, modifier = 1) {
+ return sortCompare.general(Number(a), Number(b), modifier);
+ },
+
+ /**
+ * Compare two things as if they were dates.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ date(a, b, modifier = 1) {
+ return sortCompare.general(a, b, modifier);
+ },
+
+ /**
+ * Compare two things generally, using the typical ((a > b) - (a < b))
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ general(a, b, modifier = 1) {
+ return ((a > b) - (a < b)) * modifier;
+ },
+
+ /**
+ * Compare two dates, keeping the nativeTime zero date in mind.
+ *
+ * @param {*} a - The first date to compare
+ * @param {*} b - The second date to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ date_filled(a, b, modifier = 1) {
+ const NULL_DATE = -62168601600000000;
+
+ if (a == b) {
+ return 0;
+ } else if (a == NULL_DATE) {
+ return 1;
+ } else if (b == NULL_DATE) {
+ return -1;
+ }
+ return sortCompare.general(a, b, modifier);
+ },
+
+ /**
+ * Compare two strings, sorting empty values to the end by default
+ *
+ * @param {*} a - The first string to compare
+ * @param {*} b - The second string to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ string(a, b, modifier = 1) {
+ if (a.length == 0 || b.length == 0) {
+ // sort empty values to end (so when users first sort by a
+ // column, they can see and find the desired values in that
+ // column without scrolling past all the empty values).
+ return -(a.length - b.length) * modifier;
+ }
+
+ return a.localeCompare(b, undefined, { numeric: true }) * modifier;
+ },
+
+ /**
+ * Catch-all function to compare two unknown values. Will return 0.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - Provided for consistency, but unused
+ * @returns {number} Will always return 0
+ */
+ unknown(a, b, modifier = 1) {
+ return 0;
+ },
+});
diff --git a/comm/calendar/base/modules/utils/calViewUtils.jsm b/comm/calendar/base/modules/utils/calViewUtils.jsm
new file mode 100644
index 0000000000..dd4d1fd9ba
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calViewUtils.jsm
@@ -0,0 +1,521 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * View and DOM related helper functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.view namespace.
+
+const EXPORTED_SYMBOLS = ["calview"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gParserUtils",
+ "@mozilla.org/parserutils;1",
+ "nsIParserUtils"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gTextToHtmlConverter",
+ "@mozilla.org/txttohtmlconv;1",
+ "mozITXTToHTMLConv"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "calendarSortOrder",
+ "calendar.list.sortOrder",
+ null,
+ null,
+ val => (val ? val.split(" ") : [])
+);
+
+var calview = {
+ /**
+ * Returns a parentnode - or the passed node - with the given attribute
+ * value for the given attributename by traversing up the DOM hierarchy.
+ *
+ * @param aChildNode The childnode.
+ * @param aAttibuteName The name of the attribute that is to be compared with
+ * @param aAttibuteValue The value of the attribute that is to be compared with
+ * @returns The parent with the given attributeName set that has
+ * the same value as the given given attributevalue
+ * 'aAttributeValue'. If no appropriate
+ * parent node can be retrieved it is returned 'null'.
+ */
+ getParentNodeOrThisByAttribute(aChildNode, aAttributeName, aAttributeValue) {
+ let node = aChildNode;
+ while (node && node.getAttribute(aAttributeName) != aAttributeValue) {
+ node = node.parentNode;
+ if (node.tagName == undefined) {
+ return null;
+ }
+ }
+ return node;
+ },
+
+ /**
+ * Format the given string to work inside a CSS rule selector
+ * (and as part of a non-unicode preference key).
+ *
+ * Replaces each space ' ' char with '_'.
+ * Replaces each char other than ascii digits and letters, with '-uxHHH-'
+ * where HHH is unicode in hexadecimal (variable length, terminated by the '-').
+ *
+ * Ensures: result only contains ascii digits, letters,'-', and '_'.
+ * Ensures: result is invertible, so (f(a) = f(b)) implies (a = b).
+ * also means f is not idempotent, so (a != f(a)) implies (f(a) != f(f(a))).
+ * Ensures: result must be lowercase.
+ * Rationale: preference keys require 8bit chars, and ascii chars are legible
+ * in most fonts (in case user edits PROFILE/prefs.js).
+ * CSS class names in Gecko 1.8 seem to require lowercase,
+ * no punctuation, and of course no spaces.
+ * nmchar [_a-zA-Z0-9-]|{nonascii}|{escape}
+ * name {nmchar}+
+ * http://www.w3.org/TR/CSS21/grammar.html#scanner
+ *
+ * @param aString The unicode string to format
+ * @returns The formatted string using only chars [_a-zA-Z0-9-]
+ */
+ formatStringForCSSRule(aString) {
+ function toReplacement(char) {
+ // char code is natural number (positive integer)
+ let nat = char.charCodeAt(0);
+ switch (nat) {
+ case 0x20: // space
+ return "_";
+ default:
+ return "-ux" + nat.toString(16) + "-"; // lowercase
+ }
+ }
+ // Result must be lowercase or style rule will not work.
+ return aString.toLowerCase().replace(/[^a-zA-Z0-9]/g, toReplacement);
+ },
+
+ /**
+ * Gets the cached instance of the composite calendar.
+ *
+ * @param aWindow The window to get the composite calendar for.
+ */
+ getCompositeCalendar(aWindow) {
+ if (typeof aWindow._compositeCalendar == "undefined") {
+ let comp = (aWindow._compositeCalendar = Cc[
+ "@mozilla.org/calendar/calendar;1?type=composite"
+ ].createInstance(Ci.calICompositeCalendar));
+ const prefix = "calendar-main";
+
+ const calManagerObserver = {
+ QueryInterface: ChromeUtils.generateQI([Ci.calICalendarManagerObserver]),
+
+ onCalendarRegistered(calendar) {
+ let inComposite = calendar.getProperty(prefix + "-in-composite");
+ if (inComposite === null && !calendar.getProperty("disabled")) {
+ comp.addCalendar(calendar);
+ }
+ },
+ onCalendarUnregistering(calendar) {
+ comp.removeCalendar(calendar);
+ if (!comp.defaultCalendar || comp.defaultCalendar.id == calendar.id) {
+ comp.defaultCalendar = comp.getCalendars()[0];
+ }
+ },
+ onCalendarDeleting(calendar) {},
+ };
+ lazy.cal.manager.addObserver(calManagerObserver);
+ aWindow.addEventListener("unload", () => lazy.cal.manager.removeObserver(calManagerObserver));
+
+ comp.prefPrefix = prefix; // populate calendar from existing calendars
+
+ if (typeof aWindow.gCalendarStatusFeedback != "undefined") {
+ // If we are in a window that has calendar status feedback, set
+ // up our status observer.
+ comp.setStatusObserver(aWindow.gCalendarStatusFeedback, aWindow);
+ }
+ }
+ return aWindow._compositeCalendar;
+ },
+
+ /**
+ * Hash the given string into a color from the color palette of the standard
+ * color picker.
+ *
+ * @param str The string to hash into a color.
+ * @returns The hashed color.
+ */
+ hashColor(str) {
+ // This is the palette of colors in the current colorpicker implementation.
+ // Unfortunately, there is no easy way to extract these colors from the
+ // binding directly.
+ const colorPalette = [
+ "#FFFFFF",
+ "#FFCCCC",
+ "#FFCC99",
+ "#FFFF99",
+ "#FFFFCC",
+ "#99FF99",
+ "#99FFFF",
+ "#CCFFFF",
+ "#CCCCFF",
+ "#FFCCFF",
+ "#CCCCCC",
+ "#FF6666",
+ "#FF9966",
+ "#FFFF66",
+ "#FFFF33",
+ "#66FF99",
+ "#33FFFF",
+ "#66FFFF",
+ "#9999FF",
+ "#FF99FF",
+ "#C0C0C0",
+ "#FF0000",
+ "#FF9900",
+ "#FFCC66",
+ "#FFFF00",
+ "#33FF33",
+ "#66CCCC",
+ "#33CCFF",
+ "#6666CC",
+ "#CC66CC",
+ "#999999",
+ "#CC0000",
+ "#FF6600",
+ "#FFCC33",
+ "#FFCC00",
+ "#33CC00",
+ "#00CCCC",
+ "#3366FF",
+ "#6633FF",
+ "#CC33CC",
+ "#666666",
+ "#990000",
+ "#CC6600",
+ "#CC9933",
+ "#999900",
+ "#009900",
+ "#339999",
+ "#3333FF",
+ "#6600CC",
+ "#993399",
+ "#333333",
+ "#660000",
+ "#993300",
+ "#996633",
+ "#666600",
+ "#006600",
+ "#336666",
+ "#000099",
+ "#333399",
+ "#663366",
+ "#000000",
+ "#330000",
+ "#663300",
+ "#663333",
+ "#333300",
+ "#003300",
+ "#003333",
+ "#000066",
+ "#330099",
+ "#330033",
+ ];
+
+ let sum = Array.from(str || " ", e => e.charCodeAt(0)).reduce((a, b) => a + b);
+ return colorPalette[sum % colorPalette.length];
+ },
+
+ /**
+ * Pick whichever of "black" or "white" will look better when used as a text
+ * color against a background of bgColor.
+ *
+ * @param bgColor the background color as a "#RRGGBB" string
+ */
+ getContrastingTextColor(bgColor) {
+ let calcColor = bgColor.replace(/#/g, "");
+ let red = parseInt(calcColor.substring(0, 2), 16);
+ let green = parseInt(calcColor.substring(2, 4), 16);
+ let blue = parseInt(calcColor.substring(4, 6), 16);
+
+ // Calculate the brightness (Y) value using the YUV color system.
+ let brightness = 0.299 * red + 0.587 * green + 0.114 * blue;
+
+ // Consider all colors with less than 56% brightness as dark colors and
+ // use white as the foreground color, otherwise use black.
+ if (brightness < 144) {
+ return "white";
+ }
+
+ return "#222";
+ },
+
+ /**
+ * Item comparator for inserting items into dayboxes.
+ *
+ * @param a The first item
+ * @param b The second item
+ * @returns The usual -1, 0, 1
+ */
+ compareItems(a, b) {
+ if (!a) {
+ return -1;
+ }
+ if (!b) {
+ return 1;
+ }
+
+ let aIsEvent = a.isEvent();
+ let aIsTodo = a.isTodo();
+
+ let bIsEvent = b.isEvent();
+ let bIsTodo = b.isTodo();
+
+ // sort todos before events
+ if (aIsTodo && bIsEvent) {
+ return -1;
+ }
+ if (aIsEvent && bIsTodo) {
+ return 1;
+ }
+
+ // sort items of the same type according to date-time
+ let aStartDate = a.startDate || a.entryDate || a.dueDate;
+ let bStartDate = b.startDate || b.entryDate || b.dueDate;
+ let aEndDate = a.endDate || a.dueDate || a.entryDate;
+ let bEndDate = b.endDate || b.dueDate || b.entryDate;
+ if (!aStartDate || !bStartDate) {
+ return 0;
+ }
+
+ // sort all day events before events with a duration
+ if (aStartDate.isDate && !bStartDate.isDate) {
+ return -1;
+ }
+ if (!aStartDate.isDate && bStartDate.isDate) {
+ return 1;
+ }
+
+ let cmp = aStartDate.compare(bStartDate);
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ if (!aEndDate || !bEndDate) {
+ return 0;
+ }
+ cmp = aEndDate.compare(bEndDate);
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ if (a.calendar && b.calendar) {
+ cmp =
+ lazy.calendarSortOrder.indexOf(a.calendar.id) -
+ lazy.calendarSortOrder.indexOf(b.calendar.id);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+
+ cmp = (a.title > b.title) - (a.title < b.title);
+ return cmp;
+ },
+
+ get calendarSortOrder() {
+ return lazy.calendarSortOrder;
+ },
+
+ /**
+ * Converts plain or HTML text into an HTML document fragment.
+ *
+ * @param {string} text - The text to convert.
+ * @param {Document} doc - The document where the fragment will be appended.
+ * @param {string} html - HTML if it's already available.
+ * @returns {DocumentFragment} An HTML document fragment.
+ */
+ textToHtmlDocumentFragment(text, doc, html) {
+ if (!html) {
+ let mode =
+ Ci.mozITXTToHTMLConv.kStructPhrase |
+ Ci.mozITXTToHTMLConv.kGlyphSubstitution |
+ Ci.mozITXTToHTMLConv.kURLs;
+ html = lazy.gTextToHtmlConverter.scanTXT(text, mode);
+ html = html.replace(/\r?\n/g, "<br>");
+ }
+
+ // Sanitize and convert the HTML into a document fragment.
+ let flags =
+ lazy.gParserUtils.SanitizerLogRemovals |
+ lazy.gParserUtils.SanitizerDropForms |
+ lazy.gParserUtils.SanitizerDropMedia;
+
+ let uri = Services.io.newURI(doc.baseURI);
+ return lazy.gParserUtils.parseFragment(html, flags, false, uri, doc.createElement("div"));
+ },
+
+ /**
+ * Correct the description of a Google Calendar item so that it will display
+ * as intended.
+ *
+ * @param {calIItemBase} item - The item to correct.
+ */
+ fixGoogleCalendarDescription(item) {
+ // Google Calendar inserts bare HTML into its description field instead of
+ // using the standard Alternate Text Representation mechanism. However,
+ // the HTML is a poor representation of how it displays descriptions on
+ // the site: links may be included as bare URLs and line breaks may be
+ // included as raw newlines, so in order to display descriptions as Google
+ // intends, we need to make some corrections.
+ if (item.descriptionText) {
+ // Convert HTML entities which scanHTML won't handle into their standard
+ // text representation.
+ let description = item.descriptionText.replace(/&#?\w+;?/g, potentialEntity => {
+ // Attempt to parse the pattern match as an HTML entity.
+ let body = new DOMParser().parseFromString(potentialEntity, "text/html").body;
+
+ // Don't replace text that didn't parse as an entity or that parsed as
+ // an entity which could break HTML parsing below.
+ return body.innerText.length == 1 && !'"&<>'.includes(body.innerText)
+ ? body.innerText
+ : potentialEntity;
+ });
+
+ // Replace bare URLs with links and convert remaining entities.
+ description = lazy.gTextToHtmlConverter.scanHTML(description, Ci.mozITXTToHTMLConv.kURLs);
+
+ // Setting the HTML description will mark the item dirty, but we want to
+ // avoid unnecessary updates; preserve modification time.
+ let stamp = item.stampTime;
+ let lastModified = item.lastModifiedTime;
+
+ item.descriptionHTML = description.replace(/\r?\n/g, "<br>");
+
+ // Restore modification time.
+ item.setProperty("DTSTAMP", stamp);
+ item.setProperty("LAST-MODIFIED", lastModified);
+ }
+ },
+};
+
+/**
+ * Adds CSS variables for each calendar to registered windows for coloring
+ * UI elements. Automatically tracks calendar creation, changes, and deletion.
+ */
+calview.colorTracker = {
+ calendars: null,
+ categoryBranch: null,
+ windows: new Set(),
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver", "calIObserver"]),
+
+ // Deregistration is not required.
+ registerWindow(aWindow) {
+ if (this.calendars === null) {
+ this.calendars = new Set(lazy.cal.manager.getCalendars());
+ lazy.cal.manager.addObserver(this);
+ lazy.cal.manager.addCalendarObserver(this);
+
+ this.categoryBranch = Services.prefs.getBranch("calendar.category.color.");
+ this.categoryBranch.addObserver("", this);
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ }
+
+ this.windows.add(aWindow);
+ aWindow.addEventListener("unload", () => this.windows.delete(aWindow));
+
+ this.addColorsToDocument(aWindow.document);
+ },
+ addColorsToDocument(aDocument) {
+ for (let calendar of this.calendars) {
+ this._addCalendarToDocument(aDocument, calendar);
+ }
+ this._addAllCategoriesToDocument(aDocument);
+ },
+
+ _addCalendarToDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ let backColor = aCalendar.getProperty("color") || "#a8c2e1";
+ let foreColor = calview.getContrastingTextColor(backColor);
+ style.setProperty(`--calendar-${cssSafeId}-backcolor`, backColor);
+ style.setProperty(`--calendar-${cssSafeId}-forecolor`, foreColor);
+ },
+ _removeCalendarFromDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ style.removeProperty(`--calendar-${cssSafeId}-backcolor`);
+ style.removeProperty(`--calendar-${cssSafeId}-forecolor`);
+ },
+ _addCategoryToDocument(aDocument, aCategoryName) {
+ // aCategoryName should already be formatted for CSS, because that's
+ // what is stored in the prefs, and this function is only called with
+ // arguments that come from the prefs.
+ if (/[^\w-]/.test(aCategoryName)) {
+ return;
+ }
+
+ let style = aDocument.documentElement.style;
+ let color = this.categoryBranch.getStringPref(aCategoryName, "");
+ if (color == "") {
+ // Don't use the getStringPref default, the value might actually be ""
+ // and we don't want that.
+ color = "transparent";
+ }
+ style.setProperty(`--category-${aCategoryName}-color`, color);
+ },
+ _addAllCategoriesToDocument(aDocument) {
+ for (let categoryName of this.categoryBranch.getChildList("")) {
+ this._addCategoryToDocument(aDocument, categoryName);
+ }
+ },
+
+ // calICalendarManagerObserver methods
+ onCalendarRegistered(aCalendar) {
+ this.calendars.add(aCalendar);
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarUnregistering(aCalendar) {
+ this.calendars.delete(aCalendar);
+ for (let window of this.windows) {
+ this._removeCalendarFromDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarDeleting(aCalendar) {},
+
+ // calIObserver methods
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(aItem) {},
+ onModifyItem(aNewItem, aOldItem) {},
+ onDeleteItem(aDeletedItem) {},
+ onError(aCalendar, aErrNo, aMessage) {},
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ if (aName == "color") {
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ }
+ },
+ onPropertyDeleting(aCalendar, aName) {},
+
+ // nsIObserver method
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ for (let window of this.windows) {
+ this._addCategoryToDocument(window.document, aData);
+ }
+ // TODO Currently, the only way to find out if categories are removed is
+ // to initially grab the calendar.categories.names preference and then
+ // observe changes to it. It would be better if we had hooks for this.
+ } else if (aTopic == "xpcom-shutdown") {
+ this.categoryBranch.removeObserver("", this);
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ }
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calWindowUtils.jsm b/comm/calendar/base/modules/utils/calWindowUtils.jsm
new file mode 100644
index 0000000000..21626d9ef0
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calWindowUtils.jsm
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Calendar window helpers, e.g. to open our dialogs
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.window namespace.
+
+const EXPORTED_SYMBOLS = ["calwindow"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10nDeletePrompt",
+ () => new Localization(["calendar/calendar-delete-prompt.ftl"], true)
+);
+
+var calwindow = {
+ /**
+ * Opens the Create Calendar wizard
+ *
+ * @param aWindow the window to open the dialog on, or null for the main calendar window
+ * @param aCallback a function to be performed after calendar creation
+ */
+ openCalendarWizard(aWindow, aCallback) {
+ let window = aWindow || calwindow.getCalendarWindow();
+ window.openDialog(
+ "chrome://calendar/content/calendar-creation.xhtml",
+ "caEditServer",
+ "chrome,titlebar,resizable,centerscreen",
+ aCallback
+ );
+ },
+
+ /**
+ * @typedef {object} OpenCalendarPropertiesArgs
+ * @property {calICalendar} calendar - The calendar whose properties should be displayed.
+ * @property {boolean} [canDisable=true] - Whether the user can disable the calendar.
+ */
+
+ /**
+ * Opens the calendar properties window for aCalendar.
+ *
+ * @param {ChromeWindow | null} aWindow The window to open the dialog on,
+ * or null for the main calendar window.
+ * @param {OpenCalendarPropertiesArgs} args - Passed directly to the window.
+ */
+ openCalendarProperties(aWindow, args) {
+ let window = aWindow || calwindow.getCalendarWindow();
+ window.openDialog(
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ "CalendarPropertiesDialog",
+ "chrome,titlebar,resizable,centerscreen",
+ { canDisable: true, ...args }
+ );
+ },
+
+ /**
+ * Returns the most recent calendar window in an application independent way
+ */
+ getCalendarWindow() {
+ return (
+ Services.wm.getMostRecentWindow("calendarMainWindow") ||
+ Services.wm.getMostRecentWindow("mail:3pane")
+ );
+ },
+
+ /**
+ * Open (or focus if already open) the calendar tab, even if the imip bar is
+ * in a message window, and even if there is no main three pane Thunderbird
+ * window open. Called when clicking the imip bar's calendar button.
+ */
+ goToCalendar() {
+ let openCal = mainWindow => {
+ mainWindow.focus();
+ mainWindow.document.getElementById("tabmail").openTab("calendar");
+ };
+
+ let mainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ if (mainWindow) {
+ openCal(mainWindow);
+ } else {
+ mainWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar",
+ null
+ );
+
+ // Wait until calendar is set up in the new window.
+ let calStartupObserver = {
+ observe(subject, topic, data) {
+ openCal(mainWindow);
+ Services.obs.removeObserver(calStartupObserver, "calendar-startup-done");
+ },
+ };
+ Services.obs.addObserver(calStartupObserver, "calendar-startup-done");
+ }
+ },
+
+ /**
+ * Brings up a dialog prompting the user about the deletion of the passed
+ * item(s).
+ *
+ * @param {calIItemBase|calItemBase[]} items - One or more items that will be deleted.
+ * @param {boolean} byPassPref - If true the pref for this prompt will be ignored.
+ *
+ * @returns {boolean} True if the user confirms deletion, false if otherwise.
+ */
+ promptDeleteItems(items, byPassPref) {
+ items = Array.isArray(items) ? items : [items];
+ let pref = Services.prefs.getBoolPref("calendar.item.promptDelete", true);
+
+ // Recurring events will be handled by the recurring event prompt.
+ if ((!pref && !byPassPref) || items.some(item => item.parentItem != item)) {
+ return true;
+ }
+
+ let deletingEvents;
+ let deletingTodos;
+ for (let item of items) {
+ if (!deletingEvents) {
+ deletingEvents = item.isEvent();
+ }
+ if (!deletingTodos) {
+ deletingTodos = item.isTodo();
+ }
+ }
+
+ let title;
+ let message;
+ let disableMessage;
+ if (deletingEvents && !deletingTodos) {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-event-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-event-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ } else if (!deletingEvents && deletingTodos) {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-task-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-task-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ } else {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-items-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-items-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ }
+
+ if (byPassPref) {
+ return Services.prompt.confirm(null, title, message);
+ }
+
+ let checkResult = { value: false };
+ let result = Services.prompt.confirmEx(
+ null,
+ title,
+ message,
+ Services.prompt.STD_YES_NO_BUTTONS + Services.prompt.BUTTON_DELAY_ENABLE,
+ null,
+ null,
+ null,
+ disableMessage,
+ checkResult
+ );
+
+ if (checkResult.value) {
+ Services.prefs.setBoolPref("calendar.item.promptDelete", false);
+ }
+ return result != 1;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calXMLUtils.jsm b/comm/calendar/base/modules/utils/calXMLUtils.jsm
new file mode 100644
index 0000000000..936a6c957e
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calXMLUtils.jsm
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Helper functions for parsing and serializing XML
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.xml namespace.
+
+const EXPORTED_SYMBOLS = ["calxml"];
+
+var calxml = {
+ /**
+ * Evaluate an XPath query for the given node. Be careful with the return value
+ * here, as it may be:
+ *
+ * - null, if there are no results
+ * - a number, string or boolean value
+ * - an array of strings or DOM elements
+ *
+ * @param aNode The context node to search from
+ * @param aExpr The XPath expression to search for
+ * @param aResolver (optional) The namespace resolver to use for the expression
+ * @param aType (optional) Force a result type, must be an XPathResult constant
+ * @returns The result, see above for details.
+ */
+ evalXPath(aNode, aExpr, aResolver, aType) {
+ const XPR = {
+ // XPathResultType
+ ANY_TYPE: 0,
+ NUMBER_TYPE: 1,
+ STRING_TYPE: 2,
+ BOOLEAN_TYPE: 3,
+ UNORDERED_NODE_ITERATOR_TYPE: 4,
+ ORDERED_NODE_ITERATOR_TYPE: 5,
+ UNORDERED_NODE_SNAPSHOT_TYPE: 6,
+ ORDERED_NODE_SNAPSHOT_TYPE: 7,
+ ANY_UNORDERED_NODE_TYPE: 8,
+ FIRST_ORDERED_NODE_TYPE: 9,
+ };
+ let doc = aNode.ownerDocument ? aNode.ownerDocument : aNode;
+ let resolver = aResolver || doc.createNSResolver(doc.documentElement);
+ let resultType = aType || XPR.ANY_TYPE;
+
+ let result = doc.evaluate(aExpr, aNode, resolver, resultType, null);
+ let returnResult, next;
+ switch (result.resultType) {
+ case XPR.NUMBER_TYPE:
+ returnResult = result.numberValue;
+ break;
+ case XPR.STRING_TYPE:
+ returnResult = result.stringValue;
+ break;
+ case XPR.BOOLEAN_TYPE:
+ returnResult = result.booleanValue;
+ break;
+ case XPR.UNORDERED_NODE_ITERATOR_TYPE:
+ case XPR.ORDERED_NODE_ITERATOR_TYPE:
+ returnResult = [];
+ while ((next = result.iterateNext())) {
+ if (next.nodeType == next.TEXT_NODE || next.nodeType == next.CDATA_SECTION_NODE) {
+ returnResult.push(next.wholeText);
+ } else if (ChromeUtils.getClassName(next) === "Attr") {
+ returnResult.push(next.value);
+ } else {
+ returnResult.push(next);
+ }
+ }
+ break;
+ case XPR.UNORDERED_NODE_SNAPSHOT_TYPE:
+ case XPR.ORDERED_NODE_SNAPSHOT_TYPE:
+ returnResult = [];
+ for (let i = 0; i < result.snapshotLength; i++) {
+ next = result.snapshotItem(i);
+ if (next.nodeType == next.TEXT_NODE || next.nodeType == next.CDATA_SECTION_NODE) {
+ returnResult.push(next.wholeText);
+ } else if (ChromeUtils.getClassName(next) === "Attr") {
+ returnResult.push(next.value);
+ } else {
+ returnResult.push(next);
+ }
+ }
+ break;
+ case XPR.ANY_UNORDERED_NODE_TYPE:
+ case XPR.FIRST_ORDERED_NODE_TYPE:
+ returnResult = result.singleNodeValue;
+ break;
+ default:
+ returnResult = null;
+ break;
+ }
+
+ if (Array.isArray(returnResult) && returnResult.length == 0) {
+ returnResult = null;
+ }
+
+ return returnResult;
+ },
+
+ /**
+ * Convenience function to evaluate an XPath expression and return null or the
+ * first result. Helpful if you just expect one value in a text() expression,
+ * but its possible that there will be more than one. The result may be:
+ *
+ * - null, if there are no results
+ * - A string, number, boolean or DOM Element value
+ *
+ * @param aNode The context node to search from
+ * @param aExpr The XPath expression to search for
+ * @param aResolver (optional) The namespace resolver to use for the expression
+ * @param aType (optional) Force a result type, must be an XPathResult constant
+ * @returns The result, see above for details.
+ */
+ evalXPathFirst(aNode, aExpr, aResolver, aType) {
+ let result = calxml.evalXPath(aNode, aExpr, aResolver, aType);
+
+ if (Array.isArray(result)) {
+ return result[0];
+ }
+ return result;
+ },
+
+ /**
+ * Parse the given string into a DOM tree
+ *
+ * @param str The string to parse
+ * @returns The parsed DOM Document
+ */
+ parseString(str) {
+ let parser = new DOMParser();
+ parser.forceEnableXULXBL();
+ return parser.parseFromString(str, "application/xml");
+ },
+
+ /**
+ * Read an XML file synchronously. This method should be avoided, consider
+ * rewriting the caller to be asynchronous.
+ *
+ * @param uri The URI to read.
+ * @returns The DOM Document resulting from the file.
+ */
+ parseFile(uri) {
+ let req = new XMLHttpRequest();
+ req.open("GET", uri, false);
+ req.overrideMimeType("text/xml");
+ req.send(null);
+ return req.responseXML;
+ },
+
+ /**
+ * Serialize the DOM tree into a string.
+ *
+ * @param doc The DOM document to serialize
+ * @returns The DOM document as a string.
+ */
+ serializeDOM(doc) {
+ let serializer = new XMLSerializer();
+ return serializer.serializeToString(doc);
+ },
+
+ /**
+ * Escape a string for use in XML
+ *
+ * @param str The string to escape
+ * @param isAttribute If true, " and ' are also escaped
+ * @returns The escaped string
+ */
+ escapeString(str, isAttribute) {
+ return str.replace(/[&<>'"]/g, chr => {
+ switch (chr) {
+ case "&":
+ return "&amp;";
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case '"':
+ return isAttribute ? "&quot;" : chr;
+ case "'":
+ return isAttribute ? "&apos;" : chr;
+ default:
+ return chr;
+ }
+ });
+ },
+};
diff --git a/comm/calendar/base/moz.build b/comm/calendar/base/moz.build
new file mode 100644
index 0000000000..c8f8be24bd
--- /dev/null
+++ b/comm/calendar/base/moz.build
@@ -0,0 +1,45 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS = [
+ "public",
+ "src",
+ "modules",
+ "themes",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+JS_PREFERENCE_PP_FILES += [
+ "calendar.js",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ DEFINES["THEME"] = "windows"
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ DEFINES["THEME"] = "osx"
+else:
+ DEFINES["THEME"] = "linux"
+
+with Files("content/**"):
+ BUG_COMPONENT = ("Calendar", "Calendar Views")
+
+with Files("content/preferences/**"):
+ BUG_COMPONENT = ("Calendar", "Preferences")
+
+with Files("content/dialogs/**"):
+ BUG_COMPONENT = ("Calendar", "Dialogs")
+
+with Files("content/*task*"):
+ BUG_COMPONENT = ("Calendar", "Tasks")
+
+with Files("content/dialogs/*alarm*"):
+ BUG_COMPONENT = ("Calendar", "Alarms")
+
+with Files("content/widgets/*alarm*"):
+ BUG_COMPONENT = ("Calendar", "Alarms")
+
+with Files("themes/**"):
+ BUG_COMPONENT = ("Calendar", "Calendar Views")
diff --git a/comm/calendar/base/public/calIAlarm.idl b/comm/calendar/base/public/calIAlarm.idl
new file mode 100644
index 0000000000..4fe5f9c945
--- /dev/null
+++ b/comm/calendar/base/public/calIAlarm.idl
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIVariant;
+
+interface calIAttachment;
+interface calIAttendee;
+interface calIDateTime;
+interface calIDuration;
+interface calIItemBase;
+interface calIIcalComponent;
+
+[scriptable, uuid(b8db7c7f-c168-4e11-becb-f26c1c4f5f8f)]
+interface calIAlarm : nsISupports
+{
+ /**
+ * Returns true if this alarm is able to be modified
+ */
+ readonly attribute boolean isMutable;
+
+ /**
+ * Makes this alarm immutable.
+ */
+ void makeImmutable();
+
+ /**
+ * Make a copy of this alarm. The returned alarm will be mutable.
+ */
+ calIAlarm clone();
+
+ /**
+ * How this alarm is shown. Special values as described in rfc2445 are
+ * AUDIO, DISPLAY, EMAIL
+ * In addition, custom actions may be defined as an X-Prop, i.e
+ * X-SMS.
+ *
+ * Note that aside from setting this action, the frontend must be able to
+ * handle the specified action. Unknown actions WILL NOT be notified for.
+ */
+ attribute AUTF8String action;
+
+ /**
+ * The offset between the item's date and the alarm time.
+ * This will be null for absolute alarms.
+ */
+ attribute calIDuration offset;
+
+ /**
+ * The absolute date and time the alarm should fire.
+ * This will be null for relative alarms.
+ */
+ attribute calIDateTime alarmDate;
+
+ /**
+ * One of the ALARM_RELATED constants below.
+ */
+ attribute unsigned long related;
+
+ /**
+ * The alarm is absolute and is therefore not related to either.
+ */
+ const unsigned long ALARM_RELATED_ABSOLUTE = 0;
+
+ /**
+ * The alarm's offset should be based off of the startDate or
+ * entryDate (for events and tasks, respectively)
+ */
+ const unsigned long ALARM_RELATED_START = 1;
+
+ /**
+ * the alarm's offset should be based off of the endDate or
+ * dueDate (for events and tasks, respectively)
+ */
+ const unsigned long ALARM_RELATED_END = 2;
+
+ /**
+ * Times the alarm should be repeated. This value is the number of
+ * ADDITIONAL alarms, aside from the actual alarm.
+ *
+ * For the alarm to be valid, if repeat is specified, the repeatOffset
+ * attribute MUST also be specified.
+ */
+ attribute unsigned long repeat;
+
+ /**
+ * The duration between the alarm and each subsequent repeat
+ *
+ * For the alarm to be valid, if repeatOffset is specified, the repeat
+ * attribute MUST also be specified.
+ */
+ attribute calIDuration repeatOffset;
+
+ /**
+ * If repeat is specified, this helper returns the first DATETIME the alarm
+ * should be repeated on.
+ * This will be null for relative alarms.
+ */
+ readonly attribute calIDateTime repeatDate;
+
+ /**
+ * The description of the alarm. Not valid for AUDIO alarms.
+ */
+ attribute AUTF8String description;
+
+ /**
+ * The summary of the alarm. Not valid for AUDIO and DISPLAY alarms.
+ */
+ attribute AUTF8String summary;
+
+ /**
+ * Manage Attendee for this alarm. Not valid for AUDIO and DISPLAY alarms.
+ */
+ void addAttendee(in calIAttendee aAttendee);
+ void deleteAttendee(in calIAttendee aAttendee);
+ void clearAttendees();
+ Array<calIAttendee> getAttendees();
+
+ /**
+ * Manage Attachments for this alarm.
+ * For EMAIL alarms, more than one attachment can be specified.
+ * For AUDIO alarms, one Attachment can be specified.
+ * For DISPLAY alarms, attachments are invalid.
+ */
+ void addAttachment(in calIAttachment aAttachment);
+ void deleteAttachment(in calIAttachment aAttachment);
+ void clearAttachments();
+ Array<calIAttachment> getAttachments();
+
+ /**
+ * The human readable representation of this alarm. Uses locale strings.
+ *
+ * @param aItem The item to base the string on. Defaults to an event.
+ */
+ AUTF8String toString([optional] in calIItemBase aItem);
+
+ /**
+ * The ical representation of this VALARM
+ */
+ attribute AUTF8String icalString;
+
+ /**
+ * The ical component of this VALARM
+ */
+ attribute calIIcalComponent icalComponent;
+
+ // Property bag
+ boolean hasProperty(in AUTF8String name);
+ nsIVariant getProperty(in AUTF8String name);
+ void setProperty(in AUTF8String name, in nsIVariant value);
+ void deleteProperty(in AUTF8String name);
+
+ // Each inner array has two elements: a string and a nsIVariant.
+ readonly attribute Array<Array<jsval> > properties;
+};
diff --git a/comm/calendar/base/public/calIAlarmService.idl b/comm/calendar/base/public/calIAlarmService.idl
new file mode 100644
index 0000000000..4af77628db
--- /dev/null
+++ b/comm/calendar/base/public/calIAlarmService.idl
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calICalendar;
+interface calIDuration;
+interface calITimezone;
+interface calIAlarm;
+interface calIOperation;
+
+[scriptable,uuid(dc96dd04-d2dd-448e-b307-8c8ff39c72af)]
+interface calIAlarmServiceObserver : nsISupports
+{
+ /**
+ * Gets called when an alarm has fired. Depending on type of alarm, an
+ * observer could bring up a dialog or play a sound.
+ */
+ void onAlarm(in calIItemBase item, in calIAlarm alarm);
+
+ /**
+ * Gets called when an notification has fired. The notification is only
+ * controlled by prefs, and is independent from the alarm/reminder.
+ */
+ void onNotification(in calIItemBase item);
+
+ /**
+ * Called if alarm(s) of a specific item are to be removed from
+ * the alarm window.
+ *
+ * @param aItem corresponding item, maybe master item of recurring
+ * series (then all alarms belonging to this item are to
+ * be removed)
+ */
+ void onRemoveAlarmsByItem(in calIItemBase item);
+
+ /**
+ * Called if all alarms of a specific calendar are to be removed.
+ */
+ void onRemoveAlarmsByCalendar(in calICalendar calendar);
+
+ /**
+ * Called when all alarms of a specific calendar are loaded.
+ */
+ void onAlarmsLoaded(in calICalendar calendar);
+};
+
+[scriptable,uuid(42cfa9ce-49d6-11e5-b88c-5b90eedc1c47)]
+interface calIAlarmService : nsISupports
+{
+ /**
+ * Upper limit for the snooze period for an alarm. To avoid performance issues, don't change this
+ * to a value larger then 1 at least until bug 861594 or a similar concept is implemented.
+ */
+ const unsigned long MAX_SNOOZE_MONTHS = 1;
+
+ /**
+ * This is the timezone that all-day events will be converted to in order to
+ * determine when their alarms should fire.
+ */
+ attribute calITimezone timezone;
+
+ /**
+ * Will return true while the alarm service is in the process of loading alarms
+ */
+ attribute boolean isLoading;
+
+ /**
+ * Cause the alarm service to start up, create a list of upcoming
+ * alarms in all registered calendars, add observers to watch for
+ * calendar registration and unregistration, and setup a timer to
+ * maintain that list and fire alarms.
+ *
+ * @note Will throw NS_ERROR_NOT_INITIALIZED if you have not previously set
+ * the timezone attribute.
+ */
+ void startup();
+
+ /**
+ * Shuts down the alarm service, canceling all timers and removing all
+ * alarms.
+ */
+ void shutdown();
+
+ /* add and remove observers that will be notified when an
+ alarm has gone off. It is up to the application to display
+ the alarm.
+ */
+ void addObserver(in calIAlarmServiceObserver observer);
+ void removeObserver(in calIAlarmServiceObserver observer);
+
+ /**
+ * Call to reschedule an alarm to be notified at a later point. The alarm will
+ * instead fire at "now + duration" This will cause an event to be scheduled
+ * even if it was not previously scheduled.
+ *
+ * @param item The item the alarm belongs to.
+ * @param alarm The alarm to snooze.
+ * @param duration The duration in minutes to snooze for.
+ * @return The operation that modifies the item to snooze the
+ * alarm.
+ */
+ calIOperation snoozeAlarm(in calIItemBase item, in calIAlarm alarm, in calIDuration duration);
+
+ /**
+ * Dismisses the given alarm for the passed occurrence.
+ *
+ * @param item The item the alarm belongs to.
+ * @param alarm The alarm to dismiss.
+ * @return The operation that modifies the item to dismiss the
+ * alarm.
+ */
+ calIOperation dismissAlarm(in calIItemBase item, in calIAlarm alarm);
+};
diff --git a/comm/calendar/base/public/calIAttachment.idl b/comm/calendar/base/public/calIAttachment.idl
new file mode 100644
index 0000000000..7ca054d5a2
--- /dev/null
+++ b/comm/calendar/base/public/calIAttachment.idl
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface calIIcalProperty;
+interface calIItemBase;
+
+[scriptable,uuid(7a17d45d-1c0e-4877-baa3-0eb67e770498)]
+interface calIAttachment : nsISupports
+{
+ /**
+ * The hash id is used to identify this attachment and compare it to others.
+ */
+ readonly attribute AUTF8String hashId;
+
+ /**
+ * An nsIURI object that points to the file (local or remote)
+ */
+ attribute nsIURI uri;
+
+ /**
+ * Raw attachment data, in case its not an uri
+ */
+ attribute AUTF8String rawData;
+
+ /**
+ * The type of file that this attachment refers to
+ */
+ attribute AString formatType;
+
+ /**
+ * The encoding the (local) file should be encoded with.
+ */
+ attribute AUTF8String encoding;
+
+ /**
+ * The calIIcalProperty corresponding to this object. Can be used for
+ * serializing/unserializing from ics files.
+ */
+ attribute calIIcalProperty icalProperty;
+ attribute AUTF8String icalString;
+
+ /**
+ * For accessing additional parameters, such as x-params.
+ */
+ AUTF8String getParameter(in AString name);
+ void setParameter(in AString name, in AUTF8String value);
+ void deleteParameter(in AString name);
+
+ /**
+ * Clone this calIAttachment instance into a new object.
+ */
+ calIAttachment clone();
+};
diff --git a/comm/calendar/base/public/calIAttendee.idl b/comm/calendar/base/public/calIAttendee.idl
new file mode 100644
index 0000000000..ccbda1827e
--- /dev/null
+++ b/comm/calendar/base/public/calIAttendee.idl
@@ -0,0 +1,80 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIIcalProperty;
+
+[scriptable,uuid(73a074ad-8812-4055-af75-14b509b8c5fe)]
+interface calIAttendee : nsISupports
+{
+ readonly attribute boolean isMutable;
+
+ // makes this item immutable
+ void makeImmutable();
+
+ // clone always returns a mutable event
+ calIAttendee clone();
+
+ attribute AUTF8String id;
+ attribute AUTF8String commonName;
+ attribute AUTF8String rsvp;
+
+ /**
+ * If true, indicates that this is not a standard attendee, but rather this
+ * icalProperty corresponds to the organizer of the event (rfc2445 Sec 4.8.4.3)
+ */
+ attribute boolean isOrganizer;
+
+ /**
+ * CHAIR
+ * REQ-PARTICIPANT
+ * OPT-PARTICIPANT
+ * NON-PARTICIPANT
+ */
+ attribute AUTF8String role;
+
+ /**
+ * NEEDS-ACTION
+ * ACCEPTED
+ * DECLINED
+ * TENTATIVE
+ * DELEGATED
+ * COMPLETED
+ * IN-PROCESS
+ */
+ attribute AUTF8String participationStatus;
+
+ /**
+ * INDIVIDUAL
+ * GROUP
+ * RESOURCE
+ * ROOM
+ * UNKNOWN
+ */
+ attribute AUTF8String userType;
+
+ // Each inner array has two elements: a string and a nsIVariant.
+ readonly attribute Array<Array<jsval> > properties;
+
+ // If you use the has/get/set/deleteProperty
+ // methods, property names are case-insensitive.
+ //
+ // For purposes of ICS serialization, all property names in
+ // the hashbag are in uppercase.
+ AUTF8String getProperty(in AString name);
+ void setProperty(in AString name, in AUTF8String value);
+ void deleteProperty(in AString name);
+
+ attribute calIIcalProperty icalProperty;
+ attribute AUTF8String icalString;
+
+ /**
+ * The display name of the attendee. If the attendee has a common name, this
+ * is used. Otherwise, the attendee id is displayed (often an email), with the
+ * mailto: prefix dropped.
+ */
+ AUTF8String toString();
+};
diff --git a/comm/calendar/base/public/calICalendar.idl b/comm/calendar/base/public/calICalendar.idl
new file mode 100644
index 0000000000..8366dbaf38
--- /dev/null
+++ b/comm/calendar/base/public/calICalendar.idl
@@ -0,0 +1,605 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+// decls for stuff from other files
+interface nsIURI;
+interface calIItemBase;
+interface nsIVariant;
+
+// forward decls for this file
+interface calICalendarACLManager;
+interface calICalendarACLEntry;
+interface calIObserver;
+interface calIOperationListener;
+interface calIRange;
+interface calISchedulingSupport;
+interface calIDateTime;
+interface calIOperation;
+interface calIStatusObserver;
+interface nsIDOMChromeWindow;
+
+
+[scriptable, uuid(b18782c0-6557-4e8e-931d-4bf052f0a31e)]
+interface calICalendar : nsISupports
+{
+ /**
+ * Unique ID of this calendar. Only the calendar manager is allowed to set
+ * this attribute. For everybody else, it should be considered to be
+ * read-only.
+ * The id is null for unregistered calendars.
+ */
+ attribute AUTF8String id;
+
+ /**
+ * Name of the calendar
+ * Notes: Can only be set after the calendar is registered with the calendar manager.
+ */
+ attribute AUTF8String name;
+
+ /**
+ * Type of the calendar
+ * 'memory', 'storage', 'caldav', etc
+ */
+ readonly attribute AUTF8String type;
+
+ /**
+ * If this calendar is provided by an extension, this attribute should return
+ * the extension's id, otherwise null.
+ */
+ readonly attribute AString providerID;
+
+ /**
+ * Returns the acl manager for the calendar, based on the "aclManagerClass"
+ * property. If this property is not defined, the default manager is used
+ */
+ readonly attribute calICalendarACLManager aclManager;
+
+ /**
+ * Returns the acl entry associated to the calendar.
+ */
+ readonly attribute calICalendarACLEntry aclEntry;
+
+ /**
+ * Multiple calendar instances may be composited, logically acting as a
+ * single calendar, e.g. for caching puorposing.
+ * This attribute determines the topmost calendar that returned items should
+ * belong to. If the current instance is the topmost calendar, then it should
+ * be returned directly.
+ *
+ * @see calIItemBase::calendar
+ */
+ attribute calICalendar superCalendar;
+
+ /**
+ * Setting this URI causes the calendar to be (re)loaded.
+ * This is not an unique identifier! It is also not unchangeable. Don't
+ * use it to identify a calendar, use the id attribute for that purpose.
+ */
+ attribute nsIURI uri;
+
+ /**
+ * Is this calendar read-only? Used by the UI to decide whether or not
+ * widgetry should allow editing.
+ */
+ attribute boolean readOnly;
+
+ /**
+ * Whether or not it makes sense to call refresh() on this calendar.
+ */
+ readonly attribute boolean canRefresh;
+
+ /**
+ * Setting this attribute to true will prevent the calendar to make calendar properties
+ * persistent, which is useful if you would like to set properties on unregistered
+ * calendar instances.
+ */
+ attribute boolean transientProperties;
+
+ /**
+ * Gets a calendar property.
+ * The call returns null in case the property is not known;
+ * callers should use a sensible default in that case.
+ *
+ * It's up to the provider where to store properties,
+ * e.g. on the server or in local prefs.
+ *
+ * Currently known properties are:
+ * [boolean] disabled
+ * [boolean] auto-enabled If true, the calendar will be enabled on next startup.
+ * [boolean] force-disabled If true, the calendar cannot be enabled (transient).
+ * [boolean] calendar-main-in-composite
+ * [string] name
+ * [boolean] readOnly
+ * [boolean] requiresNetwork If false, the calendar does not require
+ * network access at all. This is mainy used
+ * as a UI hint.
+ * [boolean] suppressAlarms If true, alarms of this calendar are not minded.
+ * [boolean] cache.supported If true, the calendar should to be cached,
+ * e.g. this generally applies to network calendars;
+ * default is true (if not present).
+ * [boolean] cache.enabled If true, the calendar is cached; default is false.
+ * [boolean] cache.always If true, the cache will always be enabled
+ * and the user cannot turn it off. For
+ * backward compatibility, return true for
+ * cache.enabled too.
+ *
+ * [nsresult] currentStatus The current error status of the calendar (transient).
+ *
+ * [calIItipTransport] itip.transport If the provider implements a custom calIItipTransport (transient)
+ * If null, then Email Scheduling will effectively be
+ * disabled. This means for example, the calendar will
+ * not show up in the list of calendars to store an
+ * invitation in.
+ * [boolean] itip.disableRevisionChecks If true, the iTIP handling code disables revision checks
+ * against SEQUENCE and DTSTAMP, and will never reject an
+ * iTIP message as outdated
+ * [nsIMsgIdentity] imip.identity If provided, this is the email identity used for
+ * scheduling purposes
+ * [boolean] imip.identity.disabled If true, this calendar doesn't support switching imip
+ * identities. This for example means that the
+ * dropdown of identities will not be shown in the
+ * calendar properties dialog. (transient)
+ * scheduling purposes
+ * [nsIMsgAccount] imip.account If provided, this is the email account used for
+ * scheduling purposes
+ * [string] imip.identity.key If provided, this is the email internal identity key used to
+ * get the above
+ *
+ * [string] organizerId If provided, this is the preset organizer id on creating
+ * scheduling appointments (transient)
+ * [string] organizerCN If provided, this is the preset organizer common name on creating
+ * scheduling appointments (transient)
+ *
+ * The following calendar capabilities can be used to inform the UI or backend
+ * that certain features are not supported. If not otherwise mentioned, not
+ * specifying these capabilities assumes a default value of true
+ * capabilities.alarms.popup.supported Supports popup alarms
+ * capabilities.alarms.oninviations.supported Supports alarms on inviations.
+ * capabilities.alarms.maxCount Maximum number of alarms supported per event
+ * capabilities.attachments.supported Supports attachments
+ * capabilities.categories.maxCount Maximum number of supported categories.
+ * -1 means infinite, 0 means disabled.
+ * capabilities.privacy.supported Supports a privacy state
+ * capabilities.priority.supported Supports the priority field
+ * capabilities.events.supported Supports tasks
+ * capabilities.tasks.supported Supports events
+ * capabilities.timezones.floating.supported Supports local time
+ * capabilities.timezones.UTC.supported Supports UTC/GMT timezone
+ * capabilities.autoschedule.supported Supports caldav schedule properties in
+ * icalendar (SCHEDULE-AGENT, SCHEDULE-STATUS...)
+ *
+ * The following capabilities are used to restrict the values for specific
+ * fields. An array should be specified with the values, the default
+ * values are specified here. Extensions using this need to take care of
+ * adding any UI elements needed in an overlay. To make sure the correct
+ * elements are shown, those elements should additionally specify an attribute
+ * "provider", with the type of the provider.
+ *
+ * capabilities.privacy.values = ["PUBLIC", "CONFIDENTIAL", "PRIVATE"];
+ *
+ * The following special capability disables rewriting the WWW-Authenticate
+ * header on HTTP requests to include the calendar name. The default value
+ * is false, i.e rewriting is NOT disabled.
+ *
+ * capabilities.realmrewrite.disabled = false
+ *
+ * The following capability describes if the calendar can be permanently
+ * deleted, or just unsubscribed. If this property is not specified, then
+ * only unsubscribing is allowed. If an empty array is specified, neither
+ * deleting nor unsubscribing is presented in the UI.
+ *
+ * capabilities.removeModes = ["delete", "unsubscribe"]
+ *
+ * @param aName property name
+ * @return value (string, integer and boolean values are supported),
+ * else null
+ */
+ nsIVariant getProperty(in AUTF8String aName);
+
+ /**
+ * Sets a calendar property.
+ * This will (only) cause a notification onPropertyChanged() in case
+ * the value has changed.
+ *
+ * It's up to the provider where to store properties,
+ * e.g. on the server or in local prefs.
+ *
+ * @param aName property name
+ * @param aValue value
+ * (string, integer and boolean values are supported)
+ */
+ void setProperty(in AUTF8String aName, in nsIVariant aValue);
+
+ /**
+ * Deletes a calendar property.
+ *
+ * It's up to the provider where to store properties,
+ * e.g. on the server or in local prefs.
+ *
+ * @param aName property name
+ */
+ void deleteProperty(in AUTF8String aName);
+
+ /**
+ * In combination with the other parameters to getItems(), these
+ * constants provide for a very basic filtering mechanisms for use
+ * in getting and observing items. At some point fairly soon, we're
+ * going to need to generalize this mechanism significantly (so we
+ * can allow boolean logic, categories, etc.).
+ *
+ * When adding item filters (bits which, when not set to 1, reduce the
+ * scope of the results), use bit positions <= 15, so that
+ * ITEM_FILTER_ALL_ITEMS remains compatible for components that have the
+ * constant compiled in.
+ *
+ * XXX the naming here is questionable; adding a filter (setting a bit, in
+ * this case) usually _reduces_ the set of items that pass the set of
+ * filters, rather than adding to it.
+ */
+ const unsigned long ITEM_FILTER_COMPLETED_YES = 1 << 0;
+ const unsigned long ITEM_FILTER_COMPLETED_NO = 1 << 1;
+ const unsigned long ITEM_FILTER_COMPLETED_ALL = (ITEM_FILTER_COMPLETED_YES |
+ ITEM_FILTER_COMPLETED_NO);
+
+ const unsigned long ITEM_FILTER_TYPE_TODO = 1 << 2;
+ const unsigned long ITEM_FILTER_TYPE_EVENT = 1 << 3;
+ const unsigned long ITEM_FILTER_TYPE_JOURNAL = 1 << 4;
+ const unsigned long ITEM_FILTER_TYPE_ALL = (ITEM_FILTER_TYPE_TODO |
+ ITEM_FILTER_TYPE_EVENT |
+ ITEM_FILTER_TYPE_JOURNAL);
+
+ const unsigned long ITEM_FILTER_ALL_ITEMS = 0xFFFF;
+
+ /**
+ * If set, return calIItemBase occurrences for all the appropriate instances,
+ * as determined by an item's recurrenceInfo. All of these occurrences will
+ * have their parentItem set to the recurrence parent. If not set, will
+ * return only calIItemBase parent items.
+ */
+ const unsigned long ITEM_FILTER_CLASS_OCCURRENCES = 1 << 16;
+
+ /**
+ * Scope: Attendee
+ * Filter items that correspond to an invitation from another
+ * user and the current user has not replied to it yet.
+ */
+ const unsigned long ITEM_FILTER_REQUEST_NEEDS_ACTION = 1 << 17;
+
+ /**
+ * Flags for items that have been created, modified or deleted while
+ * offline.
+ * ITEM_FILTER_OFFLINE_DELETED is a particular case in that elements *must*
+ * be excluded from searches when not specified in the filter mask.
+ */
+ const unsigned long ITEM_FILTER_OFFLINE_CREATED = 1 << 29;
+ const unsigned long ITEM_FILTER_OFFLINE_MODIFIED = 1 << 30;
+ const unsigned long ITEM_FILTER_OFFLINE_DELETED = 1 << 31;
+
+ void addObserver( in calIObserver observer );
+ void removeObserver( in calIObserver observer );
+
+ /**
+ * supportsScheduling indicates whether the calendar implements the
+ * calISchedulingSupport interface.
+ */
+ readonly attribute boolean supportsScheduling;
+
+ /**
+ * getSchedulingSupport provides a calISchedulingSupport implementation for
+ * calendars that support it.
+ */
+ calISchedulingSupport getSchedulingSupport();
+
+ /**
+ * addItem adds the given calIItemBase to the calendar.
+ *
+ * @param aItem item to add
+ * @return optional operation handle to track the operation
+ *
+ * - If aItem already has an ID, that ID is used when adding.
+ * - If aItem is mutable and has no ID, the calendar is expected
+ * to generate an ID for the item.
+ * - If aItem is immutable and has no ID, an error is thrown.
+ *
+ * The result of the operation is the calIItemBase corresponding to the
+ * immutable version of the newly added item.
+ *
+ * If an item with a given ID already exists in the calendar, the Promise is
+ * rejected with a NS_ERROR_XXXXX error.
+ *
+ * @return {Promise<calIItemBase>}
+ */
+ Promise addItem(in calIItemBase aItem);
+
+ /**
+ * adoptItem adds the given calIItemBase to the calendar, but doesn't
+ * clone it. It adopts the item as-is. This is generally for use in
+ * performance-critical situations where there is no danger of the caller
+ * using the item after making the call.
+ *
+ * @see addItem
+ *
+ * @return {Promise<calIItemBase>}
+ */
+ Promise adoptItem(in calIItemBase aItem);
+
+ /**
+ * modifyItem takes a modified item and modifies the
+ * calendar's internal version of the item to match. The item is expected to
+ * be mutable and have an ID that already exists in the calendar. If it does
+ * not, the Promise is rejected with NS_ERROR_XXXXX.
+ *
+ * If the generation of the given aNewItem does not match the generation
+ * of the internal item (indicating that someone else modified the
+ * item), the Promise is rejected with NS_ERROR_XXXXX.
+ *
+ * If you would like to disable revision checks, pass null as aOldItem. This
+ * will overwrite the item on the server.
+ *
+ * @param aNewItem new version to replace the old one
+ * @param aOldItem caller's view of the item to be changed, as it is now
+ * @return {Promise<calIItemBase>} the newly-updated immutable version of
+ * the modified item.
+ *
+ */
+ Promise modifyItem(in calIItemBase aNewItem, in calIItemBase aOldItem);
+
+ /**
+ * Deletes an item. The item is expected to have an ID that already exists in
+ * the calendar.
+ *
+ * @param aItem item to delete
+ * @return {Promise<void>} optional operation handle to track the operation
+ */
+ Promise deleteItem(in calIItemBase aItem);
+
+ /**
+ * Get a single event. The event will be typed as one of the subclasses
+ * of calIItemBase (whichever concrete type is most appropriate).
+ *
+ * @param aId UID of the event
+ * @return {Promise<calIItemBase|null>} A Promise that is resolved with the item if found.
+ */
+ Promise getItem(in string aId);
+
+ /**
+ * XXX As mentioned above, this method isn't suitably general. It's just
+ * placeholder until it gets supplanted by something more SQL or RDF-like.
+ *
+ * Ordering: This method is currently guaranteed to return lists ordered
+ * as follows to make for the least amount of pain when
+ * migrating existing frontend code:
+ *
+ * The events are sorted based on the order of their next occurrence
+ * if they recur in the future or their last occurrence in the past
+ * otherwise. Here's a presentation of the sort criteria using the
+ * time axis:
+ *
+ * -----(Last occurrence of Event1)---(Last occurrence of Event2)----(Now)----(Next occurrence of Event3)---->
+ *
+ * (Note that Event1 and Event2 will not recur in the future.)
+ *
+ * We should probably be able get rid of this ordering constraint
+ * at some point in the future.
+ *
+ * Note that the range is intended to act as a mask on the
+ * occurrences, not just the initial recurring items. So if a
+ * getItems() call without ITEM_FILTER_CLASS_occurrenceS is made, all
+ * events and todos which have occurrences inside the range should
+ * be returned, even if some of those events or todos themselves
+ * live outside the range.
+ *
+ * @param aItemFilter ITEM_FILTER flags, or-ed together
+ * @param aCount Maximum number of items to return, or 0 for
+ * an unbounded query.
+ * @param aRangeStart Items starting at this time or after should be
+ * returned. If invalid, assume "since the beginning
+ * of time".
+ * @param aRangeEndEx Items starting before (not including) aRangeEndEx should be
+ * returned. If null, assume "until the end of time".
+ * @return {ReadableStream<calIItemBase>}
+ */
+ jsval getItems(in unsigned long aItemFilter,
+ in unsigned long aCount,
+ in calIDateTime aRangeStart,
+ in calIDateTime aRangeEndEx);
+
+ /**
+ * Similar to getItems() but returns all results in a single array.
+ * @param aItemFilter ITEM_FILTER flags, or-ed together
+ * @param aCount Maximum number of items to return, or 0 for
+ * an unbounded query.
+ * @param aRangeStart Items starting at this time or after should be
+ * returned. If invalid, assume "since the beginning
+ * of time".
+ * @param aRangeEndEx Items starting before (not including) aRangeEndEx should be
+ * returned. If null, assume "until the end of time".
+ * @return {Promise<calIItemBase[]>}
+ */
+ Promise getItemsAsArray(in unsigned long aItemFilter,
+ in unsigned long aCount,
+ in calIDateTime aRangeStart,
+ in calIDateTime aRangeEndEx);
+
+ /**
+ * Refresh the datasource, and call the observers for any changes found.
+ * If the provider doesn't know the details of the changes it must call
+ * onLoad on its observers.
+ *
+ * @return optional operation handle to track the operation
+ */
+ calIOperation refresh();
+
+ /**
+ * Turn on batch mode. Observers will get a notification of this.
+ * They will still get notified for every individual change, but they are
+ * free to ignore those notifications.
+ * Use this when a lot of changes are about to happen, and it would be
+ * useless to refresh the display (or the backend store) for every change.
+ * Caller must make sure to also call endBatchMode. Make sure all errors
+ * are caught!
+ */
+ void startBatch();
+
+ /**
+ * Turn off batch mode.
+ */
+ void endBatch();
+};
+
+/**
+ * Used to allow multiple calendars (eg work and home) to be easily queried
+ * and displayed as a single unit. All calendars are referenced by ID, i.e.
+ * calendars need to have an ID when being added.
+ */
+[scriptable, uuid(6748fa00-79b5-4728-84f3-20dd47e0b031)]
+interface calICompositeCalendar : calICalendar
+{
+ /**
+ * Adds a calendar to the composite, if not already part of it.
+ *
+ * @param aCalendar the calendar to be added
+ */
+ void addCalendar(in calICalendar aCalendar);
+
+ /**
+ * Remove a calendar from the composite
+ *
+ * @param aCalendar the calendar to be removed
+ */
+ void removeCalendar(in calICalendar aCalendar);
+
+ /**
+ * If a calendar for the given ID exists in the CompositeCalendar,
+ * return it; otherwise return null.
+ *
+ * @param aId id of calendar
+ * @return calendar, or null if none
+ */
+ calICalendar getCalendarById(in AUTF8String aId);
+
+ /* return a list of all calendars currently registered */
+ Array<calICalendar> getCalendars();
+
+ /**
+ * In order for addItem() to be called on this object, it is first necessary
+ * to set this attribute to specify which underlying calendar the item is
+ * to be added to.
+ */
+ attribute calICalendar defaultCalendar;
+
+ /**
+ * If set, the composite will initialize itself from calICalendarManager
+ * prefs keyed off of the provided prefPrefix, and update those prefs to
+ * track changes in calendar membership and default calendar.
+ */
+ attribute ACString prefPrefix;
+
+ /**
+ * If returns true there is a process running that needs to displayed
+ * by the statusObserver
+ */
+ readonly attribute boolean statusDisplayed;
+
+ /**
+ * Sets a statusobserver for status notifications like startMeteors() and StopMeteors().
+ */
+ void setStatusObserver(in calIStatusObserver aStatusObserver, in nsIDOMChromeWindow aWindow);
+};
+
+/**
+ * Make a more general nsIObserverService2 and friends to support
+ * nsISupports data and use that instead?
+ *
+ * NOTE: When adding methods here, please also add them in calUtils.jsm's
+ * createAdapter() method.
+ */
+[scriptable, uuid(2953c9b2-2c73-11d9-80b6-00045ace3b8d)]
+interface calIObserver : nsISupports
+{
+ void onStartBatch(in calICalendar aCalendar);
+ void onEndBatch(in calICalendar aCalendar);
+ void onLoad( in calICalendar aCalendar );
+ void onAddItem( in calIItemBase aItem );
+ void onModifyItem( in calIItemBase aNewItem, in calIItemBase aOldItem );
+ void onDeleteItem( in calIItemBase aDeletedItem );
+ void onError( in calICalendar aCalendar, in nsresult aErrNo, in AUTF8String aMessage );
+
+ /// Called after a property is changed.
+ void onPropertyChanged(in calICalendar aCalendar,
+ in AUTF8String aName,
+ in nsIVariant aValue,
+ in nsIVariant aOldValue);
+
+ /// Called before the property is deleted.
+ void onPropertyDeleting(in calICalendar aCalendar,
+ in AUTF8String aName);
+};
+
+/**
+ * calICompositeObserver interface adds things to observe changes to
+ * a calICompositeCalendar
+ */
+[scriptable, uuid(a3584c92-b8eb-4aa8-a638-e46a2e11d6a9)]
+interface calICompositeObserver : calIObserver
+{
+ void onCalendarAdded( in calICalendar aCalendar );
+ void onCalendarRemoved( in calICalendar aCalendar );
+ void onDefaultCalendarChanged( in calICalendar aNewDefaultCalendar );
+};
+
+/**
+ * Async operations are called back via this interface. If you know that your
+ * object is not going to get called back for either of these methods, having
+ * them return NS_ERROR_NOT_IMPLEMENTED is reasonable.
+ *
+ * NOTE: When adding methods here, please also add them in calUtils.jsm's
+ * createAdapter() method.
+ */
+[scriptable, uuid(ed3d87d8-2c77-11d9-8f5f-00045ace3b8d)]
+interface calIOperationListener : nsISupports
+{
+ /**
+ * For add, modify, and delete.
+ *
+ * @param aCalendar the calICalendar on which the operation took place
+ * @param aStatus status code summarizing what happened
+ * @param aOperationType type of operation that was completed
+ * @param aId UUID of element that was changed
+ * @param aDetail not yet fully specified. If aStatus is an error
+ * result, this will probably be an extended error
+ * string (eg one returned by a server).
+ */
+ void onOperationComplete(in calICalendar aCalendar,
+ in nsresult aStatus,
+ in unsigned long aOperationType,
+ in string aId,
+ in nsIVariant aDetail);
+ const unsigned long ADD = 1;
+ const unsigned long MODIFY = 2;
+ const unsigned long DELETE = 3;
+ const unsigned long GET = 4;
+
+ /**
+ * For getItem and getItems.
+ *
+ * @param aStatus status code summarizing what happened.
+ * @param aItemType type of interface returned in the array (@see
+ * calICalendar::GetItems).
+ * @param aDetail not yet fully specified. If aStatus is an error
+ * result, this will probably be an extended error
+ * string (eg one returned by a server).
+ * @param aItems array of immutable items
+ *
+ * Multiple onGetResults might be called
+ */
+ void onGetResult(in calICalendar aCalendar,
+ in nsresult aStatus,
+ in nsIIDRef aItemType,
+ in nsIVariant aDetail,
+ [iid_is(aItemType)] in Array<nsQIResult> aItems);
+};
diff --git a/comm/calendar/base/public/calICalendarACLManager.idl b/comm/calendar/base/public/calICalendarACLManager.idl
new file mode 100644
index 0000000000..21499fa93e
--- /dev/null
+++ b/comm/calendar/base/public/calICalendarACLManager.idl
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIMsgIdentity;
+interface nsIURI;
+
+interface calICalendar;
+interface calIItemBase;
+interface calIOperationListener;
+
+interface calIItemACLEntry;
+
+/**
+ */
+[scriptable, uuid(a64bd8a0-e9f0-4f64-928a-1c98861e4703)]
+interface calICalendarACLManager : nsISupports
+{
+ /* Gets the calICalendarACLEntry of the current user for the specified
+ calendar. */
+ void getCalendarEntry(in calICalendar aCalendar,
+ in calIOperationListener aListener);
+
+ /* Gets the calIItemACLEntry of the current user for the specified
+ calendar item. Depending on the implementation, each item can have
+ different permissions based on specific attributes.
+ (TODO: should be made asynchronous one day) */
+ calIItemACLEntry getItemEntry(in calIItemBase aItem);
+};
+
+[scriptable, uuid(f3da7954-52a4-45a9-bd7d-96c518133d0c)]
+interface calICalendarACLEntry : nsISupports
+{
+ /* The calICalendarACLManager instance that generated this entry. */
+ readonly attribute calICalendarACLManager aclManager;
+
+ /* Whether the underlying calendar does have access control. */
+ readonly attribute boolean hasAccessControl;
+
+ /* Whether the user accessing the calendar is its owner. */
+ readonly attribute boolean userIsOwner;
+
+ /* Whether the user accessing the calendar can add items to it. */
+ readonly attribute boolean userCanAddItems;
+
+ /* Whether the user accessing the calendar can remove items from it. */
+ readonly attribute boolean userCanDeleteItems;
+
+ /* Returns the list of user ids matching the user accessing the
+ calendar. */
+ Array<AString> getUserAddresses();
+
+ /* Returns the list of instantiated identities for the user accessing the
+ calendar. */
+ Array<nsIMsgIdentity> getUserIdentities();
+ /* Returns the list of instantiated identities for the user representing
+ the calendar owner. */
+ Array<nsIMsgIdentity> getOwnerIdentities();
+
+ /* Helper method that forces a cleanup of any cache and a reload of the
+ current entry.
+ (TODO: should be made asynchronous one day) */
+ void refresh();
+};
+
+[scriptable, uuid(4d0b7ced-8c57-4efa-87e7-8dd5b7481312)]
+interface calIItemACLEntry : nsISupports
+{
+ /* The parent calICalendarACLEntry instance. */
+ readonly attribute calICalendarACLEntry calendarEntry;
+
+ /* Whether the active user can fully modify the item. */
+ readonly attribute boolean userCanModify;
+
+ /* Whether the active user can respond to this item, if it is an invitation. */
+ readonly attribute boolean userCanRespond;
+
+ /* Whether the active user can view all the item properties. */
+ readonly attribute boolean userCanViewAll;
+
+ /* Whether the active user can only see when this item occurs without
+ knowing any details. */
+ readonly attribute boolean userCanViewDateAndTime;
+};
diff --git a/comm/calendar/base/public/calICalendarManager.idl b/comm/calendar/base/public/calICalendarManager.idl
new file mode 100644
index 0000000000..6eff2075a3
--- /dev/null
+++ b/comm/calendar/base/public/calICalendarManager.idl
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calICalendar;
+interface calIObserver;
+interface nsIURI;
+interface nsIVariant;
+
+interface calICalendarManagerObserver;
+
+[scriptable, uuid(fd8a2565-cb0f-4ecc-945d-760d75ab16d8)]
+interface calICalendarManager : nsISupports
+{
+ /**
+ * Gives the number of registered calendars that require network access.
+ */
+ readonly attribute uint32_t networkCalendarCount;
+
+ /***
+ * Gives the number of registered readonly calendars.
+ */
+ readonly attribute uint32_t readOnlyCalendarCount;
+
+ /**
+ * Gives the number of registered calendars
+ */
+ readonly attribute uint32_t calendarCount;
+
+ /**
+ * Register a calendar provider with the given JavaScript implementation. The implementation must
+ * implement calICalendar, and can optionally implement a number of other related interfaces.
+ * Please see the documentation for calICalendar for more details.
+ *
+ * @param {string} aType The calendar type string, see calICalendar::type.
+ * @param {Object} aImplementation The class that implements calICalendar.
+ */
+ void registerCalendarProvider(in AUTF8String aType, in jsval aImplementation);
+
+ /**
+ * Unregister a calendar provider by type. Already registered calendars will be replaced by a
+ * dummy calendar that is force-disabled.
+ *
+ * @param {string} aType The calendar type string, see calICalendar::type.
+ * @param {boolean} aTemporary If true, cached calendars will not be cleared.
+ */
+ void unregisterCalendarProvider(in AUTF8String aType, [optional] in boolean aTemporary);
+
+ /**
+ * Checks if a calendar provider has been dynamically registered with the given type. This does
+ * not check for the built-in XPCOM providers.
+ *
+ * @param {string} aType The calendar type string, see calICalendar::type.
+ */
+ boolean hasCalendarProvider(in AUTF8String aType);
+
+ /*
+ * create a new calendar
+ * aType is the type ("caldav", "storage", etc)
+ */
+ calICalendar createCalendar(in AUTF8String aType, in nsIURI aURL);
+
+ /* register a newly created calendar with the calendar service */
+ void registerCalendar(in calICalendar aCalendar);
+
+ /* unregister a calendar */
+ void unregisterCalendar(in calICalendar aCalendar);
+
+ /** Remove the calendar following the calendar's capabilities.removeModes. */
+ const unsigned short REMOVE_AUTO = 0;
+
+ /** Just unsubscribe from the calendar, do not delete it. */
+ const unsigned short REMOVE_NO_DELETE = 1;
+
+ /** Passing this flag will cause the call to fail if the calendar is registered */
+ const unsigned short REMOVE_NO_UNREGISTER = 2;
+
+ /**
+ * Unregister and delete the calendar from the calendar manager. By default
+ * the calendar will be removed based on the capabilities.removeModes
+ * property of the calendar.
+ *
+ * WARNING: If the calendar supports deletion, the calendar will be
+ * permanently deleted. You can prevent this with the REMOVE_NO_DELETE flag.
+ *
+ * @param aCalendar The calendar to remove.
+ * @param aMode A combination of the above mode flags.
+ */
+ void removeCalendar(in calICalendar aCalendar, [optional] in uint8_t aMode);
+
+ /* get a calendar by its id */
+ calICalendar getCalendarById(in AUTF8String aId);
+
+ /* return a list of all calendars currently registered */
+ Array<calICalendar> getCalendars();
+
+ /** Add an observer for the calendar manager, i.e when calendars are registered */
+ void addObserver(in calICalendarManagerObserver aObserver);
+ /** Remove an observer for the calendar manager */
+ void removeObserver(in calICalendarManagerObserver aObserver);
+
+ /** Add an observer to handle changes to all calendars (even disabled or unchecked ones) */
+ void addCalendarObserver(in calIObserver aObserver);
+ /** Remove an observer to handle changes to all calendars */
+ void removeCalendarObserver(in calIObserver aObserver);
+
+ /* XXX private, don't use:
+ will vanish as soon as providers will directly read/write from moz prefs
+ */
+ nsIVariant getCalendarPref_(in calICalendar aCalendar,
+ in AUTF8String aName);
+ void setCalendarPref_(in calICalendar aCalendar,
+ in nsIVariant aName,
+ in nsIVariant aValue);
+ void deleteCalendarPref_(in calICalendar aCalendar,
+ in AUTF8String aName);
+
+};
+
+/**
+ * Observer to handle actions done by the calendar manager
+ *
+ * NOTE: When adding methods here, please also add them in calUtils.jsm's
+ * createAdapter() method.
+ */
+[scriptable, uuid(383f36f1-e669-4ca4-be7f-06b43910f44a)]
+interface calICalendarManagerObserver : nsISupports
+{
+ /** Called after the calendar is registered */
+ void onCalendarRegistered(in calICalendar aCalendar);
+
+ /** Called before the unregister actually takes place */
+ void onCalendarUnregistering(in calICalendar aCalendar);
+
+ /** Called before the delete actually takes place */
+ void onCalendarDeleting(in calICalendar aCalendar);
+};
diff --git a/comm/calendar/base/public/calICalendarProvider.idl b/comm/calendar/base/public/calICalendarProvider.idl
new file mode 100644
index 0000000000..e6adf7a060
--- /dev/null
+++ b/comm/calendar/base/public/calICalendarProvider.idl
@@ -0,0 +1,89 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface calICalendar;
+interface nsIVariant;
+interface calIProviderListener;
+
+/**
+ * High-level interface to allow providers to be pluggable.
+ */
+[scriptable, uuid(30e22db4-9f13-11d9-80d6-000b7d081f44)]
+interface calICalendarProvider : nsISupports
+{
+ /**
+ * The type of provider, this may be used as a key to uniquely identify a
+ * provider and should match the type= part of the contract id of both the
+ * provider and the matching calICalendar implementation.
+ */
+ readonly attribute AUTF8String type;
+
+ /**
+ * The way to refer to this provider in UI for the end-user
+ * (eg "Shared ICS File").
+ */
+ readonly attribute AUTF8String displayName;
+
+ /**
+ * The way to refer to this provider in the UI when needing to display a short
+ * label to describe the type (eg "CalDAV", "ICS", etc.). This is different
+ * from the type attribute as it's not meant to be used as unique identifier
+ * but only as a shorter non-localized label.
+ */
+ readonly attribute AUTF8String shortName;
+
+ /**
+ * Delete a calendar. Deletes the actual underlying calendar, which
+ * could be (for example) a file or a calendar on a server
+ *
+ * @param aCalendar the calendar to delete
+ * @param aListener where to call the results back to
+ */
+ void deleteCalendar(in calICalendar aCalendar,
+ in calIProviderListener aListener);
+
+ /**
+ * Detect calendars using the given parameters (location, username, etc.).
+ *
+ * @param username The username to use.
+ * @param password The password to use.
+ * @param location The location to use. It could be a hostname, a
+ * specific URL, the origin URL, etc.
+ * @param savePassword Whether to save the password or not.
+ * @param extraProperties Any additional properties needed.
+ * @return Promise resolved with an array of found calendars.
+ * (Array<calICalendar>). If no calendars were
+ * found, resolved with an empty array. If an
+ * error occurs, rejected with the error.
+ */
+ Promise detectCalendars(in AUTF8String username, in AUTF8String password,
+ in AUTF8String location, in boolean savePassword,
+ [optional] in jsval extraProperties);
+};
+
+[scriptable, uuid(0eebe99e-a22d-11d9-87a6-000b7d081f44)]
+interface calIProviderListener : nsISupports
+{
+ /**
+ * @param aStatus status code summarizing what happened
+ * @param aDetail not yet fully specified. If aStatus is an error
+ * result, this will probably be an extended error
+ * string (eg one returned by a server).
+ */
+ void onCreateCalendar(in calICalendar aCalendar, in nsresult aStatus,
+ in nsIVariant aDetail);
+
+ /**
+ * @param aStatus status code summarizing what happened
+ * @param aDetail not yet fully specified. If aStatus is an error
+ * result, this will probably be an extended error
+ * string (eg one returned by a server).
+ */
+ void onDeleteCalendar(in calICalendar aCalendar, in nsresult aStatus,
+ in nsIVariant aDetail);
+};
diff --git a/comm/calendar/base/public/calICalendarView.idl b/comm/calendar/base/public/calICalendarView.idl
new file mode 100644
index 0000000000..d9024d21e9
--- /dev/null
+++ b/comm/calendar/base/public/calICalendarView.idl
@@ -0,0 +1,216 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+
+interface calICalendar;
+interface calIDateTime;
+interface calICalendarViewController;
+interface calIItemBase;
+
+/**
+ * An interface for view widgets containing calendaring data.
+ *
+ * @note Code that implements this interface is intended to be pure
+ * widgetry and thus not have any preference dependencies.
+ */
+
+[scriptable, uuid(0e392744-4b2e-4b64-8862-2fb707d900a7)]
+interface calICalendarView : nsISupports
+{
+
+ /**
+ * Oftentimes other elements in the DOM in which a calIDecoratedView is
+ * used want to be aware of whether or not the view is selected. An element
+ * whose ID is observerID can be included in that DOM, and will be set to be
+ * enabled or disabled depending on whether the view is selected.
+ */
+ readonly attribute AUTF8String observerID;
+
+ /**
+ * the controller for this view
+ */
+ attribute calICalendarViewController controller;
+
+ /**
+ * If true, the view supports workdays only
+ */
+ readonly attribute boolean supportsWorkdaysOnly;
+
+ /**
+ * If this is set to 'true', the view should not display days specified to be
+ * non-workdays. The implementor is responsible for obtaining what those
+ * days are on its own.
+ */
+ attribute boolean workdaysOnly;
+
+ /**
+ * Whether or not tasks are to be displayed in the calICalendarView
+ */
+ attribute boolean tasksInView;
+
+ /**
+ * If true, the view is rotatable
+ */
+ readonly attribute boolean supportsRotation;
+
+ /**
+ * If set, the view will be rotated (i.e time on top, date at left)
+ */
+ attribute boolean rotated;
+
+ /**
+ * If true, the view is zoomable
+ */
+ readonly attribute boolean supportsZoom;
+
+ /**
+ * Zoom view in one level. Defaults to one level.
+ */
+ void zoomIn([optional] in uint32_t level);
+
+ /**
+ * Zoom view out one level. Defaults to one level.
+ */
+ void zoomOut([optional] in uint32_t level);
+
+ /**
+ * Reset view zoom.
+ */
+ void zoomReset();
+
+ /**
+ * Whether or not completed tasks are shown in the calICalendarView
+ */
+ attribute boolean showCompleted;
+
+ /**
+ * Ensure that the given date is visible; the view is free
+ * to show more dates than the given date (e.g. week view
+ * would show the entire week).
+ */
+ void showDate(in calIDateTime aDate);
+
+ /**
+ * Set a date range for the view to display, from aStartDate
+ * to aEndDate, inclusive.
+ *
+ * Some views may decide to utilize the time portion of these
+ * calIDateTimes; pass in calIDateTimes that are dates if you
+ * want to make sure this doesn't happen.
+ */
+ void setDateRange(in calIDateTime aStartDate, in calIDateTime aEndDate);
+
+ /**
+ * The start date of the view's display. If the view is displaying
+ * disjoint dates, this will be the earliest date that's displayed.
+ */
+ readonly attribute calIDateTime startDate;
+
+ /**
+ * The end date of the view's display. If the view is displaying
+ * disjoint dates, this will be the latest date that's displayed.
+ *
+ * Note that this won't be equivalent to the aEndDate passed to
+ * setDateRange, because that date isn't actually displayed!
+ */
+ readonly attribute calIDateTime endDate;
+
+ /**
+ * The first day shown in the embedded view
+ */
+ readonly attribute calIDateTime startDay;
+
+ /**
+ * The last day shown in the embedded view
+ */
+ readonly attribute calIDateTime endDay;
+
+ /**
+ * True if this view supports disjoint dates
+ */
+ readonly attribute boolean supportsDisjointDates;
+
+ /**
+ * True if this view currently has a disjoint date set.
+ */
+ readonly attribute boolean hasDisjointDates;
+
+ /**
+ * Returns the list of dates being shown by this calendar.
+ * If a date range is set, it will expand out the date range by
+ * day and return the full set.
+ */
+ Array<calIDateTime> getDateList();
+
+ /**
+ * Get the items currently selected in this view.
+ *
+ * @return the array of items currently selected in this.
+ */
+ Array<calIItemBase> getSelectedItems();
+
+ /**
+ * Select an array of items in the view. Items outside the view's current
+ * display range will be ignored.
+ *
+ * @param aCount the number of items to select
+ * @param aItems an array of items to select
+ * @param aSuppressEvent if true, the 'itemselect' event will not be fired.
+ */
+ void setSelectedItems(in Array<calIItemBase> aItems, [optional] in boolean aSuppressEvent);
+
+ /**
+ * Make as many of the selected items as possible are visible in the view.
+ */
+ void centerSelectedItems();
+
+ /**
+ * Get or set the selected day.
+ */
+ attribute calIDateTime selectedDay;
+
+ /**
+ * Get or set the timezone that the view's elements should be displayed in.
+ * Setting this does not refresh the view.
+ */
+ attribute AUTF8String timezone;
+
+ /**
+ * Ensures that the given date is visible, and that the view is centered
+ * around this date. aDate becomes the selectedDay of the view. Calling
+ * this function with the current selectedDay effectively refreshes the view
+ *
+ * @param aDate the date that must be shown in the view and becomes
+ * the selected day
+ */
+ void goToDay(in calIDateTime aDate);
+
+ /**
+ * Moves the view a specific number of pages. Negative numbers correspond to
+ * moving the view backwards. Note that it is up to the view to determine
+ * how the selected day ought to move as well.
+ *
+ * @param aNumber the number of pages to move the view
+ */
+ void moveView(in long aNumber);
+
+ /**
+ * gets the description of the range displayed by the view
+ */
+ AString getRangeDescription();
+
+ /**
+ * The type of the view e.g "day", "week", "multiweek" or "month" that refers
+ * to the displayed time period.
+ */
+ readonly attribute string type;
+ /**
+ * removes the dropshadows that are inserted into childelements during a
+ * drag and drop session
+ */
+
+ void removeDropShadows();
+};
diff --git a/comm/calendar/base/public/calICalendarViewController.idl b/comm/calendar/base/public/calICalendarViewController.idl
new file mode 100644
index 0000000000..823a72fe34
--- /dev/null
+++ b/comm/calendar/base/public/calICalendarViewController.idl
@@ -0,0 +1,75 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+
+interface calICalendar;
+interface calIDateTime;
+interface calIEvent;
+interface calIItemBase;
+
+[scriptable, uuid(40430501-a666-4c24-b234-eeac5ccb70f6)]
+interface calICalendarViewController : nsISupports
+{
+ /**
+ * Create an event, with an optional start time and optional end
+ * time in the given Calendar. The Calendar will be the
+ * displayCalendar set on the View which invokes this method
+ * on the controller, or null, if the views wish to delegate the
+ * choice of the calendar to the controller.
+ *
+ * If neither aStartTime or aEndTime are given, the user wants to
+ * create a generic event with no information prefilled.
+ *
+ * If aStartTime is given and is a date, the user wants to
+ * create an all day event, optionally a multi-all-day event if
+ * aEndTime is given (and is also a date).
+ *
+ * If aStartTime is given and is a time, but no aEndTime is
+ * given, the user wants to create an event starting at
+ * aStartTime and of the default duration. The controller has the
+ * option of creating this event automatically or via the dialog.
+ *
+ * If both aStartTime and aEndTime are given as times, then
+ * the user wants to create an event going from aStartTime
+ * to aEndTime.
+ */
+ void createNewEvent (in calICalendar aCalendar,
+ in calIDateTime aStartTime,
+ in calIDateTime aEndTime);
+
+ /**
+ * View an occurrence of an event. This opens the event in a read-only
+ * summary dialog.
+ */
+ void viewOccurrence(in calIItemBase aOccurrence);
+
+ /**
+ * Modify aOccurrence. If aNewStartTime and aNewEndTime are given,
+ * update the event to those times. If aNewTitle is given, modify the title
+ * of the item. If no parameters are given, ask the user to modify.
+ */
+ void modifyOccurrence (in calIItemBase aOccurrence,
+ in calIDateTime aNewStartTime,
+ in calIDateTime aNewEndTime,
+ in AString aNewTitle);
+ /**
+ * Delete all events in the given array. If more than one event is passed,
+ * this will prompt whether to delete just this occurrence or all occurrences.
+ * All passed events will be handled in one transaction, i.e undoing this will
+ * make all events reappear.
+ *
+ * @param aCount The number of events in the array
+ * @param aOccurrences An array of Items/Occurrences to delete
+ * @param aUseParentItems If set, each occurrence will have its parent item
+ * deleted.
+ * @param aDoNotConfirm If set, the events will be deleted without
+ * confirmation.
+ */
+ void deleteOccurrences (in Array<calIItemBase> aOccurrences,
+ in boolean aUseParentItems,
+ in boolean aDoNotConfirm);
+};
diff --git a/comm/calendar/base/public/calIChangeLog.idl b/comm/calendar/base/public/calIChangeLog.idl
new file mode 100644
index 0000000000..e49e42f61d
--- /dev/null
+++ b/comm/calendar/base/public/calIChangeLog.idl
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "calICalendar.idl"
+
+interface calIGenericOperationListener;
+interface calIOperation;
+
+/**
+ * Interface for managing offline flags in offline storage
+ * (calStorageCalendar), in particular from calICachedCalendar.
+ */
+[scriptable, uuid(36dc2c93-5851-40d2-9ba9-b1f6e682c75c)]
+interface calIOfflineStorage : calICalendar {
+ /**
+ * Mark the item of which the id is passed as parameter as new.
+ *
+ * @param aItem the item to add
+ *
+ * @return {Promise<void>}
+ */
+ Promise addOfflineItem(in calIItemBase aItem);
+
+ /**
+ * Mark the item of which the id is passed as parameter as modified.
+ *
+ * @param aItem the item to modify
+ *
+ * @return {Promise<void>}
+ */
+ Promise modifyOfflineItem(in calIItemBase aItem);
+
+ /**
+ * Mark the item of which the id is passed as parameter as deleted.
+ *
+ * @param aItem the item to delete
+ * @return {Promise<void>}
+ */
+ Promise deleteOfflineItem(in calIItemBase aItem);
+
+ /**
+ * Retrieves the offline flag for the given item.
+ *
+ * @param aItem the item to reset
+ * @return {Promise<number>}
+ */
+ Promise getItemOfflineFlag(in calIItemBase aItem);
+
+ /**
+ * Remove any offline flag from the item record.
+ *
+ * @param aItem the item to reset
+ * @return {Promise<void>}
+ */
+ Promise resetItemOfflineFlag(in calIItemBase aItem);
+};
+
+/**
+ * Interface for synchronously working providers on storing items,
+ * e.g. storage, memory. All modifying commands return after the
+ * modification has been performed.
+ *
+ * @note
+ * This interface is used in conjunction with changelog-based synchronization
+ * and additionally offers storing meta-data for items for this purpose.
+ * The meta data is stored as long as the corresponding items persist in
+ * the calendar and automatically cleanup up once the item is deleted from
+ * the calendar, but is not altered when an item is modified (modifyItem).
+ * Meta data can be fetched/stored per (master) item, i.e. if you need to
+ * store meta data for individual overridden items, you need to store it
+ * along with the master item's meta data.
+ * Finally, keep in mind that the meta data is "calendar local" and not
+ * automatically transferred when storing the item on another calISyncWriteCalendar.
+ */
+[scriptable, uuid(651e137b-2f3a-4595-af89-da51b6a37f85)]
+interface calISyncWriteCalendar : calICalendar {
+ /**
+ * Adds or replaces meta data of an item.
+ *
+ * @param id an item id
+ * @param value an arbitrary string
+ */
+ void setMetaData(in AUTF8String id,
+ in AUTF8String value);
+
+ /**
+ * Deletes meta data of an item.
+ *
+ * @param id an item id
+ */
+ void deleteMetaData(in AUTF8String id);
+
+ /**
+ * Gets meta data of an item or null if there's none or the item id is invalid.
+ *
+ * @param id an item id
+ */
+ AUTF8String getMetaData(in AUTF8String id);
+
+ /**
+ * Gets all meta data IDs.
+ */
+ Array<AString> getAllMetaDataIds();
+
+ /**
+ * Gets all meta data values.
+ */
+ Array<AString> getAllMetaDataValues();
+};
+
+/**
+ * Calendar implementing this interface have improved means of replaying their
+ * changelog data. This could for example mean, that the provider can retrieve
+ * changes between now and the last sync.
+ *
+ * Not implementing this interface is perfectly valid for calendars, that need
+ * to do a full sync each time anyway (i.e ics)
+ */
+[scriptable, uuid(0bf4c6a2-b4c7-4cae-993a-4408d8bded3e)]
+interface calIChangeLog : nsISupports {
+
+ // To denote no offline flag, use null
+ const long OFFLINE_FLAG_CREATED_RECORD = 1;
+ const long OFFLINE_FLAG_MODIFIED_RECORD = 2;
+ const long OFFLINE_FLAG_DELETED_RECORD = 4;
+
+ /**
+ * Enable the changelog calendar to retrieve offline data right after instantiation.
+ */
+ attribute calISyncWriteCalendar offlineStorage;
+
+ /**
+ * Resets the changelog. This is used if the cache should be refreshed.
+ */
+ void resetLog();
+
+ /**
+ * Instructs the calendar to replay remote changes into the above offlineStorage
+ * calendar. The calendar itself is responsible for storing anything needed
+ * to keep track of what items need updating.
+ *
+ * TODO: We might reconsider to replay on calICalendar,
+ * but this complicates implementing this interface
+ * enormously for providers.
+ *
+ * @param aDestination The calendar to sync changes into
+ * @param aListener The listener to notify when the operation completes.
+ */
+ calIOperation replayChangesOn(in calIGenericOperationListener aListener);
+};
diff --git a/comm/calendar/base/public/calIDateTime.idl b/comm/calendar/base/public/calIDateTime.idl
new file mode 100644
index 0000000000..e4da295843
--- /dev/null
+++ b/comm/calendar/base/public/calIDateTime.idl
@@ -0,0 +1,228 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIDuration;
+interface calITimezone;
+
+[scriptable, uuid(fe3e9a58-2938-4b2c-9085-4989d5f7244f)]
+interface calIDateTime : nsISupports
+{
+ /**
+ * isMutable is true if this instance is modifiable.
+ * If isMutable is false, any attempts to modify
+ * the object will throw NS_ERROR_OBJECT_IS_IMMUTABLE.
+ */
+ readonly attribute boolean isMutable;
+
+ /**
+ * Make this calIDateTime instance immutable.
+ */
+ void makeImmutable();
+
+ /**
+ * Clone this calIDateTime instance into a new
+ * mutable object.
+ */
+ calIDateTime clone();
+
+ /**
+ * valid is true if this object contains a valid
+ * time/date.
+ */
+ // true if this thing is set/valid
+ readonly attribute boolean isValid;
+
+ /**
+ * nativeTime contains this instance's PRTime value relative
+ * to the UTC epoch, regardless of the timezone that's set
+ * on this instance. If nativeTime is set, the given UTC PRTime
+ * value is exploded into year/month/etc, forcing the timezone
+ * setting to UTC.
+ *
+ * @warning: When the timezone is set to 'floating', this will return
+ * the nativeTime as-if the timezone was UTC. Take this into account
+ * when comparing values.
+ *
+ * @note on objects that are pinned to a timezone and have isDate set,
+ * nativeTime will be 00:00:00 in the timezone of that date, not 00:00:00 in
+ * UTC.
+ */
+ attribute PRTime nativeTime;
+
+ /**
+ * Full 4-digit year value (e.g. "1989", "2004")
+ */
+ attribute short year;
+
+ /**
+ * Month, 0-11, 0 = January
+ */
+ attribute short month;
+
+ /**
+ * Day of month, 1-[28,29,30,31]
+ */
+ attribute short day;
+
+ /**
+ * Hour, 0-23
+ */
+ attribute short hour;
+
+ /**
+ * Minute, 0-59
+ */
+ attribute short minute;
+
+ /**
+ * Second, 0-59
+ */
+ attribute short second;
+
+ /**
+ * Gets or sets the timezone of this calIDateTime instance.
+ * Setting the timezone does not change the actual date/time components;
+ * to convert between timezones, use getInTimezone().
+ *
+ * @throws NS_ERROR_INVALID_ARG if null is passed in.
+ */
+ attribute calITimezone timezone;
+
+ /**
+ * Resets the datetime object.
+ *
+ * @param year full 4-digit year value (e.g. "1989", "2004")
+ * @param month month, 0-11, 0 = January
+ * @param day day of month, 1-[28,29,31]
+ * @param hour hour, 0-23
+ * @param minute minute, 0-59
+ * @param second second, 0-59
+ * @param timezone timezone
+ *
+ * The passed datetime will be normalized, e.g. a minute value of 60 will
+ * increase the hour.
+ *
+ * @throws NS_ERROR_INVALID_ARG if no timezone is passed in.
+ */
+ void resetTo(in short year,
+ in short month,
+ in short day,
+ in short hour,
+ in short minute,
+ in short second,
+ in calITimezone timezone);
+
+ /**
+ * The offset of the timezone this datetime is in, relative to UTC, in
+ * seconds. A positive number means that the timezone is ahead of UTC.
+ */
+ readonly attribute long timezoneOffset;
+
+ /**
+ * isDate indicates that this calIDateTime instance represents a date
+ * (a whole day), and not a specific time on that day. If isDate is set,
+ * accessing the hour/minute/second fields will return 0, and and setting
+ * them is an illegal operation.
+ */
+ attribute boolean isDate;
+
+ /*
+ * computed values
+ */
+
+ /**
+ * Day of the week. 0-6, with Sunday = 0.
+ */
+ readonly attribute short weekday;
+
+ /**
+ * Day of the year, 1-[365,366].
+ */
+ readonly attribute short yearday;
+
+ /*
+ * Methods
+ */
+
+ /**
+ * Resets this instance to Jan 1, 1970 00:00:00 UTC.
+ */
+ void reset();
+
+ /**
+ * Return a string representation of this instance.
+ */
+ AUTF8String toString();
+
+ /**
+ * Returns a string representation of this instance suitable for JSON.
+ */
+ AUTF8String toJSON();
+
+ /**
+ * Return a new calIDateTime instance that's the result of
+ * converting this one into the given timezone. Valid values
+ * for aTimezone are the same as the timezone field. If
+ * the "floating" timezone is given, then this object
+ * is just cloned, and the timezone is set to floating.
+ */
+ calIDateTime getInTimezone(in calITimezone aTimezone);
+
+ // add the given calIDateTime, treating it as a duration, to
+ // this item.
+ // XXX will change
+ void addDuration (in calIDuration aDuration);
+
+ // Subtract two dates and return a duration
+ // returns duration of this - aOtherDate
+ // if aOtherDate is > this the duration will be negative
+ calIDuration subtractDate (in calIDateTime aOtherDate);
+
+ /**
+ * Compare this calIDateTime instance to aOther. Returns -1, 0, 1 to
+ * indicate if this < aOther, this == aOther, or this > aOther,
+ * respectively.
+ *
+ * This comparison is timezone-aware; the given values are converted
+ * to a common timezone before comparing. If either this or aOther is
+ * floating, both objects are treated as floating for the comparison.
+ *
+ * If either this or aOther has isDate set, then only the date portion is
+ * compared.
+ *
+ * @exception calIErrors.INVALID_TIMEZONE bad timezone on this object
+ * (not the argument object)
+ */
+ long compare (in calIDateTime aOther);
+
+ //
+ // Some helper getters for calculating useful ranges
+ //
+
+ /**
+ * Returns SUNDAY of the given datetime object's week.
+ */
+ readonly attribute calIDateTime startOfWeek;
+
+ /**
+ * Returns SATURDAY of the datetime object's week.
+ */
+ readonly attribute calIDateTime endOfWeek;
+
+ // the start/end of the current object's month
+ readonly attribute calIDateTime startOfMonth;
+ readonly attribute calIDateTime endOfMonth;
+
+ // the start/end of the current object's year
+ readonly attribute calIDateTime startOfYear;
+ readonly attribute calIDateTime endOfYear;
+
+ /**
+ * This object as either an iCalendar DATE or DATETIME string, as
+ * appropriate and sets the timezone to either UTC or floating.
+ */
+ attribute ACString icalString;
+};
diff --git a/comm/calendar/base/public/calIDeletedItems.idl b/comm/calendar/base/public/calIDeletedItems.idl
new file mode 100644
index 0000000000..8045fa24b5
--- /dev/null
+++ b/comm/calendar/base/public/calIDeletedItems.idl
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIDateTime;
+
+[scriptable, uuid(2414729b-37dc-456e-ba72-f9c33891e6ee)]
+interface calIDeletedItems : nsISupports
+{
+ /**
+ * Clean the database of all deleted items older than an internal threshold.
+ */
+ void flush();
+
+ /**
+ * Gets the time the item with given id was deleted at. If passed, the
+ * search will be restricted to a certain calendar
+ *
+ * @param aId The ID of the item to search for.
+ * @param aCalId The calendar id to restrict the search to.
+ * @return The date/time the item was deleted, or null if not found.
+ */
+ calIDateTime getDeletedDate(in AUTF8String aId, [optional] in AUTF8String aCalId);
+};
diff --git a/comm/calendar/base/public/calIDuration.idl b/comm/calendar/base/public/calIDuration.idl
new file mode 100644
index 0000000000..a6bb00ed79
--- /dev/null
+++ b/comm/calendar/base/public/calIDuration.idl
@@ -0,0 +1,104 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(78537f21-fd5c-4e02-ab26-8ff6a3d946cb)]
+interface calIDuration : nsISupports
+{
+ /**
+ * isMutable is true if this instance is modifiable.
+ * If isMutable is false, any attempts to modify
+ * the object will throw CAL_ERROR_ITEM_IS_MUTABLE.
+ */
+ readonly attribute boolean isMutable;
+
+ /**
+ * Make this calIDuration instance immutable.
+ */
+ void makeImmutable();
+
+ /**
+ * Clone this calIDuration instance into a new
+ * mutable object.
+ */
+ calIDuration clone();
+
+ /**
+ * Is Negative
+ */
+ attribute boolean isNegative;
+
+ /**
+ * Weeks
+ */
+ attribute short weeks;
+
+ /**
+ * Days
+ */
+ attribute short days;
+
+ /**
+ * Hours
+ */
+ attribute short hours;
+
+ /**
+ * Minutes
+ */
+ attribute short minutes;
+
+ /**
+ * Seconds
+ */
+ attribute short seconds;
+
+ /**
+ * total duration in seconds
+ */
+ attribute long inSeconds;
+
+ /*
+ * Methods
+ */
+
+ /**
+ * Add a duration
+ */
+ void addDuration(in calIDuration aDuration);
+
+ /**
+ * Compare with another duration
+ *
+ * @param aOther to be compared with this object
+ *
+ * @return -1, 0, 1 if this < aOther, this == aOther, or this > aOther,
+ * respectively.
+ */
+ long compare(in calIDuration aOther);
+
+ /**
+ * Reset this duration to 0
+ */
+ void reset();
+
+ /**
+ * Normalize the duration
+ */
+ void normalize();
+
+ /**
+ * Return a string representation of this instance.
+ */
+ AUTF8String toString();
+
+ attribute jsval icalDuration;
+
+ /**
+ * This object as an iCalendar DURATION string
+ */
+ attribute ACString icalString;
+};
diff --git a/comm/calendar/base/public/calIErrors.idl b/comm/calendar/base/public/calIErrors.idl
new file mode 100644
index 0000000000..51571822e0
--- /dev/null
+++ b/comm/calendar/base/public/calIErrors.idl
@@ -0,0 +1,117 @@
+/* -*- Mode: IDL; tab-width: 50; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(404c7d78-bec7-474c-aa2a-82c0d0563bb6)]
+interface calIErrors : nsISupports
+{
+ /**
+ * The first two constants are copied from nsError.h, but named slightly
+ * differently, because if they're named the same, the names collide and
+ * the compiler can't deal.
+ */
+ const unsigned long CAL_ERROR_MODULE_CALENDAR = 5;
+ const unsigned long CAL_ERROR_MODULE_BASE_OFFSET = 0x45;
+
+ /**
+ * The beginning of this set of error codes, also copied from the macros
+ * in nsError.h.
+ */
+ const unsigned long ERROR_BASE = (1<<31) |
+ (CAL_ERROR_MODULE_CALENDAR + CAL_ERROR_MODULE_BASE_OFFSET) << 16;
+
+ /* Onto the actual errors! */
+
+ /**
+ * An invalid or nonexistent timezone was encountered.
+ */
+ const unsigned long INVALID_TIMEZONE = ERROR_BASE + 1;
+
+ /**
+ * Attempted to modify a readOnly calendar.
+ */
+ const unsigned long CAL_IS_READONLY = ERROR_BASE + 2;
+
+ /**
+ * Error while decoding an (ics) file from utf8
+ */
+ const unsigned long CAL_UTF8_DECODING_FAILED = ERROR_BASE + 3;
+
+ /**
+ * Tried to add an item to a calendar in which an item with the
+ * same ID already existed
+ */
+ const unsigned long DUPLICATE_ID = ERROR_BASE + 4;
+
+ /**
+ * Operation has been cancelled.
+ */
+ const unsigned long OPERATION_CANCELLED = ERROR_BASE + 5;
+
+ /**
+ * Creation of calendar object failed
+ */
+ const unsigned long PROVIDER_CREATION_FAILED = ERROR_BASE + 6;
+
+ /**
+ * Profile data has newer schema than we know in this calendar version.
+ */
+ const unsigned long STORAGE_UNKNOWN_SCHEMA_ERROR = ERROR_BASE + 7;
+
+ /**
+ * Profile data may refer to newer timezones than we know.
+ */
+ const unsigned long STORAGE_UNKNOWN_TIMEZONES_ERROR = ERROR_BASE + 8;
+
+ /**
+ * The calendar could not be accessed for reading.
+ */
+ const unsigned long READ_FAILED = ERROR_BASE + 9;
+
+ /**
+ * The calendar could not be accessed for modification.
+ */
+ const unsigned long MODIFICATION_FAILED = ERROR_BASE + 10;
+
+ /* ICS specific errors */
+ const unsigned long ICS_ERROR_BASE = ERROR_BASE + 0x100;
+
+ /**
+ * ICS errors, copied from icalerror.h.
+ * The numbers (minus ICS_ERROR_BASE) should match with the enum
+ * values from icalerror.h
+ */
+ const unsigned long ICS_NO_ERROR = ICS_ERROR_BASE + 0;
+ const unsigned long ICS_BADARG = ICS_ERROR_BASE + 1;
+ const unsigned long ICS_NEWFAILED = ICS_ERROR_BASE + 2;
+ const unsigned long ICS_ALLOCATION = ICS_ERROR_BASE + 3;
+ const unsigned long ICS_MALFORMEDDATA = ICS_ERROR_BASE + 4;
+ const unsigned long ICS_PARSE = ICS_ERROR_BASE + 5;
+ const unsigned long ICS_INTERNAL = ICS_ERROR_BASE + 6;
+ const unsigned long ICS_FILE = ICS_ERROR_BASE + 7;
+ const unsigned long ICS_USAGE = ICS_ERROR_BASE + 8;
+ const unsigned long ICS_UNIMPLEMENTED = ICS_ERROR_BASE + 9;
+ const unsigned long ICS_UNKNOWN = ICS_ERROR_BASE + 10;
+
+ /**
+ * Range for former WCAP provider. This could be re-used now in theory, but
+ * you might as well just add to the end.
+ * Range previously claimed is [ERROR_BASE + 0x200, ERROR_BASE + 0x300)
+ */
+ const unsigned long EX_WCAP_ERROR_BASE = ERROR_BASE + 0x200;
+
+ /**
+ * (Cal)DAV specific errors
+ * Range is [ERROR_BASE + 0x301, ERROR_BASE + 0x399]
+ */
+ const unsigned long DAV_ERROR_BASE = ERROR_BASE + 0x301;
+ const unsigned long DAV_NOT_DAV = DAV_ERROR_BASE + 0;
+ const unsigned long DAV_DAV_NOT_CALDAV = DAV_ERROR_BASE + 1;
+ const unsigned long DAV_NO_PROPS = DAV_ERROR_BASE + 2;
+ const unsigned long DAV_PUT_ERROR = DAV_ERROR_BASE + 3;
+ const unsigned long DAV_REMOVE_ERROR = DAV_ERROR_BASE + 4;
+ const unsigned long DAV_REPORT_ERROR = DAV_ERROR_BASE + 5;
+};
diff --git a/comm/calendar/base/public/calIEvent.idl b/comm/calendar/base/public/calIEvent.idl
new file mode 100644
index 0000000000..218163ec30
--- /dev/null
+++ b/comm/calendar/base/public/calIEvent.idl
@@ -0,0 +1,42 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "calIItemBase.idl"
+
+interface calIDuration;
+
+//
+// calIEvent
+//
+// An interface for an event (analogous to a VEVENT)
+//
+
+[scriptable, uuid(5ab15c1c-e295-4d8e-a9a9-ba5bc848b59a)]
+interface calIEvent : calIItemBase
+{
+ // these attributes are marked readonly, as the calIDates are owned
+ // by the event; however, the actual calIDate objects are not read
+ // only and are intended to be manipulated to adjust dates.
+
+ /**
+ * The (inclusive) start of the event.
+ */
+ attribute calIDateTime startDate;
+
+ /**
+ * The (non-inclusive) end of the event.
+ * Note that for all-day events, non-inclusive means that this will be set
+ * to the day after the last day of the event.
+ * If startDate.isDate is set, endDate.isDate must also be set.
+ */
+ attribute calIDateTime endDate;
+
+ /**
+ * The duration of the event.
+ * equal to endDate - startDate
+ */
+ readonly attribute calIDuration duration;
+
+};
diff --git a/comm/calendar/base/public/calIFreeBusyProvider.idl b/comm/calendar/base/public/calIFreeBusyProvider.idl
new file mode 100644
index 0000000000..5215f54998
--- /dev/null
+++ b/comm/calendar/base/public/calIFreeBusyProvider.idl
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIDateTime;
+interface calIPeriod;
+interface calIOperation;
+interface calIGenericOperationListener;
+
+[scriptable, uuid(EB24424C-DD22-4306-9379-FA098C61F5AF)]
+interface calIFreeBusyProvider : nsISupports
+{
+ /**
+ * Gets free/busy intervals.
+ * Results are notified to the passed listener interface.
+ *
+ * @param aCalId calid or MAILTO:rfc822addr
+ * @param aRangeStart start time of free-busy search
+ * @param aRangeEnd end time of free-busy search
+ * @param aBusyTypes what free-busy intervals should be returned
+ * @param aListener called with an array of calIFreeBusyInterval objects
+ * @return optional operation handle to track the operation
+ */
+ calIOperation getFreeBusyIntervals(in AUTF8String aCalId,
+ in calIDateTime aRangeStart,
+ in calIDateTime aRangeEnd,
+ in unsigned long aBusyTypes,
+ in calIGenericOperationListener aListener);
+};
+
+/**
+ * This interface reflects a free or busy interval in time.
+ * Referring to RFC 2445, section 4.2.9, for the different types.
+ */
+[scriptable, uuid(CCBEAF5E-DB87-4bc9-8BB7-24754B76BCB5)]
+interface calIFreeBusyInterval : nsISupports
+{
+ /**
+ * The calId this free-busy period belongs to.
+ */
+ readonly attribute AUTF8String calId;
+
+ /**
+ * The free-busy time interval.
+ */
+ readonly attribute calIPeriod interval;
+
+ /**
+ * The value UNKNOWN indicates that the free-busy information for the time interval is
+ * not known.
+ */
+ const unsigned long UNKNOWN = 0;
+
+ /**
+ * The value FREE indicates that the time interval is free for scheduling.
+ */
+ const unsigned long FREE = 1;
+
+ /**
+ * The value BUSY indicates that the time interval is busy because one
+ * or more events have been scheduled for that interval.
+ */
+ const unsigned long BUSY = 1 << 1;
+
+ /**
+ * The value BUSY_UNAVAILABLE indicates that the time interval is busy
+ * and that the interval can not be scheduled.
+ */
+ const unsigned long BUSY_UNAVAILABLE = 1 << 2;
+
+ /**
+ * The value BUSY_TENTATIVE indicates that the time interval is busy because
+ * one or more events have been tentatively scheduled for that interval.
+ */
+ const unsigned long BUSY_TENTATIVE = 1 << 3;
+
+ /**
+ * All BUSY* states.
+ */
+ const unsigned long BUSY_ALL = (BUSY |
+ BUSY_UNAVAILABLE |
+ BUSY_TENTATIVE);
+
+ /**
+ * One of the above types.
+ */
+ readonly attribute unsigned long freeBusyType;
+};
+
+/**
+ * This service acts as a central access point for free-busy lookup.
+ * A free-busy request will be multiplexed to all added free-busy providers.
+ * Adding a free-busy provider is transient.
+ */
+[scriptable, uuid(BE1796CF-CB53-482e-8942-D6CAA0A11BAA)]
+interface calIFreeBusyService : calIFreeBusyProvider
+{
+ /**
+ * Adds a new free-busy provider.
+ */
+ void addProvider(in calIFreeBusyProvider aProvider);
+
+ /**
+ * Removes a free-busy provider.
+ */
+ void removeProvider(in calIFreeBusyProvider aProvider);
+};
diff --git a/comm/calendar/base/public/calIICSService.idl b/comm/calendar/base/public/calIICSService.idl
new file mode 100644
index 0000000000..46d5f90320
--- /dev/null
+++ b/comm/calendar/base/public/calIICSService.idl
@@ -0,0 +1,242 @@
+/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// XXX use strings for kind values instead of enumerated constants?
+
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+interface calIDuration;
+interface calITimezone;
+
+interface calIIcalProperty;
+interface nsIInputStream;
+
+/**
+ * General notes:
+ *
+ * As with libical, use of getNextFoo(footype) is only valid if there have been
+ * no intervening getNextFoo(otherfootype)s, or removeFoo()s, or addFoo()s. In
+ * general, you want to do as little manipulation of your FooContainers as
+ * possible while iterating over them.
+ */
+[scriptable,uuid(59132cf2-e48c-4807-ab53-779f414a7fbc)]
+interface calIIcalComponent : nsISupports
+{
+ /**
+ * The parent ical property
+ */
+ readonly attribute calIIcalComponent parent;
+
+ /**
+ * Access to the inner ical.js objects. Only use these if you know what you
+ * are doing.
+ */
+ attribute jsval icalComponent;
+ attribute jsval icalTimezone;
+
+ /**
+ * This is the value that an integer-valued getter will provide if
+ * there is no such property on the wrapped ical structure.
+ */
+ const int32_t INVALID_VALUE = -1;
+
+ /**
+ * @param kind ANY, XROOT, VCALENDAR, VEVENT, etc.
+ */
+ calIIcalComponent getFirstSubcomponent(in AUTF8String componentType);
+ calIIcalComponent getNextSubcomponent(in AUTF8String componentType);
+
+ readonly attribute AUTF8String componentType;
+
+ attribute AUTF8String uid;
+ attribute AUTF8String prodid;
+ attribute AUTF8String version;
+
+ /**
+ * PUBLISH, REQUEST, REPLY, etc.
+ */
+ attribute AUTF8String method;
+
+ /**
+ * TENTATIVE, CONFIRMED, CANCELLED, etc.
+ */
+ attribute AUTF8String status;
+
+ attribute AUTF8String summary;
+ attribute AUTF8String description;
+ attribute AUTF8String location;
+ attribute AUTF8String categories;
+ attribute AUTF8String URL;
+
+ attribute int32_t priority;
+
+ attribute calIDateTime startTime;
+ attribute calIDateTime endTime;
+ readonly attribute calIDuration duration;
+ attribute calIDateTime dueTime;
+ attribute calIDateTime stampTime;
+
+ attribute calIDateTime createdTime;
+ attribute calIDateTime completedTime;
+ attribute calIDateTime lastModified;
+
+ /**
+ * The recurrence ID, a.k.a. DTSTART-of-calculated-occurrence,
+ * or null if this isn't an occurrence.
+ */
+ attribute calIDateTime recurrenceId;
+
+ AUTF8String serializeToICS();
+
+ /**
+ * Return a string representation of this instance.
+ */
+ AUTF8String toString();
+
+ /**
+ * Serializes this component (and subcomponents) directly to an
+ * input stream. Typically used for performance to avoid
+ * unnecessary conversions and XPConnect traversals.
+ *
+ * @result an input stream which can be read to get the serialized
+ * version of this component, encoded in UTF-8. Implements
+ * nsISeekableStream so that it can be used with
+ * nsIUploadChannel.
+ */
+ nsIInputStream serializeToICSStream();
+
+ void addSubcomponent(in calIIcalComponent comp);
+// If you add then remove a property/component, the referenced
+// timezones won't get purged out. There's currently no client code.
+// void removeSubcomponent(in calIIcalComponent comp);
+
+ /**
+ * @param kind ANY, ATTENDEE, X-WHATEVER, etc.
+ */
+ calIIcalProperty getFirstProperty(in AUTF8String kind);
+ calIIcalProperty getNextProperty(in AUTF8String kind);
+ void addProperty(in calIIcalProperty prop);
+// If you add then remove a property/component, the referenced
+// timezones won't get purged out. There's currently no client code.
+// void removeProperty(in calIIcalProperty prop);
+
+ /**
+ * Timezones need special handling, as they must be
+ * emitted as children of VCALENDAR, but can be referenced by
+ * any sub component.
+ * Adding a second timezone (of the same TZID) will remove the
+ * first one.
+ */
+ void addTimezoneReference(in calITimezone aTimezone);
+
+ /**
+ * Returns an array of VTIMEZONE components.
+ * These are the timezones that are in use by this
+ * component and its children.
+ */
+ Array<calITimezone> getReferencedTimezones();
+
+ /**
+ * Clones the component. The cloned component is decoupled from any parent.
+ * @return cloned component
+ */
+ calIIcalComponent clone();
+};
+
+[scriptable,uuid(5b13a69c-53d3-44a0-9203-f89f7e5e1604)]
+interface calIIcalProperty : nsISupports
+{
+ /**
+ * The whole property as an ical string.
+ * @exception Any error will be thrown as an calIError::ICS_ error.
+ */
+ readonly attribute AUTF8String icalString;
+
+ /**
+ * Access to the inner ical.js objects. Only use these if you know what you
+ * are doing.
+ */
+ attribute jsval icalProperty;
+
+ /**
+ * The parent component containing this property
+ */
+ readonly attribute calIIcalComponent parent;
+
+ /**
+ * Return a string representation of this instance.
+ */
+ AUTF8String toString();
+
+ /**
+ * The value of the property as string.
+ * The exception for properties of TEXT or X- type, those will be unescaped
+ * when getting, and also expects an unescaped string when setting.
+ * Datetime, numeric and other non-text types are represented as ical string
+ */
+ attribute AUTF8String value;
+
+ /**
+ * The value of the property in (escaped) ical format.
+ */
+ attribute AUTF8String valueAsIcalString;
+
+ /**
+ * The value of the property as date/datetime value, keeping
+ * track of the used timezone referenced in the owning component.
+ */
+ attribute calIDateTime valueAsDatetime;
+
+ // XXX attribute AUTF8String stringValueWithParams; ?
+ readonly attribute AUTF8String propertyName;
+
+ AUTF8String getParameter(in AUTF8String paramname);
+ void setParameter(in AUTF8String paramname, in AUTF8String paramval);
+
+ AUTF8String getFirstParameterName();
+ AUTF8String getNextParameterName();
+
+ void removeParameter(in AUTF8String paramname);
+ void clearXParameters();
+};
+
+[scriptable,uuid(eda9565f-f9bb-4846-b134-1e0653b2e767)]
+interface calIIcsComponentParsingListener : nsISupports
+{
+ /**
+ * Called when the parsing has completed.
+ *
+ * @param rc The result code of parsing
+ * @param rootComp The root ical component that was parsed
+ */
+ void onParsingComplete(in nsresult rc, in calIIcalComponent rootComp);
+};
+
+[scriptable,uuid(31e7636b-5a64-4d15-bc60-67b67cd85176)]
+interface calIICSService : nsISupports
+{
+ /**
+ * Parse an ICS string into components.
+ *
+ * @param serialized an ICS string
+ */
+ calIIcalComponent parseICS(in AUTF8String serialized);
+
+ /**
+ * Asynchronously parse an ICS string into components.
+ *
+ * @param serialized an ICS string
+ * @param listener The listener that notifies the root component
+ */
+ void parseICSAsync(in AUTF8String serialized,
+ in calIIcsComponentParsingListener listener);
+
+ calIIcalComponent createIcalComponent(in AUTF8String kind);
+ calIIcalProperty createIcalProperty(in AUTF8String kind);
+ calIIcalProperty createIcalPropertyFromString(in AUTF8String str);
+};
diff --git a/comm/calendar/base/public/calIIcsParser.idl b/comm/calendar/base/public/calIIcsParser.idl
new file mode 100644
index 0000000000..9d27edfa4b
--- /dev/null
+++ b/comm/calendar/base/public/calIIcsParser.idl
@@ -0,0 +1,81 @@
+/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIIcalProperty;
+interface calIIcalComponent;
+interface calIItemBase;
+interface nsIInputStream;
+interface calIIcsParser;
+
+/**
+ * Listener being called once asynchronous parsing is done.
+ */
+[scriptable, uuid(d22527da-b0e2-41b7-b6f4-ee9c243cd285)]
+interface calIIcsParsingListener : nsISupports
+{
+ void onParsingComplete(in nsresult rc, in calIIcsParser parser);
+};
+
+/**
+ * An interface for parsing an ics string or stream into its items.
+ * Note that this is not a service. A new instance must be created for every new
+ * string or stream to be parsed.
+ */
+[scriptable, uuid(83e9befe-5e9e-49de-8bc2-d882f464f7e7)]
+interface calIIcsParser : nsISupports
+{
+ /**
+ * Parse an ics string into its items, and store top-level properties and
+ * components that are not interpreted.
+ *
+ * @param aICSString
+ * The ICS string to parse
+ * @param optional aAsyncParsing
+ * If non-null, parsing will be performed on a worker thread,
+ * and the passed listener is called when it's done
+ */
+ void parseString(in AString aICSString,
+ [optional] in calIIcsParsingListener aAsyncParsing);
+
+ /**
+ * Parse an input stream.
+ *
+ * @see parseString
+ * @param aICSString
+ * The stream to parse
+ * @param optional aAsyncParsing
+ * If non-null, parsing will be performed on a worker thread,
+ * and the passed listener is called when it's done
+ */
+ void parseFromStream(in nsIInputStream aStream,
+ [optional] in calIIcsParsingListener aAsyncParsing);
+
+ /**
+ * Get the items that were in the string or stream. In case an item represents a
+ * recurring series, the (unexpanded) parent item is returned only.
+ * Please keep in mind that any parentless items (see below) are not contained
+ * in the returned set of items.
+ */
+ Array<calIItemBase> getItems();
+
+ /**
+ * Get the parentless items that may have occurred, i.e. overridden items of a
+ * recurring series (having a RECURRENCE-ID) missing their parent item in the
+ * parsed content.
+ */
+ Array<calIItemBase> getParentlessItems();
+
+ /**
+ * Get the top-level properties that were not interpreted as anything special
+ */
+ Array<calIIcalProperty> getProperties();
+
+ /**
+ * Get the top-level components that were not interpreted as anything special
+ */
+ Array<calIIcalComponent> getComponents();
+};
diff --git a/comm/calendar/base/public/calIIcsSerializer.idl b/comm/calendar/base/public/calIIcsSerializer.idl
new file mode 100644
index 0000000000..2d81c8387c
--- /dev/null
+++ b/comm/calendar/base/public/calIIcsSerializer.idl
@@ -0,0 +1,73 @@
+/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIIcalProperty;
+interface calIIcalComponent;
+interface calIItemBase;
+interface nsIOutputStream;
+interface nsIInputStream;
+
+/**
+ * An interface for serializing calendar items into an ICS string.
+ * Note that this is not a service. A new instance must be created for every new
+ * set of items to be serialized.
+ */
+[scriptable, uuid(4dcf6b4e-7322-4a61-a191-8d8cc1aea42e)]
+interface calIIcsSerializer : nsISupports
+{
+ /**
+ * Add some items to the items that are to be serialized. Can be called
+ * multiple times, and appends to the set on every call.
+ *
+ * @param aItems
+ * The items to be added
+ */
+ void addItems(in Array<calIItemBase> aItems);
+
+ /**
+ * Add a property to the top-level properties to be added on serializing. Can
+ * be called multiple times, and appends to the set on every call.
+ *
+ * @param aProperty
+ * The property to be added
+ */
+ void addProperty(in calIIcalProperty aProperty);
+
+ /**
+ * Add a component to the top-level components to be added on serializing. Can
+ * be called multiple times, and appends to the set on every call.
+ *
+ * @param aComponent
+ * The component to be added
+ */
+ void addComponent(in calIIcalComponent aComponent);
+
+ /**
+ * Serialize the added items, properties and components into an ICS string
+ *
+ * @returns
+ * A string containing the serialized items, properties and components.
+ */
+ AString serializeToString();
+
+ /**
+ * Serialize the added items, properties and components into an ICS stream
+ *
+ * @returns
+ * A stream containing the serialized items, properties and components.
+ */
+ nsIInputStream serializeToInputStream();
+
+ /**
+ * Serialize the added items, properties and components into an ICS stream
+ *
+ * @param aStream
+ * A stream into which the serialized items, properties and components
+ * will be written.
+ */
+ void serializeToStream(in nsIOutputStream aStream);
+};
diff --git a/comm/calendar/base/public/calIImportExport.idl b/comm/calendar/base/public/calIImportExport.idl
new file mode 100644
index 0000000000..1f16d351e3
--- /dev/null
+++ b/comm/calendar/base/public/calIImportExport.idl
@@ -0,0 +1,56 @@
+/* -*- Mode: IDL; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface nsIInputStream;
+interface nsIOutputStream;
+
+[scriptable, uuid(efef8333-e995-4f45-bdf7-bfcabbd9793e)]
+interface calIFileType : nsISupports
+{
+ /**
+ * The default extension that should be associated
+ * with files of this type.
+ */
+ readonly attribute AString defaultExtension;
+
+ /**
+ * The extension filter to use in the filepicker's filter list.
+ * Separate multiple extensions with semicolon and space.
+ * For example "*.html; *.htm".
+ */
+ readonly attribute AString extensionFilter;
+
+ /**
+ * The description to show to the user in the filter list.
+ */
+ readonly attribute AString description;
+};
+
+[scriptable, uuid(dbe262ca-d6c6-4691-8d46-e7f6bbe632ec)]
+interface calIImporter : nsISupports
+{
+ Array<calIFileType> getFileTypes();
+
+ Array<calIItemBase> importFromStream(in nsIInputStream aStream);
+};
+
+[scriptable, uuid(18c75bb3-6309-4c33-903f-6055fec39d07)]
+interface calIExporter : nsISupports
+{
+ Array<calIFileType> getFileTypes();
+
+ /**
+ * Export the items into the stream
+ *
+ * @param aStream the stream to put the data into
+ * @param aItems an array of items to be exported
+ * @param aTitle a title the exporter can choose to use
+ */
+ void exportToStream(in nsIOutputStream aStream, in Array<calIItemBase> aItems, in AString aTitle);
+};
diff --git a/comm/calendar/base/public/calIItemBase.idl b/comm/calendar/base/public/calIItemBase.idl
new file mode 100644
index 0000000000..f87935b297
--- /dev/null
+++ b/comm/calendar/base/public/calIItemBase.idl
@@ -0,0 +1,372 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIVariant;
+
+interface calIItemACLEntry;
+interface calIAlarm;
+interface calIAttachment;
+interface calIAttendee;
+interface calICalendar;
+interface calIDateTime;
+interface calIDuration;
+interface calIIcalComponent;
+interface calIRecurrenceInfo;
+interface calIRelation;
+
+//
+// calIItemBase
+//
+// Base for Events, Todos, Journals, etc.
+//
+
+[scriptable, uuid(9c988b8d-af45-4046-b05e-34417bba9058)]
+interface calIItemBase : nsISupports
+{
+ // returns true if this thing is able to be modified;
+ // if the item is not mutable, attempts to modify
+ // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE
+ readonly attribute boolean isMutable;
+
+ // makes this item immutable
+ void makeImmutable();
+
+ // clone always returns a mutable event
+ calIItemBase clone();
+
+ /**
+ * Returns true if this item is an instance of calIEvent.
+ */
+ boolean isEvent();
+
+ /**
+ * Returns true if this item is an instance of calITodo.
+ */
+ boolean isTodo();
+
+ /**
+ * Hash Id that incorporates the item's UID, RECURRENCE-ID and calendar.id
+ * to be used for lookup of items that come from different calendars.
+ * Setting either id, recurrenceId or the calendar attribute leads to
+ * a recomputation of hashId.
+ *
+ * @attention Individual implementors of calIItemBase must stick to the
+ * same algorithm that base/src/calItemBase.js uses.
+ */
+ readonly attribute AUTF8String hashId;
+
+ /**
+ * Checks whether the argument object refers the same calendar item as
+ * this one, by testing both the id and recurrenceId property. This
+ *
+ * @arg aItem the item to compare against this one
+ *
+ * @return true if both ids match, false otherwise
+ */
+ boolean hasSameIds(in calIItemBase aItem);
+
+ /**
+ * Returns the acl entry associated to the item.
+ */
+ readonly attribute calIItemACLEntry aclEntry;
+
+ //
+ // the generation number of this item
+ //
+ attribute uint32_t generation;
+
+ // the time when this item was created
+ readonly attribute calIDateTime creationDate;
+
+ // last time any attribute was modified on this item, in UTC
+ readonly attribute calIDateTime lastModifiedTime;
+
+ // last time a "significant change" was made to this item
+ readonly attribute calIDateTime stampTime;
+
+ // the calICalendar to which this event belongs
+ attribute calICalendar calendar;
+
+ // the ID of this event
+ attribute AUTF8String id;
+
+ // event title
+ attribute AUTF8String title;
+
+ /**
+ * The event's description in plain text.
+ *
+ * Setting this will reset descriptionHTML.
+ */
+ attribute AUTF8String descriptionText;
+
+ /**
+ * The event's description, as HTML.
+ *
+ * The text content MUST match descriptionText but the HTML can contain
+ * formatting, links etc.
+ *
+ * Getter: If HTML was not set, the plain text will be upconverted to HTML.
+ *
+ * Setter: Setting HTML data will set descriptionText to the
+ * downconverted pretty-printed plain text.
+ */
+ attribute AUTF8String descriptionHTML;
+
+ // event priority
+ attribute short priority;
+ attribute AUTF8String privacy;
+
+ // status of the event
+ attribute AUTF8String status;
+
+ // ical interop; writing this means parsing
+ // the ical string into this event
+ attribute AUTF8String icalString;
+
+ // an icalComponent for this item, suitable for serialization.
+ // the icalComponent returned is not live: changes in it or this
+ // item will not be reflected in the other.
+ attribute calIIcalComponent icalComponent;
+
+ //
+ // alarms
+ //
+
+ /**
+ * Get all alarms assigned to this item
+ *
+ * @param aAlarms The array of calIAlarms
+ */
+ Array<calIAlarm> getAlarms();
+
+ /**
+ * Add an alarm to the item
+ *
+ * @param aAlarm The calIAlarm to add
+ */
+ void addAlarm(in calIAlarm aAlarm);
+
+ /**
+ * Delete an alarm from the item
+ *
+ * @param aAlarm The calIAlarm to delete
+ */
+ void deleteAlarm(in calIAlarm aAlarm);
+
+ /**
+ * Clear all alarms from the item
+ */
+ void clearAlarms();
+
+ // The last time this alarm was fired and acknowledged by the user; coerced to UTC.
+ attribute calIDateTime alarmLastAck;
+
+ //
+ // recurrence
+ //
+ attribute calIRecurrenceInfo recurrenceInfo;
+ readonly attribute calIDateTime recurrenceStartDate;
+
+ //
+ // All event properties are stored in a property bag;
+ // some number of these are "promoted" to top-level
+ // accessor attributes. For example, "SUMMARY" is
+ // promoted to the top-level "title" attribute.
+ //
+ // If you use the has/get/set/deleteProperty
+ // methods, property names are case-insensitive.
+ //
+ // For purposes of ICS serialization, all property names in
+ // the hashbag are in uppercase.
+ //
+ // The isPropertyPromoted() attribute can will indicate
+ // if a particular property is promoted or not, for
+ // serialization purposes.
+ //
+
+ // Note that if this item is a proxy, then any requests for
+ // non-existent properties will be forward to the parent item.
+
+ // some other properties that may exist:
+ //
+ // 'description' - description (string)
+ // 'location' - location (string)
+ // 'categories' - categories (string)
+ // 'syncId' - sync id (string)
+ // 'inviteEmailAddress' - string
+ // alarmLength/alarmUnits/alarmEmailAddress/lastAlarmAck
+ // recurInterval/recurCount/recurWeekdays/recurWeeknumber
+
+ // these forward to an internal property bag; implemented here, so we can
+ // do access control on set/delete to have control over mutability.
+ // Each inner array has two elements: a string and a nsIVariant.
+ readonly attribute Array<Array<jsval> > properties;
+ boolean hasProperty(in AString name);
+
+ /**
+ * Gets a particular property.
+ * Objects passed back are still owned by the item, e.g. if callers need to
+ * store or modify a calIDateTime they must clone it.
+ */
+ nsIVariant getProperty(in AString name);
+
+ /**
+ * Sets a particular property.
+ * Ownership of objects gets passed to the item, e.g. callers must not
+ * modify a calIDateTime after it's been passed to an item.
+ *
+ * @warning this reflects the current implementation
+ * xxx todo: rethink whether it's more sensible to store
+ * clones in calItemBase.
+ */
+ void setProperty(in AString name, in nsIVariant value);
+
+ // will not throw an error if you delete a property that doesn't exist
+ void deleteProperty(in AString name);
+
+ // returns true if the given property is promoted to some
+ // top-level attribute (e.g. id or title)
+ boolean isPropertyPromoted(in AString name);
+
+ /**
+ * Returns a particular parameter value for a property, or null if the
+ * parameter does not exist. If the property does not exist, throws.
+ *
+ * @param aPropertyName the name of the property
+ * @param aParameterName the name of the parameter on the property
+ */
+ AString getPropertyParameter(in AString aPropertyName,
+ in AString aParameterName);
+
+ /**
+ * Checks if the given property has the given parameter.
+ *
+ * @param aPropertyName The name of the property.
+ * @param aParameterName The name of the parameter on the property.
+ * @return True, if the parameter exists on the property
+ */
+ boolean hasPropertyParameter(in AString aPropertyName,
+ in AString aParameterName);
+
+ /**
+ * Sets a particular parameter value for a property, or unsets if null is
+ * passed. If the property does not exist, throws.
+ *
+ * @param aPropertyName The name of the property
+ * @param aParameterName The name of the parameter on the property
+ * @param aParameterValue The value of the parameter to set
+ */
+ void setPropertyParameter(in AString aPropertyName,
+ in AString aParameterName,
+ in AUTF8String aParameterValue);
+
+ /**
+ * Returns the names of all the parameters set on the given property.
+ *
+ * @param aPropertyName {AString} The name of the property.
+ * @return {Array<AString} The parameter names.
+ */
+ Array<AString> getParameterNames(in AString aPropertyName);
+
+ /**
+ * The organizer (originator) of the item. We will likely not
+ * honour or preserve all fields in the calIAttendee passed around here.
+ * A base class like calIPerson might be more appropriate here, if we ever
+ * grow one.
+ */
+ attribute calIAttendee organizer;
+
+ //
+ // Attendees
+ //
+
+ // The array returned here is not live; it will not reflect calls to
+ // removeAttendee/addAttendee that follow the call to getAttendees.
+ Array<calIAttendee> getAttendees();
+
+ /**
+ * getAttendeeById's matching is done in a case-insensitive manner to handle
+ * places where "MAILTO:" or similar properties are capitalized arbitrarily
+ * by different calendar clients.
+ */
+ calIAttendee getAttendeeById(in AUTF8String id);
+ void addAttendee(in calIAttendee attendee);
+ void removeAttendee(in calIAttendee attendee);
+ void removeAllAttendees();
+
+ //
+ // Attachments
+ //
+ Array<calIAttachment> getAttachments();
+ void addAttachment(in calIAttachment attachment);
+ void removeAttachment(in calIAttachment attachment);
+ void removeAllAttachments();
+
+ //
+ // Categories
+ //
+
+ /**
+ * Gets the array of categories this item belongs to.
+ */
+ Array<AString> getCategories();
+
+ /**
+ * Sets the array of categories this item belongs to.
+ */
+ void setCategories(in Array<AString> aCategories);
+
+ //
+ // Relations
+ //
+
+ /**
+ * This gives back every relation where the item is neither the owner of the
+ * relation nor the referred relation
+ */
+ Array<calIRelation> getRelations();
+
+ /**
+ * Adds a relation to the item
+ */
+ void addRelation(in calIRelation relation);
+
+ /**
+ * Removes the relation for this item and the referred item
+ */
+ void removeRelation(in calIRelation relation);
+
+ /**
+ * Removes every relation for this item (in this items and also where it is referred
+ */
+ void removeAllRelations();
+
+ // Occurrence querying
+ //
+
+ /**
+ * Return a list of occurrences of this item between the given dates. The items
+ * returned are the same type as this one, as proxies.
+ */
+ Array<calIItemBase> getOccurrencesBetween(in calIDateTime aStartDate, in calIDateTime aEndDate);
+
+ /**
+ * If this item is a proxy or overridden item, parentItem will point upwards
+ * to our parent. Otherwise, it will point to this.
+ * parentItem can thus always be used for modifyItem() calls
+ * to providers.
+ */
+ attribute calIItemBase parentItem;
+
+ /**
+ * The recurrence ID, a.k.a. DTSTART-of-calculated-occurrence,
+ * or null if this isn't an occurrence.
+ * Be conservative about setting this. It isn't marked as such, but
+ * consider it as readonly.
+ */
+ attribute calIDateTime recurrenceId;
+};
diff --git a/comm/calendar/base/public/calIItipItem.idl b/comm/calendar/base/public/calIItipItem.idl
new file mode 100644
index 0000000000..acb6683f1f
--- /dev/null
+++ b/comm/calendar/base/public/calIItipItem.idl
@@ -0,0 +1,112 @@
+/* -*- Mode: idl; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calICalendar;
+
+/**
+ * calIItipItem is an interface used to carry information between the mime
+ * parser, the imip-bar UI, and the iTIP processor. It encapsulates a list of
+ * calIItemBase objects and provides specialized iTIP methods for those items.
+ */
+[scriptable, uuid(7539c158-c30d-41d0-90e9-41d315ac3eb1)]
+interface calIItipItem : nsISupports
+{
+ /**
+ * Initializes the item with an ics string
+ * @param - in parameter - AString of ical Data
+ */
+ void init(in AUTF8String icalData);
+
+ /**
+ * Creates a new calItipItem with the same attributes as the one that
+ * clone() is called upon.
+ */
+ calIItipItem clone();
+
+ /**
+ * Attribute: isSend - set to TRUE when sending this item to initiate an
+ * iMIP communication. This will be used by the iTIP processor to route
+ * the item directly to the email subsystem so that communication can be
+ * initiated. For example, if you are Sending a REQUEST, you would set
+ * this flag, and send the iTIP Item into the iTIP processor, which would
+ * handle everything else.
+ */
+ attribute boolean isSend;
+
+ /**
+ * Attribute: sender - set to the email address of the sender if part of an
+ * iMIP communication.
+ */
+ attribute AUTF8String sender;
+
+ /**
+ * Attribute: receivedMethod - method the iTIP item had upon receipt
+ */
+ attribute AUTF8String receivedMethod;
+
+ /**
+ * Attribute: responseMethod - method that the protocol handler (or the
+ * user) decides to use to respond to the iTIP item (could be COUNTER,
+ * REPLY, DECLINECOUNTER, etc)
+ */
+ attribute AUTF8String responseMethod;
+
+ /**
+ * Attribute: autoResponse Set to one of the three constants below
+ */
+ attribute unsigned long autoResponse;
+
+ /**
+ * Used to tell the iTIP processor to use an automatic response when
+ * handling this iTIP item
+ */
+ const unsigned long AUTO = 0;
+
+ /**
+ * Used to tell the iTIP processor to allow the user to edit the response
+ */
+ const unsigned long USER = 1;
+
+ /**
+ * Used to tell the iTIP processor not to respond at all.
+ */
+ const unsigned long NONE = 2;
+
+ /**
+ * Attribute: targetCalendar - the calendar that this thing should be
+ * stored in, if it should be stored onto a calendar.
+ */
+ attribute calICalendar targetCalendar;
+
+ /**
+ * The identity this item was received on. Helps to determine which
+ * attendee to manipulate. This should be the full email address of the
+ * attendee that is considered to be the local user.
+ */
+ attribute AUTF8String identity;
+
+ /**
+ * localStatus: The response that the user has made to the invitation in
+ * this ItipItem.
+ */
+ attribute AUTF8String localStatus;
+
+ /**
+ * Get the list of items that are encapsulated in this calIItipItem
+ * @returns An array of calIItemBase items that are inside this
+ * calIItipItem
+ */
+ Array<calIItemBase> getItemList();
+
+ /**
+ * Modifies the state of the given attendee in the item's ics
+ * @param attendeeId - AString containing attendee address
+ * @param status - AString containing the new attendee status
+ */
+ void setAttendeeStatus(in AString attendeeId, in AString status);
+};
diff --git a/comm/calendar/base/public/calIItipTransport.idl b/comm/calendar/base/public/calIItipTransport.idl
new file mode 100644
index 0000000000..72f6b7b63d
--- /dev/null
+++ b/comm/calendar/base/public/calIItipTransport.idl
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIItipItem;
+interface calIAttendee;
+interface calIDateTime;
+
+/**
+ * calIItipTransport is a generic transport interface that is implemented
+ * by transports (eg: email, XMPP, etc.) wishing to send calIItipItems
+ */
+[scriptable, uuid(caedabb9-d886-4814-ada5-a5636d2fb939)]
+interface calIItipTransport : nsISupports
+{
+ /**
+ * Scheme to be used to prefix attendees. For example, the Email transport
+ * should return "mailto".
+ */
+ readonly attribute AUTF8String scheme;
+
+ /**
+ * Sending identity. This can be set to change the "sender" identity from
+ * defaultIdentity above.
+ */
+ attribute AUTF8String senderAddress;
+
+ /**
+ * Type of the transport: email, xmpp, etc.
+ */
+ readonly attribute AUTF8String type;
+
+ /**
+ * Sends a calIItipItem to the recipients using the specified title and
+ * alternative representation. If a calIItipItem is attached, then an ICS
+ * representation of those objects are generated and attached to the email.
+ * If the calIItipItem is null, then the item(s) is sent without any
+ * text/calendar mime part.
+ * @param recipientArray array of recipients
+ * @param calIItipItem set of calIItems encapsulated as calIItipItems
+ * @param sender the attendee the calIItipItem is coming from.
+ */
+ boolean sendItems(in Array<calIAttendee> recipientArray,
+ in calIItipItem item,
+ in calIAttendee sender);
+};
diff --git a/comm/calendar/base/public/calIOperation.idl b/comm/calendar/base/public/calIOperation.idl
new file mode 100644
index 0000000000..92f3e8bb8d
--- /dev/null
+++ b/comm/calendar/base/public/calIOperation.idl
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsIVariant.idl"
+
+[scriptable, uuid(B96C2997-7AAA-4619-AD48-B7EBD9236C93)]
+interface calIOperation : nsISupports
+{
+ /**
+ * Id for easy management of pending requests.
+ */
+ readonly attribute AUTF8String id;
+
+ /**
+ * Determines whether the request is pending, i.e. has not been completed.
+ */
+ readonly attribute boolean isPending;
+
+ /**
+ * Status of the request, e.g. NS_OK while pending or after successful
+ * completion, or NS_ERROR_FAILED when failed.
+ */
+ readonly attribute nsIVariant status;
+
+ /**
+ * Cancels a pending request and changes status.
+ * @param aStatus operation status to be set;
+ * defaults to calIErrors.OPERATION_CANCELLED if null
+ */
+ void cancel([optional] in nsIVariant aStatus);
+};
+
+[scriptable, uuid(1FA39726-63D2-440c-A464-296D2822B9DA)]
+interface calIGenericOperationListener : nsISupports
+{
+ /**
+ * Generic callback receiving result.
+ * Results may appear in multiple calls, i.e. callees have to collect
+ * until isPending is false.
+ *
+ * @param aOperation operation object
+ * @param aResult result or null in case of an error
+ */
+ void onResult(in calIOperation aOperation, in nsIVariant aResult);
+};
diff --git a/comm/calendar/base/public/calIPeriod.idl b/comm/calendar/base/public/calIPeriod.idl
new file mode 100644
index 0000000000..d11f0ebdb3
--- /dev/null
+++ b/comm/calendar/base/public/calIPeriod.idl
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIDateTime;
+interface calIDuration;
+
+[scriptable,uuid(ace2a74c-bd08-476f-be8b-6565abc50339)]
+interface calIPeriod : nsISupports
+{
+ attribute jsval icalPeriod;
+
+ /**
+ * isMutable is true if this instance is modifiable.
+ * If isMutable is false, any attempts to modify
+ * the object will throw NS_ERROR_OBJECT_IS_IMMUTABLE.
+ */
+ readonly attribute boolean isMutable;
+
+ /**
+ * Make this calIPeriod instance immutable.
+ */
+ void makeImmutable();
+
+ /**
+ * Clone this calIPeriod instance into a new
+ * mutable object.
+ */
+ calIPeriod clone();
+
+ /**
+ * The start datetime of this period
+ */
+ attribute calIDateTime start;
+
+ /**
+ * The end datetime of this period
+ */
+ attribute calIDateTime end;
+
+ /**
+ * The duration, equal to end-start
+ */
+ readonly attribute calIDuration duration;
+
+
+ /**
+ * Return a string representation of this instance.
+ */
+ AUTF8String toString();
+
+ /**
+ * This object as an iCalendar DURATION string
+ */
+ attribute ACString icalString;
+};
diff --git a/comm/calendar/base/public/calIRecurrenceDate.idl b/comm/calendar/base/public/calIRecurrenceDate.idl
new file mode 100644
index 0000000000..33f1d4b482
--- /dev/null
+++ b/comm/calendar/base/public/calIRecurrenceDate.idl
@@ -0,0 +1,24 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+#include "calIRecurrenceItem.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+
+interface calIIcalProperty;
+
+// an interface implementing a RDATE or EXDATE
+
+[scriptable, uuid(c5b331d4-b470-475b-9497-db9e2731e559)]
+interface calIRecurrenceDate : calIRecurrenceItem
+{
+ //
+ // recurrence date set
+ //
+ attribute calIDateTime date;
+};
diff --git a/comm/calendar/base/public/calIRecurrenceInfo.idl b/comm/calendar/base/public/calIRecurrenceInfo.idl
new file mode 100644
index 0000000000..0e21298c47
--- /dev/null
+++ b/comm/calendar/base/public/calIRecurrenceInfo.idl
@@ -0,0 +1,184 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+
+interface calIRecurrenceItem;
+
+interface calIIcalProperty;
+
+[scriptable, uuid(8ca5db89-2583-4f0c-b845-4a6d2f229efd)]
+interface calIRecurrenceInfo : nsISupports
+{
+ // returns true if this thing is able to be modified;
+ // if the item is not mutable, attempts to modify
+ // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE
+ readonly attribute boolean isMutable;
+
+ // makes this item immutable
+ void makeImmutable();
+
+ // clone always returns a mutable event
+ calIRecurrenceInfo clone();
+
+ // initialize this with the item for which this recurrence
+ // applies, so that the start date can be tracked
+ attribute calIItemBase item;
+
+ /**
+ * The start date of an item is directly referenced by parts of calIRecurrenceInfo,
+ * thus changing the former without adjusting the latter would break the internal structure.
+ * This method provides the necessary functionality. There's no need to call it manually
+ * after writing to the start date of an item, since it's called automatically in the
+ * appropriate setter of an item.
+ */
+ void onStartDateChange(in calIDateTime aNewStartTime, in calIDateTime aOldStartTime);
+
+ /**
+ * If the base item's UID changes, this implicitly has to change all overridden items' UID, too.
+ *
+ * @param id new UID
+ */
+ void onIdChange(in AUTF8String aNewId);
+
+ /**
+ * The end point of the last occurrence, to avoid calculating occurrences for
+ * items that have finished recurring. If the object is mutable, or the
+ * recurrence is not finite, this value is the maximum possible PRTime.
+ */
+ readonly attribute PRTime recurrenceEndDate;
+
+ Array<calIRecurrenceItem> getRecurrenceItems();
+ /**
+ * Set of recurrence items; the order of these matters.
+ */
+ void setRecurrenceItems(in Array<calIRecurrenceItem> aItems);
+
+ unsigned long countRecurrenceItems();
+ void clearRecurrenceItems();
+ void appendRecurrenceItem(in calIRecurrenceItem aItem);
+
+ calIRecurrenceItem getRecurrenceItemAt(in unsigned long aIndex);
+ void deleteRecurrenceItemAt(in unsigned long aIndex);
+ void deleteRecurrenceItem(in calIRecurrenceItem aItem);
+ // inserts the item at the given index, pushing the item that was previously there forward
+ void insertRecurrenceItemAt(in calIRecurrenceItem aItem, in unsigned long aIndex);
+
+ /**
+ * isFinite is true if the recurrence items specify a finite number
+ * of occurrences. This is useful for UI and for possibly other users.
+ */
+ readonly attribute boolean isFinite;
+
+ /**
+ * This is a shortcut to appending or removing a single negative date
+ * assertion. aRecurrenceId needs to be a normal recurrence id, it may not be
+ * RDATE.
+ */
+ void removeOccurrenceAt(in calIDateTime aRecurrenceId);
+ void restoreOccurrenceAt(in calIDateTime aRecurrenceId);
+
+ /*
+ * exceptions
+ */
+
+ /**
+ * Modify an a particular occurrence with the given exception proxy
+ * item. If the recurrenceId isn't an already existing exception item,
+ * a new exception is added. Otherwise, the existing exception
+ * is modified.
+ *
+ * The item's parentItem must be equal to this RecurrenceInfo's
+ * item. <-- XXX check this, compare by calendar/id only
+ *
+ * @param anItem exceptional/overridden item
+ * @param aTakeOverOwnership whether the recurrence info object can take over
+ * the item or needs to clone it
+ */
+ void modifyException(in calIItemBase anItem, in boolean aTakeOverOwnership);
+
+ /**
+ * Return an existing exception item for the given recurrence ID.
+ * If an exception does not exist, null is returned.
+ */
+ calIItemBase getExceptionFor(in calIDateTime aRecurrenceId);
+
+ /**
+ * Removes an exception item for the given recurrence ID, if
+ * any exist.
+ */
+ void removeExceptionFor(in calIDateTime aRecurrenceId);
+
+ /**
+ * Returns a list of all recurrence ids that have exceptions.
+ */
+ Array<calIDateTime> getExceptionIds();
+
+ /*
+ * Recurrence calculation
+ */
+
+ /*
+ * Get the occurrence at the given recurrence ID; if there is no
+ * exception, then create a new proxy object with the normal occurrence.
+ * Otherwise, return the exception.
+ *
+ * @param aRecurrenceId The recurrence ID to get the occurrence for.
+ * @return The occurrence or exception corresponding to the id
+ */
+ calIItemBase getOccurrenceFor(in calIDateTime aRecurrenceId);
+
+ /**
+ * Return the chronologically next occurrence after aTime. This takes
+ * exceptions and EXDATE/RDATEs into account.
+ *
+ * @param aTime The (exclusive) date to start searching.
+ * @return The next occurrence, or null if there is none.
+ */
+ calIItemBase getNextOccurrence(in calIDateTime aTime);
+
+ /**
+ * Return the chronologically previous occurrence after aTime. This takes
+ * exceptions and EXDATE/RDATEs into account.
+ *
+ * @param aTime The (exclusive) date to start searching.
+ * @return The previous occurrence, or null if there is none.
+ */
+ calIItemBase getPreviousOccurrence(in calIDateTime aTime);
+
+ /**
+ * Return an array of calIDateTime representing all start times of this event
+ * between start (inclusive) and end (non-inclusive). Exceptions are taken
+ * into account.
+ *
+ * @param aRangeStart The (inclusive) date to start searching.
+ * @param aRangeEnd The (exclusive) date to end searching.
+ * @param aMaxCount The maximum number of dates to return
+ *
+ * @param aCount The number of dates returned.
+ * @return The array of dates.
+ */
+ Array<calIDateTime> getOccurrenceDates(in calIDateTime aRangeStart,
+ in calIDateTime aRangeEnd,
+ in unsigned long aMaxCount);
+
+ /**
+ * Return an array of calIItemBase representing all occurrences of this event
+ * between start (inclusive) and end (non-inclusive). Exceptions are taken
+ * into account.
+ *
+ * @param aRangeStart The (inclusive) date to start searching.
+ * @param aRangeEnd The (exclusive) date to end searching.
+ * @param aMaxCount The maximum number of occurrences to return
+ *
+ * @param aCount The number of occurrences returned.
+ * @return The array of occurrences.
+ */
+ Array<calIItemBase> getOccurrences(in calIDateTime aRangeStart,
+ in calIDateTime aRangeEnd,
+ in unsigned long aMaxCount);
+};
diff --git a/comm/calendar/base/public/calIRecurrenceItem.idl b/comm/calendar/base/public/calIRecurrenceItem.idl
new file mode 100644
index 0000000000..9e2b83dadb
--- /dev/null
+++ b/comm/calendar/base/public/calIRecurrenceItem.idl
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+
+interface calIIcalProperty;
+
+[scriptable, uuid(918a243b-d887-41b0-8b4b-9cd56a9dd55f)]
+interface calIRecurrenceItem : nsISupports
+{
+ // returns true if this thing is able to be modified;
+ // if the item is not mutable, attempts to modify
+ // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE
+ readonly attribute boolean isMutable;
+
+ // makes this item immutable
+ void makeImmutable();
+
+ // clone always returns a mutable event
+ calIRecurrenceItem clone();
+
+ // defaults to false; if true, this item is to be interpreted
+ // as a negative rule (e.g. exceptions instead of rdates)
+ attribute boolean isNegative;
+
+ // returns whether this item has a finite number of dates
+ // or not (e.g. a rule with no end date)
+ readonly attribute boolean isFinite;
+
+ /**
+ * Search for the next occurrence after aTime and return its recurrence id.
+ * aRecurrenceId must be the recurrence id of an occurrence to search after.
+ * If this item has an unsupported FREQ value ("SECONDLY" or "MINUTELY"), null
+ * is returned.
+ *
+ * @require (aTime >= aRecurrenceId)
+ * @param aRecurrenceId The recurrence id to start searching at.
+ * @param aTime The earliest time to find the occurrence after.
+ */
+ calIDateTime getNextOccurrence(in calIDateTime aRecurrenceId,
+ in calIDateTime aTime);
+
+ /**
+ * Return an array of calIDateTime of the start of all occurrences of
+ * this event starting at aStartTime, between rangeStart and an
+ * optional rangeEnd. If this item has an unsupported FREQ value ("SECONDLY"
+ * or "MINUTELY"), an empty array is returned.
+ */
+ Array<calIDateTime> getOccurrences(in calIDateTime aStartTime,
+ in calIDateTime aRangeStart,
+ in calIDateTime aRangeEnd,
+ in unsigned long aMaxCount);
+
+ attribute calIIcalProperty icalProperty;
+ attribute AUTF8String icalString;
+};
diff --git a/comm/calendar/base/public/calIRecurrenceRule.idl b/comm/calendar/base/public/calIRecurrenceRule.idl
new file mode 100644
index 0000000000..8694036a8c
--- /dev/null
+++ b/comm/calendar/base/public/calIRecurrenceRule.idl
@@ -0,0 +1,53 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+#include "calIRecurrenceItem.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+
+// an interface implementing a RRULE
+
+[scriptable, uuid(e965a91a-49fa-41b5-b668-1a824a73bdbf)]
+interface calIRecurrenceRule : calIRecurrenceItem
+{
+ //
+ // rule-based recurrence
+ //
+
+ // null/"", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY",
+ // "MONTHLY", "YEARLY"
+ attribute AUTF8String type;
+
+ // repeat every N of type
+ attribute long interval;
+
+ // These two are mutually exclusive; whichever is set
+ // invalidates the other. It's only valid to read the one
+ // that was set; the other will throw NS_ERROR_FAILURE. Use
+ // isByCount to figure out whether count or untilDate is valid.
+ // Setting count to -1 or untilDate to null indicates infinite
+ // recurrence.
+ attribute long count;
+ attribute calIDateTime untilDate;
+
+ // if this isn't infinite recurrence, this flag indicates whether
+ // it was set by count or not
+ readonly attribute boolean isByCount;
+
+ // The week start for this rule, used for certain calculations. This is a
+ // value from 0=Sunday to 6=Saturday.
+ attribute short weekStart;
+
+ // the components defining the recurrence
+ // "BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY",
+ // "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH",
+ // "BYSETPOS"
+ Array<short> getComponent (in AUTF8String aComponentType);
+ void setComponent (in AUTF8String aComponentType, in Array<short> aValues);
+
+};
diff --git a/comm/calendar/base/public/calIRelation.idl b/comm/calendar/base/public/calIRelation.idl
new file mode 100644
index 0000000000..ee73dc9aaa
--- /dev/null
+++ b/comm/calendar/base/public/calIRelation.idl
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIIcalProperty;
+interface calIItemBase;
+
+[scriptable,uuid(77f0820a-2b49-4c8e-86bf-2b6bda46e391)]
+interface calIRelation : nsISupports
+{
+ /**
+ * The type of the relation between the items:
+ * PARENT
+ * CHILD
+ * SIBLING
+ */
+ attribute AUTF8String relType;
+
+ /**
+ * The id of the related item
+ **/
+
+ attribute AUTF8String relId;
+
+ /**
+ * The calIIcalProperty corresponding to this object. Can be used for
+ * serializing/unserializing from ics files.
+ */
+ attribute calIIcalProperty icalProperty;
+ attribute AUTF8String icalString;
+
+ /**
+ * For accessing additional parameters, such as x-params.
+ */
+ AUTF8String getParameter(in AString name);
+ void setParameter(in AString name, in AUTF8String value);
+ void deleteParameter(in AString name);
+
+ /**
+ * Clone this calIRelation instance into a new object.
+ */
+ calIRelation clone();
+};
diff --git a/comm/calendar/base/public/calISchedulingSupport.idl b/comm/calendar/base/public/calISchedulingSupport.idl
new file mode 100644
index 0000000000..123ca35192
--- /dev/null
+++ b/comm/calendar/base/public/calISchedulingSupport.idl
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+interface calIItemBase;
+interface calIAttendee;
+
+/**
+ * Accesses scheduling specific information of calendar items.
+ * Implementation by providers is optional.
+ */
+[scriptable, uuid(9221e243-c97e-4c5f-9e00-5d7d3521bb44)]
+interface calISchedulingSupport : nsISupports
+{
+ /**
+ * Tests whether the passed item corresponds to an invitation, e.g.
+ * the CUA or server has placed it in the calendar.
+ *
+ * @param aItem Item to be tested.
+ * @return Whether the passed item corresponds to an invitation.
+ */
+ boolean isInvitation(in calIItemBase aItem);
+
+ /**
+ * Gets the invited attendee if the passed item corresponds to
+ * an invitation. UI code will use that attendee to modify e.g. PARTSTAT.
+ * If isInvitation returns true, getInvitedAttendee must return
+ * an attendee. If isInvitation is false, getInvitedAttendee may return
+ * an attendee in case the organizer (and owner of the calendar) has
+ * invited himself.
+ *
+ * @param aItem Invitation item.
+ * @return Attendee object, or null.
+ */
+ calIAttendee getInvitedAttendee(in calIItemBase aItem);
+
+ /**
+ * Checks whether the provider keeps track of sending out the proper
+ * iTIP/iMIP message for a particular item.
+ *
+ * @param aMethod a iTIP method
+ * @param aItem an item that has been modified/deleted etc.
+ * @return true, if the provider keeps track of sending out passed message
+ */
+ boolean canNotify(in AUTF8String aMethod, in calIItemBase aItem);
+};
diff --git a/comm/calendar/base/public/calIStartupService.idl b/comm/calendar/base/public/calIStartupService.idl
new file mode 100644
index 0000000000..c387bf5a4b
--- /dev/null
+++ b/comm/calendar/base/public/calIStartupService.idl
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+#include "calIOperation.idl"
+
+/**
+ * Interface that can be used on services that need to be started up and shut
+ * down. The service needs to be registered within calStartupService.js, so this
+ * is only useful from within calendar code. If you want calendar code to be
+ * fully initialized, listen to "calendar-startup-done" via nsIObserverService.
+ */
+[scriptable, uuid(99d52094-37f9-4c81-9c55-32fbeb6a79cf)]
+interface calIStartupService: nsISupports
+{
+ /**
+ * Function called when the service should be started
+ *
+ * @param completeListener The listener to call on startup completion.
+ */
+ void startup(in calIGenericOperationListener completeListener);
+
+ /**
+ * Function called when the service should be shut down.
+ *
+ * @param completeListener The listener to call on shutdown completion.
+ */
+ void shutdown(in calIGenericOperationListener completeListener);
+};
diff --git a/comm/calendar/base/public/calIStatusObserver.idl b/comm/calendar/base/public/calIStatusObserver.idl
new file mode 100644
index 0000000000..2dc48e14c4
--- /dev/null
+++ b/comm/calendar/base/public/calIStatusObserver.idl
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+interface calICalendar;
+interface nsIDOMChromeWindow;
+
+[scriptable, uuid(60160f68-4514-41b4-a19d-2f2cf0143426)]
+interface calIStatusObserver : nsISupports
+{
+
+ void initialize(in nsIDOMChromeWindow aWindow);
+
+ /**
+ * Starts the display of an operation to check a series of calendars
+ * This operation may either be determined or undetermined
+ * @param aProgressMode An integer value that can accept DETERMINED_PROGRESS,
+ * UNDETERMINED_PROGRESS or NO_PROGRESS
+ * @param aCalendarsCount If the first parameter is DETERMINED_PROGRESS
+ * aCalendarCount is the number of Calendars
+ * which completion is to be displayed
+ */
+ void startMeteors(in unsigned long aProgressMode, in unsigned long aCalendarCount);
+
+ /**
+ * stops the display of an progressed operation
+ */
+ void stopMeteors();
+
+ /**
+ * increments the display value denoting that a calendar has been processed
+ */
+ void calendarCompleted(in calICalendar aCalendar);
+
+ /**
+ * @return An integer value denoting whether a progress is running or not;
+ * if it returns DETERMINED_PROGRESS a determined progress
+ is running;
+ * if it returns UNDETERMINED_PROGRESS an undetermined progress
+ is running;
+ * if it returns NO_PROGRESS no Progress is running.
+ */
+ readonly attribute unsigned long spinning;
+
+ /**
+ * A constant that denotes that no operation is running
+ */
+ const unsigned long NO_PROGRESS = 0;
+
+ /**
+ * A constant that refers to whether an operation is determined
+ */
+ const unsigned long DETERMINED_PROGRESS = 1;
+
+ /**
+ * A constant that refers to whether an operation is undetermined
+ */
+ const unsigned long UNDETERMINED_PROGRESS = 2;
+};
diff --git a/comm/calendar/base/public/calITimezone.idl b/comm/calendar/base/public/calITimezone.idl
new file mode 100644
index 0000000000..a6d6135abd
--- /dev/null
+++ b/comm/calendar/base/public/calITimezone.idl
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIIcalComponent;
+
+[scriptable, uuid(d79161e7-0db9-427d-a0c3-27e0db3b030f)]
+interface calITimezone : nsISupports
+{
+ /**
+ * VTIMEZONE ical component, null if floating or UTC.
+ */
+ readonly attribute calIIcalComponent icalComponent;
+
+ /**
+ * The TZID of this timezone.
+ */
+ readonly attribute AUTF8String tzid;
+
+ /**
+ * Whether this timezone is the "floating" timezone.
+ */
+ readonly attribute boolean isFloating;
+
+ /**
+ * Whether this is the "UTC" timezone.
+ */
+ readonly attribute boolean isUTC;
+
+ /**
+ * Localized name of the timezone; falls back to TZID if unknown.
+ */
+ readonly attribute AString displayName;
+
+ /**
+ * For debugging purposes.
+ *
+ * @return "UTC", "floating" or component's ical representation
+ */
+ AUTF8String toString();
+};
diff --git a/comm/calendar/base/public/calITimezoneDatabase.idl b/comm/calendar/base/public/calITimezoneDatabase.idl
new file mode 100644
index 0000000000..6c9d0c0505
--- /dev/null
+++ b/comm/calendar/base/public/calITimezoneDatabase.idl
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+/**
+ * Provides access to raw iCalendar timezone definitions. Intended for providing
+ * a backing database for a calITimezoneService; most consumers will want to use
+ * calITimezoneService instead.
+ */
+[scriptable, uuid(dcace7e1-9600-47ba-a27e-b3bc8222192a)]
+interface calITimezoneDatabase : nsISupports
+{
+ /**
+ * The version of the IANA Time Zone Database provided.
+ */
+ readonly attribute AUTF8String version;
+
+ /**
+ * Get all supported canonical timezone IDs. This does not include link
+ * timezone IDs and should only be used to provide users with a list of
+ * timezones they may select, not to restrict supported timezone values.
+ *
+ * @return a list of supported canonical timezone IDs
+ */
+ Array<AUTF8String> getCanonicalTimezoneIds();
+
+ /**
+ * Get an iCalendar timezone definition by timezone ID.
+ *
+ * @param tzid timezone ID for which to return definition
+ * @return a string containing an ical VTIMEZONE definition, or
+ * the empty string if the timezone ID is not recognized
+ */
+ AUTF8String getTimezoneDefinition(in AUTF8String tzid);
+};
diff --git a/comm/calendar/base/public/calITimezoneService.idl b/comm/calendar/base/public/calITimezoneService.idl
new file mode 100644
index 0000000000..6c7ef24112
--- /dev/null
+++ b/comm/calendar/base/public/calITimezoneService.idl
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calITimezone;
+
+/**
+ * Provides access to timezone definitions from the IANA Time Zone Database.
+ */
+[scriptable, uuid(ab1bfe6a-ee95-4038-b594-34aeeda9911a)]
+interface calITimezoneService : nsISupports
+{
+ /**
+ * All canonical timezone IDs provided by the current IANA Time Zone
+ * Database; intended to provide a list of selectable timezones, not to
+ * restrict the list of valid timezones.
+ */
+ readonly attribute Array<AUTF8String> timezoneIds;
+
+ /**
+ * The version of the IANA Time Zone Database provided.
+ */
+ readonly attribute AUTF8String version;
+
+ /**
+ * Get a timezone definition by timezone ID.
+ *
+ * @param tzid timezone ID for which to return definition
+ * @return a timezone object, or null if ID is not recognized
+ */
+ calITimezone getTimezone(in AUTF8String tzid);
+
+ /**
+ * The definition for the "floating" timezone, in which times are relative
+ * to local time.
+ */
+ readonly attribute calITimezone floating;
+
+ /**
+ * The timezone definition for Coordinated Universal Time.
+ */
+ readonly attribute calITimezone UTC;
+
+ /**
+ * Returns the current default timezone for calendars/events.
+ */
+ readonly attribute calITimezone defaultTimezone;
+};
diff --git a/comm/calendar/base/public/calITodo.idl b/comm/calendar/base/public/calITodo.idl
new file mode 100644
index 0000000000..1ed322829d
--- /dev/null
+++ b/comm/calendar/base/public/calITodo.idl
@@ -0,0 +1,72 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "calIItemBase.idl"
+
+//
+// calITodo
+//
+// An interface for a todo item (analogous to a VTODO)
+//
+
+[scriptable, uuid(0a93fdad-8a5c-44e9-8f90-16a6df819e03)]
+interface calITodo : calIItemBase
+{
+ const long CAL_TODO_STATUS_NEEDSACTION = 4;
+ const long CAL_TODO_STATUS_COMPLETED = 5;
+ const long CAL_TODO_STATUS_INPROCESS = 6;
+
+ // as per the rather broken RFC2445,
+
+ // entryDate maps to DTSTART, which is the day
+ // this todo shows up on, if set. (optional).
+ //
+ // dueDate maps to DUE, which is the day
+ // this todo is due, if set. (optional).
+ //
+ // If neither DUE nor DTSTART are set, then
+ // the todo appears "today" until it is completed.
+ //
+ // The completeDate is the date the todo was completed,
+ // or null if it hasn't been completed yet.
+
+ attribute calIDateTime entryDate;
+ attribute calIDateTime dueDate;
+ attribute calIDateTime completedDate;
+ attribute short percentComplete;
+
+ // A todo isCompleted if any of the following is true:
+ // - percentComplete is 100, or
+ // - completedDate is non-null, or
+ // - status is COMPLETED.
+ // Setting isCompleted to true will
+ // - set percentComplete to 100, and
+ // - set completedDate to the current time, if it is not already set, and
+ // - set status to COMPLETED.
+ // Setting isCompleted to false will remove percentComplete, completedDate,
+ // and status properties. (This returns the todo to its state at creation,
+ // in terms of completion-relevant properties.)
+ //
+ // If you would like to take advantage of the full, confusing disaster that
+ // is the RFC2445 VTODO status state space, you can feel free to set the
+ // fields individually, instead of setting isCompleted directly. (And then
+ // hope that whatever else you're talking to has the same set of rules for
+ // determining if something is completed or not.)
+ //
+ // Setting percentComplete, completedDate, or status individually does not
+ // affect any of the others at present. (E.g., setting the percentComplete
+ // from 100 to 50 doesn't clear completedDate, or change status to
+ // IN-PROCESS.) It's not clear that we want any more magic than a simple
+ // property to control "all complete" vs "not complete in any way".
+ attribute boolean isCompleted;
+
+ /**
+ * The duration of the todo, which is either set or defined as
+ * dueDate - entryDate.
+ * Please note that null is returned if there is no duration set and entry
+ * Date or dueDate don't exist.
+ */
+ attribute calIDuration duration;
+};
diff --git a/comm/calendar/base/public/calIWeekInfoService.idl b/comm/calendar/base/public/calIWeekInfoService.idl
new file mode 100644
index 0000000000..c688579418
--- /dev/null
+++ b/comm/calendar/base/public/calIWeekInfoService.idl
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface calIDateTime;
+
+/**
+ * This interface will calculate a week title from a given datetime. This
+ * will depends on the users preferences.
+ * Extensions might override the default implementation, in order to
+ * generate week titles aimed at special cases (like weeknumbers for a
+ * schoolyear)
+ */
+[scriptable, uuid(650fd33b-ebf4-46fa-b9ca-dd80b2451498)]
+interface calIWeekInfoService: nsISupports
+{
+ /**
+ * Return the week title. It's meant to be displayed.
+ * (Usually, will return a weeknumber, but might return a string like Q1W4)
+ *
+ * @param dateTime
+ * The dateTime to get the weektitle for
+ * @returns
+ * A string, representing the week title. Will usually be the
+ * week number. Every week (7 days) should get a different string,
+ * but the switch from one week to the next isn't necessarily
+ * on sunday.
+ */
+ AString getWeekTitle(in calIDateTime dateTime);
+
+ /**
+ * Gets the first day of a week of a passed day under consideration
+ * of the preference setting "calendar.week.start"
+ *
+ * @param aDate The dateTime to get get the start of the week for
+ * @return A dateTime-object denoting the first day of the week
+ */
+ calIDateTime getStartOfWeek(in calIDateTime dateTime);
+
+ /**
+ * Gets the last day of a week of a passed day under consideration
+ * of the preference setting "calendar.week.start"
+ *
+ * @param aDate The dateTime to get get the last day of the week for
+ * @return A dateTime-object denoting the last day of the week
+ */
+ calIDateTime getEndOfWeek(in calIDateTime dateTime);
+};
diff --git a/comm/calendar/base/public/moz.build b/comm/calendar/base/public/moz.build
new file mode 100644
index 0000000000..8879b4e63b
--- /dev/null
+++ b/comm/calendar/base/public/moz.build
@@ -0,0 +1,56 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "calIAlarm.idl",
+ "calIAlarmService.idl",
+ "calIAttachment.idl",
+ "calIAttendee.idl",
+ "calICalendar.idl",
+ "calICalendarACLManager.idl",
+ "calICalendarManager.idl",
+ "calICalendarProvider.idl",
+ "calICalendarView.idl",
+ "calICalendarViewController.idl",
+ "calIChangeLog.idl",
+ "calIDateTime.idl",
+ "calIDeletedItems.idl",
+ "calIDuration.idl",
+ "calIErrors.idl",
+ "calIEvent.idl",
+ "calIFreeBusyProvider.idl",
+ "calIIcsParser.idl",
+ "calIIcsSerializer.idl",
+ "calIICSService.idl",
+ "calIImportExport.idl",
+ "calIItemBase.idl",
+ "calIItipItem.idl",
+ "calIItipTransport.idl",
+ "calIOperation.idl",
+ "calIPeriod.idl",
+ "calIRecurrenceDate.idl",
+ "calIRecurrenceInfo.idl",
+ "calIRecurrenceItem.idl",
+ "calIRecurrenceRule.idl",
+ "calIRelation.idl",
+ "calISchedulingSupport.idl",
+ "calIStartupService.idl",
+ "calIStatusObserver.idl",
+ "calITimezone.idl",
+ "calITimezoneDatabase.idl",
+ "calITimezoneService.idl",
+ "calITodo.idl",
+ "calIWeekInfoService.idl",
+]
+
+XPIDL_MODULE = "calbase"
+
+EXPORTS += []
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Internal Components")
+
+with Files("calIAlarm*"):
+ BUG_COMPONENT = ("Calendar", "Alarms")
diff --git a/comm/calendar/base/src/CalAlarm.jsm b/comm/calendar/base/src/CalAlarm.jsm
new file mode 100644
index 0000000000..d6f0faffaa
--- /dev/null
+++ b/comm/calendar/base/src/CalAlarm.jsm
@@ -0,0 +1,693 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalAlarm"];
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalDuration: "resource:///modules/CalDuration.jsm",
+});
+
+const ALARM_RELATED_ABSOLUTE = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+const ALARM_RELATED_START = Ci.calIAlarm.ALARM_RELATED_START;
+const ALARM_RELATED_END = Ci.calIAlarm.ALARM_RELATED_END;
+
+/**
+ * Constructor for `calIAlarm` objects.
+ *
+ * @class
+ * @implements {calIAlarm}
+ * @param {string} [icalString] - Optional iCal string for initializing existing alarms.
+ */
+function CalAlarm(icalString) {
+ this.wrappedJSObject = this;
+ this.mProperties = new Map();
+ this.mPropertyParams = {};
+ this.mAttendees = [];
+ this.mAttachments = [];
+ if (icalString) {
+ this.icalString = icalString;
+ }
+}
+
+CalAlarm.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIAlarm"]),
+ classID: Components.ID("{b8db7c7f-c168-4e11-becb-f26c1c4f5f8f}"),
+
+ mProperties: null,
+ mPropertyParams: null,
+ mAction: null,
+ mAbsoluteDate: null,
+ mOffset: null,
+ mDuration: null,
+ mAttendees: null,
+ mAttachments: null,
+ mSummary: null,
+ mDescription: null,
+ mLastAck: null,
+ mImmutable: false,
+ mRelated: 0,
+ mRepeat: 0,
+
+ /**
+ * calIAlarm
+ */
+
+ ensureMutable() {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+
+ get isMutable() {
+ return !this.mImmutable;
+ },
+
+ makeImmutable() {
+ if (this.mImmutable) {
+ return;
+ }
+
+ const objectMembers = ["mAbsoluteDate", "mOffset", "mDuration", "mLastAck"];
+ for (let member of objectMembers) {
+ if (this[member] && this[member].isMutable) {
+ this[member].makeImmutable();
+ }
+ }
+
+ // Properties
+ for (let propval of this.mProperties.values()) {
+ if (propval?.isMutable) {
+ propval.makeImmutable();
+ }
+ }
+
+ this.mImmutable = true;
+ },
+
+ clone() {
+ let cloned = new CalAlarm();
+
+ cloned.mImmutable = false;
+
+ const simpleMembers = ["mAction", "mSummary", "mDescription", "mRelated", "mRepeat"];
+
+ const arrayMembers = ["mAttendees", "mAttachments"];
+
+ const objectMembers = ["mAbsoluteDate", "mOffset", "mDuration", "mLastAck"];
+
+ for (let member of simpleMembers) {
+ cloned[member] = this[member];
+ }
+
+ for (let member of arrayMembers) {
+ let newArray = [];
+ for (let oldElem of this[member]) {
+ newArray.push(oldElem.clone());
+ }
+ cloned[member] = newArray;
+ }
+
+ for (let member of objectMembers) {
+ if (this[member] && this[member].clone) {
+ cloned[member] = this[member].clone();
+ } else {
+ cloned[member] = this[member];
+ }
+ }
+
+ // X-Props
+ cloned.mProperties = new Map();
+ for (let [name, value] of this.mProperties.entries()) {
+ if (value instanceof lazy.CalDateTime || value instanceof Ci.calIDateTime) {
+ value = value.clone();
+ }
+
+ cloned.mProperties.set(name, value);
+
+ let propBucket = this.mPropertyParams[name];
+ if (propBucket) {
+ let newBucket = {};
+ for (let param in propBucket) {
+ newBucket[param] = propBucket[param];
+ }
+ cloned.mPropertyParams[name] = newBucket;
+ }
+ }
+ return cloned;
+ },
+
+ get related() {
+ return this.mRelated;
+ },
+ set related(aValue) {
+ this.ensureMutable();
+ switch (aValue) {
+ case ALARM_RELATED_ABSOLUTE:
+ this.mOffset = null;
+ break;
+ case ALARM_RELATED_START:
+ case ALARM_RELATED_END:
+ this.mAbsoluteDate = null;
+ break;
+ }
+
+ this.mRelated = aValue;
+ },
+
+ get action() {
+ return this.mAction || "DISPLAY";
+ },
+ set action(aValue) {
+ this.ensureMutable();
+ this.mAction = aValue;
+ },
+
+ get description() {
+ if (this.action == "AUDIO") {
+ return null;
+ }
+ return this.mDescription;
+ },
+ set description(aValue) {
+ this.ensureMutable();
+ this.mDescription = aValue;
+ },
+
+ get summary() {
+ if (this.mAction == "DISPLAY" || this.mAction == "AUDIO") {
+ return null;
+ }
+ return this.mSummary;
+ },
+ set summary(aValue) {
+ this.ensureMutable();
+ this.mSummary = aValue;
+ },
+
+ get offset() {
+ return this.mOffset;
+ },
+ set offset(aValue) {
+ if (aValue && !(aValue instanceof lazy.CalDuration) && !(aValue instanceof Ci.calIDuration)) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (this.related != ALARM_RELATED_START && this.related != ALARM_RELATED_END) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ this.ensureMutable();
+ this.mOffset = aValue;
+ },
+
+ get alarmDate() {
+ return this.mAbsoluteDate;
+ },
+ set alarmDate(aValue) {
+ if (aValue && !(aValue instanceof lazy.CalDateTime) && !(aValue instanceof Ci.calIDateTime)) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (this.related != ALARM_RELATED_ABSOLUTE) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ this.ensureMutable();
+ this.mAbsoluteDate = aValue;
+ },
+
+ get repeat() {
+ if (!this.mDuration) {
+ return 0;
+ }
+ return this.mRepeat || 0;
+ },
+ set repeat(aValue) {
+ this.ensureMutable();
+ if (aValue === null) {
+ this.mRepeat = null;
+ } else {
+ this.mRepeat = parseInt(aValue, 10);
+ if (isNaN(this.mRepeat)) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+ },
+
+ get repeatOffset() {
+ if (!this.mRepeat) {
+ return null;
+ }
+ return this.mDuration;
+ },
+ set repeatOffset(aValue) {
+ this.ensureMutable();
+ if (
+ aValue !== null &&
+ !(aValue instanceof lazy.CalDuration) &&
+ !(aValue instanceof Ci.calIDuration)
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ this.mDuration = aValue;
+ },
+
+ get repeatDate() {
+ if (
+ this.related != ALARM_RELATED_ABSOLUTE ||
+ !this.mAbsoluteDate ||
+ !this.mRepeat ||
+ !this.mDuration
+ ) {
+ return null;
+ }
+
+ let alarmDate = this.mAbsoluteDate.clone();
+
+ // All Day events are handled as 00:00:00
+ alarmDate.isDate = false;
+ alarmDate.addDuration(this.mDuration);
+ return alarmDate;
+ },
+
+ getAttendees() {
+ let attendees;
+ if (this.action == "AUDIO" || this.action == "DISPLAY") {
+ attendees = [];
+ } else {
+ attendees = this.mAttendees.concat([]);
+ }
+ return attendees;
+ },
+
+ addAttendee(aAttendee) {
+ // Make sure its not duplicate
+ this.deleteAttendee(aAttendee);
+
+ // Now check if its valid
+ if (this.action == "AUDIO" || this.action == "DISPLAY") {
+ throw new Error("Alarm type AUDIO/DISPLAY may not have attendees");
+ }
+
+ // And add it (again)
+ this.mAttendees.push(aAttendee);
+ },
+
+ deleteAttendee(aAttendee) {
+ let deleteId = aAttendee.id;
+ for (let i = 0; i < this.mAttendees.length; i++) {
+ if (this.mAttendees[i].id == deleteId) {
+ this.mAttendees.splice(i, 1);
+ break;
+ }
+ }
+ },
+
+ clearAttendees() {
+ this.mAttendees = [];
+ },
+
+ getAttachments() {
+ let attachments;
+ if (this.action == "AUDIO") {
+ attachments = this.mAttachments.length ? [this.mAttachments[0]] : [];
+ } else if (this.action == "DISPLAY") {
+ attachments = [];
+ } else {
+ attachments = this.mAttachments.concat([]);
+ }
+ return attachments;
+ },
+
+ addAttachment(aAttachment) {
+ // Make sure its not duplicate
+ this.deleteAttachment(aAttachment);
+
+ // Now check if its valid
+ if (this.action == "AUDIO" && this.mAttachments.length) {
+ throw new Error("Alarm type AUDIO may only have one attachment");
+ } else if (this.action == "DISPLAY") {
+ throw new Error("Alarm type DISPLAY may not have attachments");
+ }
+
+ // And add it (again)
+ this.mAttachments.push(aAttachment);
+ },
+
+ deleteAttachment(aAttachment) {
+ let deleteHash = aAttachment.hashId;
+ for (let i = 0; i < this.mAttachments.length; i++) {
+ if (this.mAttachments[i].hashId == deleteHash) {
+ this.mAttachments.splice(i, 1);
+ break;
+ }
+ }
+ },
+
+ clearAttachments() {
+ this.mAttachments = [];
+ },
+
+ get icalString() {
+ let comp = this.icalComponent;
+ return comp ? comp.serializeToICS() : "";
+ },
+ set icalString(val) {
+ this.ensureMutable();
+ this.icalComponent = cal.icsService.parseICS(val);
+ },
+
+ promotedProps: {
+ ACTION: "action",
+ TRIGGER: "offset",
+ REPEAT: "repeat",
+ DURATION: "duration",
+ SUMMARY: "summary",
+ DESCRIPTION: "description",
+ "X-MOZ-LASTACK": "lastAck",
+
+ // These have complex setters and will be ignored in setProperty
+ ATTACH: true,
+ ATTENDEE: true,
+ },
+
+ get icalComponent() {
+ let comp = cal.icsService.createIcalComponent("VALARM");
+
+ // Set up action (REQUIRED)
+ let actionProp = cal.icsService.createIcalProperty("ACTION");
+ actionProp.value = this.action;
+ comp.addProperty(actionProp);
+
+ // Set up trigger (REQUIRED)
+ let triggerProp = cal.icsService.createIcalProperty("TRIGGER");
+ if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) {
+ // Set the trigger to a specific datetime
+ triggerProp.setParameter("VALUE", "DATE-TIME");
+ triggerProp.valueAsDatetime = this.mAbsoluteDate.getInTimezone(cal.dtz.UTC);
+ } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) {
+ triggerProp.valueAsIcalString = this.mOffset.icalString;
+ if (this.related == ALARM_RELATED_END) {
+ // An alarm related to the end of the event.
+ triggerProp.setParameter("RELATED", "END");
+ }
+ } else {
+ // No offset or absolute date is not valid.
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ comp.addProperty(triggerProp);
+
+ // Set up repeat and duration (OPTIONAL, but if one exists, the other
+ // MUST also exist)
+ if (this.repeat && this.repeatOffset) {
+ let repeatProp = cal.icsService.createIcalProperty("REPEAT");
+ let durationProp = cal.icsService.createIcalProperty("DURATION");
+
+ repeatProp.value = this.repeat;
+ durationProp.valueAsIcalString = this.repeatOffset.icalString;
+
+ comp.addProperty(repeatProp);
+ comp.addProperty(durationProp);
+ }
+
+ // Set up attendees (REQUIRED for EMAIL action)
+ /* TODO should we be strict here?
+ if (this.action == "EMAIL" && !this.getAttendees().length) {
+ throw Cr.NS_ERROR_NOT_INITIALIZED;
+ } */
+ for (let attendee of this.getAttendees()) {
+ comp.addProperty(attendee.icalProperty);
+ }
+
+ /* TODO should we be strict here?
+ if (this.action == "EMAIL" && !this.attachments.length) {
+ throw Cr.NS_ERROR_NOT_INITIALIZED;
+ } */
+
+ for (let attachment of this.getAttachments()) {
+ comp.addProperty(attachment.icalProperty);
+ }
+
+ // Set up summary (REQUIRED for EMAIL)
+ if (this.summary || this.action == "EMAIL") {
+ let summaryProp = cal.icsService.createIcalProperty("SUMMARY");
+ // Summary needs to have a non-empty value
+ summaryProp.value = this.summary || cal.l10n.getCalString("alarmDefaultSummary");
+ comp.addProperty(summaryProp);
+ }
+
+ // Set up the description (REQUIRED for DISPLAY and EMAIL)
+ if (this.description || this.action == "DISPLAY" || this.action == "EMAIL") {
+ let descriptionProp = cal.icsService.createIcalProperty("DESCRIPTION");
+ // description needs to have a non-empty value
+ descriptionProp.value = this.description || cal.l10n.getCalString("alarmDefaultDescription");
+ comp.addProperty(descriptionProp);
+ }
+
+ // Set up lastAck
+ if (this.lastAck) {
+ let lastAckProp = cal.icsService.createIcalProperty("X-MOZ-LASTACK");
+ lastAckProp.value = this.lastAck;
+ comp.addProperty(lastAckProp);
+ }
+
+ // Set up X-Props. mProperties contains only non-promoted props
+ // eslint-disable-next-line array-bracket-spacing
+ for (let [propName, propValue] of this.mProperties.entries()) {
+ let icalprop = cal.icsService.createIcalProperty(propName);
+ icalprop.value = propValue;
+
+ // Add parameters
+ let propBucket = this.mPropertyParams[propName];
+ if (propBucket) {
+ for (let paramName in propBucket) {
+ try {
+ icalprop.setParameter(paramName, propBucket[paramName]);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG(
+ "Warning: Invalid alarm parameter value " + paramName + "=" + propBucket[paramName]
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ comp.addProperty(icalprop);
+ }
+ return comp;
+ },
+ set icalComponent(aComp) {
+ this.ensureMutable();
+ if (!aComp || aComp.componentType != "VALARM") {
+ // Invalid Component
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let actionProp = aComp.getFirstProperty("ACTION");
+ let triggerProp = aComp.getFirstProperty("TRIGGER");
+ let repeatProp = aComp.getFirstProperty("REPEAT");
+ let durationProp = aComp.getFirstProperty("DURATION");
+ let summaryProp = aComp.getFirstProperty("SUMMARY");
+ let descriptionProp = aComp.getFirstProperty("DESCRIPTION");
+ let lastAckProp = aComp.getFirstProperty("X-MOZ-LASTACK");
+
+ if (actionProp) {
+ this.action = actionProp.value;
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (triggerProp) {
+ if (triggerProp.getParameter("VALUE") == "DATE-TIME") {
+ this.mAbsoluteDate = triggerProp.valueAsDatetime;
+ this.related = ALARM_RELATED_ABSOLUTE;
+ } else {
+ this.mOffset = cal.createDuration(triggerProp.valueAsIcalString);
+
+ let related = triggerProp.getParameter("RELATED");
+ this.related = related == "END" ? ALARM_RELATED_END : ALARM_RELATED_START;
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (durationProp && repeatProp) {
+ this.repeatOffset = cal.createDuration(durationProp.valueAsIcalString);
+ this.repeat = repeatProp.value;
+ } else if (durationProp || repeatProp) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ } else {
+ this.repeatOffset = null;
+ this.repeat = 0;
+ }
+
+ // Set up attendees
+ this.clearAttendees();
+ for (let attendeeProp of cal.iterate.icalProperty(aComp, "ATTENDEE")) {
+ let attendee = new lazy.CalAttendee();
+ attendee.icalProperty = attendeeProp;
+ this.addAttendee(attendee);
+ }
+
+ // Set up attachments
+ this.clearAttachments();
+ for (let attachProp of cal.iterate.icalProperty(aComp, "ATTACH")) {
+ let attach = new lazy.CalAttachment();
+ attach.icalProperty = attachProp;
+ this.addAttachment(attach);
+ }
+
+ // Set up summary
+ this.summary = summaryProp ? summaryProp.value : null;
+
+ // Set up description
+ this.description = descriptionProp ? descriptionProp.value : null;
+
+ // Set up the alarm lastack. We can't use valueAsDatetime here since
+ // the default for an X-Prop is TEXT and in older versions we didn't set
+ // VALUE=DATE-TIME.
+ this.lastAck = lastAckProp ? cal.createDateTime(lastAckProp.valueAsIcalString) : null;
+
+ this.mProperties = new Map();
+ this.mPropertyParams = {};
+
+ // Other properties
+ for (let prop of cal.iterate.icalProperty(aComp)) {
+ if (!this.promotedProps[prop.propertyName]) {
+ this.setProperty(prop.propertyName, prop.value);
+
+ for (let [paramName, param] of cal.iterate.icalParameter(prop)) {
+ if (!(prop.propertyName in this.mPropertyParams)) {
+ this.mPropertyParams[prop.propertyName] = {};
+ }
+ this.mPropertyParams[prop.propertyName][paramName] = param;
+ }
+ }
+ }
+ },
+
+ hasProperty(aName) {
+ return this.getProperty(aName.toUpperCase()) != null;
+ },
+
+ getProperty(aName) {
+ let name = aName.toUpperCase();
+ if (name in this.promotedProps) {
+ if (this.promotedProps[name] === true) {
+ // Complex promoted props will return undefined
+ return undefined;
+ }
+ return this[this.promotedProps[name]];
+ }
+ return this.mProperties.get(name);
+ },
+
+ setProperty(aName, aValue) {
+ this.ensureMutable();
+ let name = aName.toUpperCase();
+ if (name in this.promotedProps) {
+ if (this.promotedProps[name] === true) {
+ cal.WARN(`Attempted to set complex property ${name} to a simple value ${aValue}`);
+ } else {
+ this[this.promotedProps[name]] = aValue;
+ }
+ } else {
+ this.mProperties.set(name, aValue);
+ }
+ return aValue;
+ },
+
+ deleteProperty(aName) {
+ this.ensureMutable();
+ let name = aName.toUpperCase();
+ if (name in this.promotedProps) {
+ this[this.promotedProps[name]] = null;
+ } else {
+ this.mProperties.delete(name);
+ }
+ },
+
+ get properties() {
+ return [...this.mProperties.entries()];
+ },
+
+ toString(aItem) {
+ function alarmString(aPrefix) {
+ if (!aItem || aItem.isEvent()) {
+ return aPrefix + "Event";
+ } else if (aItem.isTodo()) {
+ return aPrefix + "Task";
+ }
+ return aPrefix;
+ }
+
+ if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) {
+ // this is an absolute alarm. Use the calendar default timezone and
+ // format it.
+ let formatDate = this.mAbsoluteDate.getInTimezone(cal.dtz.defaultTimezone);
+ return cal.dtz.formatter.formatDateTime(formatDate);
+ } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) {
+ // Relative alarm length
+ let alarmlen = Math.abs(this.mOffset.inSeconds / 60);
+ if (alarmlen == 0) {
+ // No need to get the other information if the alarm is at the start
+ // of the event/task.
+ if (this.related == ALARM_RELATED_START) {
+ return cal.l10n.getString("calendar-alarms", alarmString("reminderTitleAtStart"));
+ } else if (this.related == ALARM_RELATED_END) {
+ return cal.l10n.getString("calendar-alarms", alarmString("reminderTitleAtEnd"));
+ }
+ }
+
+ let unit;
+ if (alarmlen % 1440 == 0) {
+ // Alarm is in days
+ unit = "unitDays";
+ alarmlen /= 1440;
+ } else if (alarmlen % 60 == 0) {
+ unit = "unitHours";
+ alarmlen /= 60;
+ } else {
+ unit = "unitMinutes";
+ }
+ let localeUnitString = cal.l10n.getCalString(unit);
+ let unitString = PluralForm.get(alarmlen, localeUnitString).replace("#1", alarmlen);
+ let originStringName = "reminderCustomOrigin";
+
+ // Origin
+ switch (this.related) {
+ case ALARM_RELATED_START:
+ originStringName += "Begin";
+ break;
+ case ALARM_RELATED_END:
+ originStringName += "End";
+ break;
+ }
+
+ if (this.offset.isNegative) {
+ originStringName += "Before";
+ } else {
+ originStringName += "After";
+ }
+
+ let originString = cal.l10n.getString("calendar-alarms", alarmString(originStringName));
+ return cal.l10n.getString("calendar-alarms", "reminderCustomTitle", [
+ unitString,
+ originString,
+ ]);
+ }
+ // This is an incomplete alarm, but then again we should never reach
+ // this state.
+ return "[Incomplete calIAlarm]";
+ },
+};
diff --git a/comm/calendar/base/src/CalAlarmMonitor.jsm b/comm/calendar/base/src/CalAlarmMonitor.jsm
new file mode 100644
index 0000000000..0412e72850
--- /dev/null
+++ b/comm/calendar/base/src/CalAlarmMonitor.jsm
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalAlarmMonitor"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalEvent", "resource:///modules/CalEvent.jsm");
+
+function peekAlarmWindow() {
+ return Services.wm.getMostRecentWindow("Calendar:AlarmWindow");
+}
+
+/**
+ * The alarm monitor takes care of playing the alarm sound and opening one copy
+ * of the calendar-alarm-dialog. Both depend on their respective prefs to be
+ * set. This monitor is only used for DISPLAY type alarms.
+ */
+function CalAlarmMonitor() {
+ this.wrappedJSObject = this;
+ this.mAlarms = [];
+ // A map from itemId to item.
+ this._notifyingItems = new Map();
+
+ this.mSound = Cc["@mozilla.org/sound;1"].createInstance(Ci.nsISound);
+
+ Services.obs.addObserver(this, "alarm-service-startup");
+ Services.obs.addObserver(this, "alarm-service-shutdown");
+}
+
+var calAlarmMonitorClassID = Components.ID("{4b7ae030-ed79-11d9-8cd6-0800200c9a66}");
+var calAlarmMonitorInterfaces = [Ci.nsIObserver, Ci.calIAlarmServiceObserver];
+CalAlarmMonitor.prototype = {
+ mAlarms: null,
+
+ // This is a work-around for the fact that there is a delay between when
+ // we call openWindow and when it appears via getMostRecentWindow. If an
+ // alarm is fired in that time-frame, it will actually end up in another window.
+ mWindowOpening: null,
+
+ // nsISound instance used for playing all sounds
+ mSound: null,
+
+ classID: calAlarmMonitorClassID,
+ QueryInterface: cal.generateQI(["nsIObserver", "calIAlarmServiceObserver"]),
+ classInfo: cal.generateCI({
+ contractID: "@mozilla.org/calendar/alarm-monitor;1",
+ classDescription: "Calendar Alarm Monitor",
+ classID: calAlarmMonitorClassID,
+ interfaces: calAlarmMonitorInterfaces,
+ flags: Ci.nsIClassInfo.SINGLETON,
+ }),
+
+ /**
+ * nsIObserver
+ */
+ observe(aSubject, aTopic, aData) {
+ let alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService);
+ switch (aTopic) {
+ case "alarm-service-startup":
+ alarmService.addObserver(this);
+ break;
+ case "alarm-service-shutdown":
+ alarmService.removeObserver(this);
+ break;
+ case "alertclickcallback": {
+ let item = this._notifyingItems.get(aData);
+ if (item) {
+ let calWindow = cal.window.getCalendarWindow();
+ if (calWindow) {
+ calWindow.openEventDialogForViewing(item, true);
+ }
+ }
+ break;
+ }
+ case "alertfinished":
+ this._notifyingItems.delete(aData);
+ break;
+ }
+ },
+
+ /**
+ * calIAlarmServiceObserver
+ */
+ onAlarm(aItem, aAlarm) {
+ if (aAlarm.action != "DISPLAY") {
+ // This monitor only looks for DISPLAY alarms.
+ return;
+ }
+
+ this.mAlarms.push([aItem, aAlarm]);
+
+ if (Services.prefs.getBoolPref("calendar.alarms.playsound", true)) {
+ // We want to make sure the user isn't flooded with alarms so we
+ // limit this using a preference. For example, if the user has 20
+ // events that fire an alarm in the same minute, then the alarm
+ // sound will only play 5 times. All alarms will be shown in the
+ // dialog nevertheless.
+ let maxAlarmSoundCount = Services.prefs.getIntPref("calendar.alarms.maxsoundsperminute", 5);
+ let now = new Date();
+
+ if (!this.mLastAlarmSoundDate || now - this.mLastAlarmSoundDate >= 60000) {
+ // Last alarm was long enough ago, reset counters. Note
+ // subtracting JSDate results in microseconds.
+ this.mAlarmSoundCount = 0;
+ this.mLastAlarmSoundDate = now;
+ } else {
+ // Otherwise increase the counter
+ this.mAlarmSoundCount++;
+ }
+
+ if (maxAlarmSoundCount > this.mAlarmSoundCount) {
+ // Only ring the alarm sound if we haven't hit the max count.
+ try {
+ let soundURL;
+ if (Services.prefs.getIntPref("calendar.alarms.soundType", 0) == 0) {
+ soundURL = "chrome://calendar/content/sound.wav";
+ } else {
+ soundURL = Services.prefs.getStringPref("calendar.alarms.soundURL", null);
+ }
+ if (soundURL && soundURL.length > 0) {
+ soundURL = Services.io.newURI(soundURL);
+ this.mSound.play(soundURL);
+ } else {
+ this.mSound.beep();
+ }
+ } catch (exc) {
+ cal.ERROR("Error playing alarm sound: " + exc);
+ }
+ }
+ }
+
+ if (!Services.prefs.getBoolPref("calendar.alarms.show", true)) {
+ return;
+ }
+
+ let calAlarmWindow = peekAlarmWindow();
+ if (!calAlarmWindow && (!this.mWindowOpening || this.mWindowOpening.closed)) {
+ this.mWindowOpening = Services.ww.openWindow(
+ null,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ "_blank",
+ "chrome,dialog=yes,all,resizable",
+ this
+ );
+ }
+ if (!this.mWindowOpening) {
+ calAlarmWindow.addWidgetFor(aItem, aAlarm);
+ }
+ },
+
+ /**
+ * @see {calIAlarmServiceObserver}
+ * @param {calIItemBase} item - The item to notify about.
+ */
+ onNotification(item) {
+ // Don't notify about canceled events.
+ if (item.status == "CANCELLED") {
+ return;
+ }
+ // Don't notify if you declined this event invitation.
+ if (
+ (item instanceof lazy.CalEvent || item instanceof Ci.calIEvent) &&
+ item.calendar instanceof Ci.calISchedulingSupport &&
+ item.calendar.isInvitation(item) &&
+ item.calendar.getInvitedAttendee(item)?.participationStatus == "DECLINED"
+ ) {
+ return;
+ }
+
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(Ci.nsIAlertNotification);
+ let alertsService = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService);
+ alert.init(
+ item.id, // name
+ "chrome://messenger/skin/icons/new-mail-alert.png",
+ item.title,
+ item.getProperty("description"),
+ true, // clickable
+ item.id // cookie
+ );
+ this._notifyingItems.set(item.id, item);
+ alertsService.showAlert(alert, this);
+ },
+
+ window_onLoad() {
+ let calAlarmWindow = this.mWindowOpening;
+ this.mWindowOpening = null;
+ if (this.mAlarms.length > 0) {
+ for (let [item, alarm] of this.mAlarms) {
+ calAlarmWindow.addWidgetFor(item, alarm);
+ }
+ } else {
+ // Uh oh, it seems the alarms were removed even before the window
+ // finished loading. Looks like we can close it again
+ calAlarmWindow.closeIfEmpty();
+ }
+ },
+
+ onRemoveAlarmsByItem(aItem) {
+ let calAlarmWindow = peekAlarmWindow();
+ this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => {
+ let ret = aItem.hashId != thisItem.hashId;
+ if (!ret && calAlarmWindow) {
+ // window is open
+ calAlarmWindow.removeWidgetFor(thisItem, alarm);
+ }
+ return ret;
+ });
+ },
+
+ onRemoveAlarmsByCalendar(calendar) {
+ let calAlarmWindow = peekAlarmWindow();
+ this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => {
+ let ret = calendar.id != thisItem.calendar.id;
+
+ if (!ret && calAlarmWindow) {
+ // window is open
+ calAlarmWindow.removeWidgetFor(thisItem, alarm);
+ }
+ return ret;
+ });
+ },
+
+ onAlarmsLoaded(aCalendar) {
+ // the alarm dialog won't close while alarms are loading, check again now
+ let calAlarmWindow = peekAlarmWindow();
+ if (calAlarmWindow && this.mAlarms.length == 0) {
+ calAlarmWindow.closeIfEmpty();
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalAlarmService.jsm b/comm/calendar/base/src/CalAlarmService.jsm
new file mode 100644
index 0000000000..916582a9af
--- /dev/null
+++ b/comm/calendar/base/src/CalAlarmService.jsm
@@ -0,0 +1,827 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalAlarmService"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+
+var kHoursBetweenUpdates = 6;
+
+function nowUTC() {
+ return cal.dtz.jsDateToDateTime(new Date()).getInTimezone(cal.dtz.UTC);
+}
+
+function newTimerWithCallback(aCallback, aDelay, aRepeating) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ timer.initWithCallback(
+ aCallback,
+ aDelay,
+ aRepeating ? timer.TYPE_REPEATING_PRECISE : timer.TYPE_ONE_SHOT
+ );
+ return timer;
+}
+
+/**
+ * Keeps track of seemingly immutable items with alarms that we can't dismiss.
+ * Some servers quietly discard our modifications to repeating events which will
+ * cause dismissed alarms to re-appear if we do not keep track.
+ *
+ * We track the items by their "hashId" property storing it in the calendar
+ * property "alarms.ignored".
+ */
+const IgnoredAlarmsStore = {
+ /**
+ * @type {number}
+ */
+ maxItemsPerCalendar: 1500,
+
+ _getCache(item) {
+ return item.calendar.getProperty("alarms.ignored")?.split(",") || [];
+ },
+
+ /**
+ * Adds an item to the store. No alarms will be created for this item again.
+ *
+ * @param {calIItemBase} item
+ */
+ add(item) {
+ let cache = this._getCache(item);
+ let id = item.parentItem.hashId;
+ if (!cache.includes(id)) {
+ if (cache.length >= this.maxItemsPerCalendar) {
+ cache[0] = id;
+ } else {
+ cache.push(id);
+ }
+ }
+ item.calendar.setProperty("alarms.ignored", cache.join(","));
+ },
+
+ /**
+ * Returns true if the item's hashId is in the store.
+ *
+ * @param {calIItemBase} item
+ * @returns {boolean}
+ */
+ has(item) {
+ return this._getCache(item).includes(item.parentItem.hashId);
+ },
+};
+
+function CalAlarmService() {
+ this.wrappedJSObject = this;
+
+ this.mLoadedCalendars = {};
+ this.mTimerMap = {};
+ this.mNotificationTimerMap = {};
+ this.mObservers = new cal.data.ListenerSet(Ci.calIAlarmServiceObserver);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gNotificationsTimes",
+ "calendar.notifications.times",
+ "",
+ () => this.initAlarms(cal.manager.getCalendars())
+ );
+
+ this.calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ alarmService: this,
+
+ calendarsInBatch: new Set(),
+
+ // calIObserver:
+ onStartBatch(calendar) {
+ this.calendarsInBatch.add(calendar);
+ },
+ onEndBatch(calendar) {
+ this.calendarsInBatch.delete(calendar);
+ },
+ onLoad(calendar) {
+ // ignore any onLoad events until initial getItems() call of startup has finished:
+ if (calendar && this.alarmService.mLoadedCalendars[calendar.id]) {
+ // a refreshed calendar signals that it has been reloaded
+ // (and cannot notify detailed changes), thus reget all alarms of it:
+ this.alarmService.initAlarms([calendar]);
+ }
+ },
+
+ onAddItem(aItem) {
+ // If we're in a batch, ignore this notification. We're going to reload anyway.
+ if (!this.calendarsInBatch.has(aItem.calendar)) {
+ this.alarmService.addAlarmsForOccurrences(aItem);
+ }
+ },
+ onModifyItem(aNewItem, aOldItem) {
+ // If we're in a batch, ignore this notification. We're going to reload anyway.
+ if (this.calendarsInBatch.has(aNewItem.calendar)) {
+ return;
+ }
+
+ if (!aNewItem.recurrenceId) {
+ // deleting an occurrence currently calls modifyItem(newParent, *oldOccurrence*)
+ aOldItem = aOldItem.parentItem;
+ }
+
+ this.onDeleteItem(aOldItem);
+ this.onAddItem(aNewItem);
+ },
+ onDeleteItem(aDeletedItem) {
+ this.alarmService.removeAlarmsForOccurrences(aDeletedItem);
+ },
+ onError(aCalendar, aErrNo, aMessage) {},
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ switch (aName) {
+ case "suppressAlarms":
+ case "disabled":
+ case "notifications.times":
+ this.alarmService.initAlarms([aCalendar]);
+ break;
+ }
+ },
+ onPropertyDeleting(aCalendar, aName) {
+ this.onPropertyChanged(aCalendar, aName);
+ },
+ };
+
+ this.calendarManagerObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver"]),
+ alarmService: this,
+
+ onCalendarRegistered(aCalendar) {
+ this.alarmService.observeCalendar(aCalendar);
+ // initial refresh of alarms for new calendar:
+ this.alarmService.initAlarms([aCalendar]);
+ },
+ onCalendarUnregistering(aCalendar) {
+ // XXX todo: we need to think about calendar unregistration;
+ // there may still be dangling items (-> alarm dialog),
+ // dismissing those alarms may write data...
+ this.alarmService.unobserveCalendar(aCalendar);
+ delete this.alarmService.mLoadedCalendars[aCalendar.id];
+ },
+ onCalendarDeleting(aCalendar) {
+ this.alarmService.unobserveCalendar(aCalendar);
+ delete this.alarmService.mLoadedCalendars[aCalendar.id];
+ },
+ };
+}
+
+var calAlarmServiceClassID = Components.ID("{7a9200dd-6a64-4fff-a798-c5802186e2cc}");
+var calAlarmServiceInterfaces = [Ci.calIAlarmService, Ci.nsIObserver];
+CalAlarmService.prototype = {
+ mRangeStart: null,
+ mRangeEnd: null,
+ mUpdateTimer: null,
+ mStarted: false,
+ mTimerMap: null,
+ mObservers: null,
+ mTimezone: null,
+
+ classID: calAlarmServiceClassID,
+ QueryInterface: cal.generateQI(["calIAlarmService", "nsIObserver"]),
+ classInfo: cal.generateCI({
+ classID: calAlarmServiceClassID,
+ contractID: "@mozilla.org/calendar/alarm-service;1",
+ classDescription: "Calendar Alarm Service",
+ interfaces: calAlarmServiceInterfaces,
+ flags: Ci.nsIClassInfo.SINGLETON,
+ }),
+
+ _logger: console.createInstance({
+ prefix: "calendar.alarms",
+ maxLogLevel: "Warn",
+ maxLogLevelPref: "calendar.alarms.loglevel",
+ }),
+
+ /**
+ * nsIObserver
+ */
+ observe(aSubject, aTopic, aData) {
+ // This will also be called on app-startup, but nothing is done yet, to
+ // prevent unwanted dialogs etc. See bug 325476 and 413296
+ if (aTopic == "profile-after-change" || aTopic == "wake_notification") {
+ this.shutdown();
+ this.startup();
+ }
+ if (aTopic == "xpcom-shutdown") {
+ this.shutdown();
+ }
+ },
+
+ /**
+ * calIAlarmService APIs
+ */
+ get timezone() {
+ // TODO Do we really need this? Do we ever set the timezone to something
+ // different than the default timezone?
+ return this.mTimezone || cal.dtz.defaultTimezone;
+ },
+
+ set timezone(aTimezone) {
+ this.mTimezone = aTimezone;
+ },
+
+ async snoozeAlarm(aItem, aAlarm, aDuration) {
+ // Right now we only support snoozing all alarms for the given item for
+ // aDuration.
+
+ // Make sure we're working with the parent, otherwise we'll accidentally
+ // create an exception
+ let newEvent = aItem.parentItem.clone();
+ let alarmTime = nowUTC();
+
+ // Set the last acknowledged time to now.
+ newEvent.alarmLastAck = alarmTime;
+
+ alarmTime = alarmTime.clone();
+ alarmTime.addDuration(aDuration);
+
+ let propName = "X-MOZ-SNOOZE-TIME";
+ if (aItem.parentItem != aItem) {
+ // This is the *really* hard case where we've snoozed a single
+ // instance of a recurring event. We need to not only know that
+ // there was a snooze, but also which occurrence was snoozed. Part
+ // of me just wants to create a local db of snoozes here...
+ propName = "X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime;
+ }
+ newEvent.setProperty(propName, alarmTime.icalString);
+
+ // calling modifyItem will cause us to get the right callback
+ // and update the alarm properly
+ let modifiedItem = await newEvent.calendar.modifyItem(newEvent, aItem.parentItem);
+
+ if (modifiedItem.getProperty(propName) == alarmTime.icalString) {
+ return;
+ }
+
+ // The server did not persist our changes for some reason.
+ // Include the item in the ignored list so we skip displaying alarms for
+ // this item in the future.
+ IgnoredAlarmsStore.add(aItem);
+ },
+
+ async dismissAlarm(aItem, aAlarm) {
+ if (cal.acl.isCalendarWritable(aItem.calendar) && cal.acl.userCanModifyItem(aItem)) {
+ let now = nowUTC();
+ // We want the parent item, otherwise we're going to accidentally
+ // create an exception. We've relnoted (for 0.1) the slightly odd
+ // behavior this can cause if you move an event after dismissing an
+ // alarm
+ let oldParent = aItem.parentItem;
+ let newParent = oldParent.clone();
+ newParent.alarmLastAck = now;
+ // Make sure to clear out any snoozes that were here.
+ if (aItem.recurrenceId) {
+ newParent.deleteProperty("X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime);
+ } else {
+ newParent.deleteProperty("X-MOZ-SNOOZE-TIME");
+ }
+
+ let modifiedItem = await newParent.calendar.modifyItem(newParent, oldParent);
+ if (modifiedItem.alarmLastAck && now.compare(modifiedItem.alarmLastAck) == 0) {
+ return;
+ }
+
+ // The server did not persist our changes for some reason.
+ // Include the item in the ignored list so we skip displaying alarms for
+ // this item in the future.
+ IgnoredAlarmsStore.add(aItem);
+ }
+ // if the calendar of the item is r/o, we simple remove the alarm
+ // from the list without modifying the item, so this works like
+ // effectively dismissing from a user's pov, since the alarm neither
+ // popups again in the current user session nor will be added after
+ // next restart, since it is missed then already
+ this.removeAlarmsForItem(aItem);
+ },
+
+ addObserver(aObserver) {
+ this.mObservers.add(aObserver);
+ },
+
+ removeObserver(aObserver) {
+ this.mObservers.delete(aObserver);
+ },
+
+ startup() {
+ if (this.mStarted) {
+ return;
+ }
+
+ Services.obs.addObserver(this, "profile-after-change");
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ Services.obs.addObserver(this, "wake_notification");
+
+ // Make sure the alarm monitor is alive so it's observing the notification.
+ Cc["@mozilla.org/calendar/alarm-monitor;1"].getService(Ci.calIAlarmServiceObserver);
+ // Tell people that we're alive so they can start monitoring alarms.
+ Services.obs.notifyObservers(null, "alarm-service-startup");
+
+ cal.manager.addObserver(this.calendarManagerObserver);
+
+ for (let calendar of cal.manager.getCalendars()) {
+ this.observeCalendar(calendar);
+ }
+
+ /* set up a timer to update alarms every N hours */
+ let timerCallback = {
+ alarmService: this,
+ notify() {
+ let now = nowUTC();
+ let start;
+ if (this.alarmService.mRangeEnd) {
+ // This is a subsequent search, so we got all the past alarms before
+ start = this.alarmService.mRangeEnd.clone();
+ } else {
+ // This is our first search for alarms. We're going to look for
+ // alarms +/- 1 month from now. If someone sets an alarm more than
+ // a month ahead of an event, or doesn't start Thunderbird
+ // for a month, they'll miss some, but that's a slim chance
+ start = now.clone();
+ start.month -= Ci.calIAlarmService.MAX_SNOOZE_MONTHS;
+ this.alarmService.mRangeStart = start.clone();
+ }
+ let until = now.clone();
+ until.month += Ci.calIAlarmService.MAX_SNOOZE_MONTHS;
+
+ // We don't set timers for every future alarm, only those within 6 hours
+ let end = now.clone();
+ end.hour += kHoursBetweenUpdates;
+ this.alarmService.mRangeEnd = end.getInTimezone(cal.dtz.UTC);
+
+ this.alarmService.findAlarms(cal.manager.getCalendars(), start, until);
+ },
+ };
+ timerCallback.notify();
+
+ this.mUpdateTimer = newTimerWithCallback(timerCallback, kHoursBetweenUpdates * 3600000, true);
+
+ this.mStarted = true;
+ },
+
+ shutdown() {
+ if (!this.mStarted) {
+ return;
+ }
+
+ // Tell people that we're no longer running.
+ Services.obs.notifyObservers(null, "alarm-service-shutdown");
+
+ if (this.mUpdateTimer) {
+ this.mUpdateTimer.cancel();
+ this.mUpdateTimer = null;
+ }
+
+ cal.manager.removeObserver(this.calendarManagerObserver);
+
+ // Stop observing all calendars. This will also clear the timers.
+ for (let calendar of cal.manager.getCalendars()) {
+ this.unobserveCalendar(calendar);
+ }
+
+ this.mRangeEnd = null;
+
+ Services.obs.removeObserver(this, "profile-after-change");
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.obs.removeObserver(this, "wake_notification");
+
+ this.mStarted = false;
+ },
+
+ observeCalendar(calendar) {
+ calendar.addObserver(this.calendarObserver);
+ },
+
+ unobserveCalendar(calendar) {
+ calendar.removeObserver(this.calendarObserver);
+ this.disposeCalendarTimers([calendar]);
+ this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]);
+ },
+
+ addAlarmsForItem(aItem) {
+ if ((aItem.isTodo() && aItem.isCompleted) || IgnoredAlarmsStore.has(aItem)) {
+ // If this is a task and it is completed or the id is in the ignored list,
+ // don't add the alarm.
+ return;
+ }
+
+ let showMissed = Services.prefs.getBoolPref("calendar.alarms.showmissed", true);
+
+ let alarms = aItem.getAlarms();
+ for (let alarm of alarms) {
+ let alarmDate = cal.alarms.calculateAlarmDate(aItem, alarm);
+
+ if (!alarmDate || alarm.action != "DISPLAY") {
+ // Only take care of DISPLAY alarms with an alarm date.
+ continue;
+ }
+
+ // Handle all day events. This is kinda weird, because they don't have
+ // a well defined startTime. We just consider the start/end to be
+ // midnight in the user's timezone.
+ if (alarmDate.isDate) {
+ alarmDate = alarmDate.getInTimezone(this.timezone);
+ alarmDate.isDate = false;
+ }
+ alarmDate = alarmDate.getInTimezone(cal.dtz.UTC);
+
+ // Check for snooze
+ let snoozeDate;
+ if (aItem.parentItem == aItem) {
+ snoozeDate = aItem.getProperty("X-MOZ-SNOOZE-TIME");
+ } else {
+ snoozeDate = aItem.parentItem.getProperty(
+ "X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime
+ );
+ }
+
+ if (
+ snoozeDate &&
+ !(snoozeDate instanceof lazy.CalDateTime) &&
+ !(snoozeDate instanceof Ci.calIDateTime)
+ ) {
+ snoozeDate = cal.createDateTime(snoozeDate);
+ }
+
+ // an alarm can only be snoozed to a later time, if earlier it's from another alarm.
+ if (snoozeDate && snoozeDate.compare(alarmDate) > 0) {
+ // If the alarm was snoozed, the snooze time is more important.
+ alarmDate = snoozeDate;
+ }
+
+ let now = nowUTC();
+ if (alarmDate.timezone.isFloating) {
+ now = cal.dtz.now();
+ now.timezone = cal.dtz.floating;
+ }
+
+ if (alarmDate.compare(now) >= 0) {
+ // We assume that future alarms haven't been acknowledged
+ // Delay is in msec, so don't forget to multiply
+ let timeout = alarmDate.subtractDate(now).inSeconds * 1000;
+
+ // No sense in keeping an extra timeout for an alarm that's past
+ // our range.
+ let timeUntilRefresh = this.mRangeEnd.subtractDate(now).inSeconds * 1000;
+ if (timeUntilRefresh < timeout) {
+ continue;
+ }
+
+ this.addTimer(aItem, alarm, timeout);
+ } else if (
+ showMissed &&
+ cal.acl.isCalendarWritable(aItem.calendar) &&
+ cal.acl.userCanModifyItem(aItem)
+ ) {
+ // This alarm is in the past and the calendar is writable, so we
+ // could snooze or dismiss alarms. See if it has been previously
+ // ack'd.
+ let lastAck = aItem.parentItem.alarmLastAck;
+ if (lastAck && lastAck.compare(alarmDate) >= 0) {
+ // The alarm was previously dismissed or snoozed, no further
+ // action required.
+ continue;
+ } else {
+ // The alarm was not snoozed or dismissed, fire it now.
+ this.alarmFired(aItem, alarm);
+ }
+ }
+ }
+
+ this.addNotificationForItem(aItem);
+ },
+
+ removeAlarmsForItem(aItem) {
+ // make sure already fired alarms are purged out of the alarm window:
+ this.mObservers.notify("onRemoveAlarmsByItem", [aItem]);
+ // Purge alarms specifically for this item (i.e exception)
+ for (let alarm of aItem.getAlarms()) {
+ this.removeTimer(aItem, alarm);
+ }
+
+ this.removeNotificationForItem(aItem);
+ },
+
+ /**
+ * Get the timeouts before notifications are fired for an item.
+ *
+ * @param {calIItemBase} item - A calendar item instance.
+ * @returns {number[]} Timeouts of notifications in milliseconds in ascending order.
+ */
+ calculateNotificationTimeouts(item) {
+ let now = nowUTC();
+ let until = now.clone();
+ until.month += 1;
+ // We only care about items no more than a month ahead.
+ if (!cal.item.checkIfInRange(item, now, until)) {
+ return [];
+ }
+ let startDate = item[cal.dtz.startDateProp(item)];
+ let endDate = item[cal.dtz.endDateProp(item)];
+ let timeouts = [];
+ // The calendar level notifications setting overrides the global setting.
+ let prefValue = (
+ item.calendar.getProperty("notifications.times") || this.gNotificationsTimes
+ ).split(",");
+ for (let entry of prefValue) {
+ entry = entry.trim();
+ if (!entry) {
+ continue;
+ }
+ let [tag, value] = entry.split(":");
+ if (!value) {
+ value = tag;
+ tag = "";
+ }
+ let duration;
+ try {
+ duration = cal.createDuration(value);
+ } catch (e) {
+ this._logger.error(`Failed to parse ${entry}`, e);
+ continue;
+ }
+ let fireDate;
+ if (tag == "END" && endDate) {
+ fireDate = endDate.clone();
+ } else if (startDate) {
+ fireDate = startDate.clone();
+ } else {
+ continue;
+ }
+ fireDate.addDuration(duration);
+ let timeout = fireDate.subtractDate(now).inSeconds * 1000;
+ if (timeout > 0) {
+ timeouts.push(timeout);
+ }
+ }
+ return timeouts.sort((x, y) => x - y);
+ },
+
+ /**
+ * Set up notification timers for an item.
+ *
+ * @param {calIItemBase} item - A calendar item instance.
+ */
+ addNotificationForItem(item) {
+ let alarmTimerCallback = {
+ notify: () => {
+ this.mObservers.notify("onNotification", [item]);
+ this.removeFiredNotificationTimer(item);
+ },
+ };
+ let timeouts = this.calculateNotificationTimeouts(item);
+ let timers = timeouts.map(timeout => newTimerWithCallback(alarmTimerCallback, timeout, false));
+
+ if (timers.length > 0) {
+ this._logger.debug(
+ `addNotificationForItem hashId=${item.hashId}: adding ${timers.length} timers, timeouts=${timeouts}`
+ );
+ this.mNotificationTimerMap[item.calendar.id] =
+ this.mNotificationTimerMap[item.calendar.id] || {};
+ this.mNotificationTimerMap[item.calendar.id][item.hashId] = timers;
+ }
+ },
+
+ /**
+ * Remove notification timers for an item.
+ *
+ * @param {calIItemBase} item - A calendar item instance.
+ */
+ removeNotificationForItem(item) {
+ if (
+ !this.mNotificationTimerMap[item.calendar.id] ||
+ !this.mNotificationTimerMap[item.calendar.id][item.hashId]
+ ) {
+ return;
+ }
+
+ for (let timer of this.mNotificationTimerMap[item.calendar.id][item.hashId]) {
+ timer.cancel();
+ }
+
+ delete this.mNotificationTimerMap[item.calendar.id][item.hashId];
+
+ // If the calendar map is empty, remove it from the timer map
+ if (Object.keys(this.mNotificationTimerMap[item.calendar.id]).length == 0) {
+ delete this.mNotificationTimerMap[item.calendar.id];
+ }
+ },
+
+ /**
+ * Remove the first notification timers for an item to release some memory.
+ *
+ * @param {calIItemBase} item - A calendar item instance.
+ */
+ removeFiredNotificationTimer(item) {
+ // The first timer is fired first.
+ let removed = this.mNotificationTimerMap[item.calendar.id][item.hashId].shift();
+
+ let remainingTimersCount = this.mNotificationTimerMap[item.calendar.id][item.hashId].length;
+ this._logger.debug(
+ `removeFiredNotificationTimer hashId=${item.hashId}: removed=${removed.delay}, remaining ${remainingTimersCount} timers`
+ );
+ if (remainingTimersCount == 0) {
+ delete this.mNotificationTimerMap[item.calendar.id][item.hashId];
+ }
+
+ // If the calendar map is empty, remove it from the timer map
+ if (Object.keys(this.mNotificationTimerMap[item.calendar.id]).length == 0) {
+ delete this.mNotificationTimerMap[item.calendar.id];
+ }
+ },
+
+ getOccurrencesInRange(aItem) {
+ // We search 1 month in each direction for alarms. Therefore,
+ // we need occurrences between initial start date and 1 month from now
+ let until = nowUTC();
+ until.month += 1;
+
+ if (aItem && aItem.recurrenceInfo) {
+ return aItem.recurrenceInfo.getOccurrences(this.mRangeStart, until, 0);
+ }
+ return cal.item.checkIfInRange(aItem, this.mRangeStart, until) ? [aItem] : [];
+ },
+
+ addAlarmsForOccurrences(aParentItem) {
+ let occs = this.getOccurrencesInRange(aParentItem);
+
+ // Add an alarm for each occurrence
+ occs.forEach(this.addAlarmsForItem, this);
+ },
+
+ removeAlarmsForOccurrences(aParentItem) {
+ let occs = this.getOccurrencesInRange(aParentItem);
+
+ // Remove alarm for each occurrence
+ occs.forEach(this.removeAlarmsForItem, this);
+ },
+
+ addTimer(aItem, aAlarm, aTimeout) {
+ this.mTimerMap[aItem.calendar.id] = this.mTimerMap[aItem.calendar.id] || {};
+ this.mTimerMap[aItem.calendar.id][aItem.hashId] =
+ this.mTimerMap[aItem.calendar.id][aItem.hashId] || {};
+
+ let self = this;
+ let alarmTimerCallback = {
+ notify() {
+ self.alarmFired(aItem, aAlarm);
+ },
+ };
+
+ let timer = newTimerWithCallback(alarmTimerCallback, aTimeout, false);
+ this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString] = timer;
+ },
+
+ removeTimer(aItem, aAlarm) {
+ /* Is the calendar in the timer map */
+ if (
+ aItem.calendar.id in this.mTimerMap &&
+ /* ...and is the item in the calendar map */
+ aItem.hashId in this.mTimerMap[aItem.calendar.id] &&
+ /* ...and is the alarm in the item map ? */
+ aAlarm.icalString in this.mTimerMap[aItem.calendar.id][aItem.hashId]
+ ) {
+ // First cancel the existing timer
+ let timer = this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString];
+ timer.cancel();
+
+ // Remove the alarm from the item map
+ delete this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString];
+
+ // If the item map is empty, remove it from the calendar map
+ if (this.mTimerMap[aItem.calendar.id][aItem.hashId].toSource() == "({})") {
+ delete this.mTimerMap[aItem.calendar.id][aItem.hashId];
+ }
+
+ // If the calendar map is empty, remove it from the timer map
+ if (this.mTimerMap[aItem.calendar.id].toSource() == "({})") {
+ delete this.mTimerMap[aItem.calendar.id];
+ }
+ }
+ },
+
+ disposeCalendarTimers(aCalendars) {
+ for (let calendar of aCalendars) {
+ if (calendar.id in this.mTimerMap) {
+ for (let hashId in this.mTimerMap[calendar.id]) {
+ let itemTimerMap = this.mTimerMap[calendar.id][hashId];
+ for (let icalString in itemTimerMap) {
+ let timer = itemTimerMap[icalString];
+ timer.cancel();
+ }
+ }
+ delete this.mTimerMap[calendar.id];
+ }
+ if (calendar.id in this.mNotificationTimerMap) {
+ for (let timers of Object.values(this.mNotificationTimerMap[calendar.id])) {
+ for (let timer of timers) {
+ timer.cancel();
+ }
+ }
+ delete this.mNotificationTimerMap[calendar.id];
+ }
+ }
+ },
+
+ async findAlarms(aCalendars, aStart, aUntil) {
+ const calICalendar = Ci.calICalendar;
+ let filter =
+ calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ calICalendar.ITEM_FILTER_CLASS_OCCURRENCES |
+ calICalendar.ITEM_FILTER_TYPE_ALL;
+
+ await Promise.all(
+ aCalendars.map(async calendar => {
+ if (calendar.getProperty("suppressAlarms") && calendar.getProperty("disabled")) {
+ this.mLoadedCalendars[calendar.id] = true;
+ this.mObservers.notify("onAlarmsLoaded", [calendar]);
+ return;
+ }
+
+ // Assuming that suppressAlarms does not change anymore until next refresh.
+ this.mLoadedCalendars[calendar.id] = false;
+
+ for await (let items of cal.iterate.streamValues(
+ calendar.getItems(filter, 0, aStart, aUntil)
+ )) {
+ await new Promise((resolve, reject) => {
+ cal.iterate.forEach(
+ items,
+ item => {
+ try {
+ this.removeAlarmsForItem(item);
+ this.addAlarmsForItem(item);
+ } catch (e) {
+ console.error("Promise was rejected: " + e);
+ this.mLoadedCalendars[calendar.id] = true;
+ this.mObservers.notify("onAlarmsLoaded", [calendar]);
+ reject(e);
+ }
+ },
+ () => {
+ resolve();
+ }
+ );
+ });
+ }
+
+ // The calendar has been loaded, so until now, onLoad events can be ignored.
+ this.mLoadedCalendars[calendar.id] = true;
+
+ // Notify observers that the alarms for the calendar have been loaded.
+ this.mObservers.notify("onAlarmsLoaded", [calendar]);
+ })
+ );
+ },
+
+ initAlarms(aCalendars) {
+ // Purge out all alarm timers belonging to the refreshed/loaded calendars
+ this.disposeCalendarTimers(aCalendars);
+
+ // Purge out all alarms from dialog belonging to the refreshed/loaded calendars
+ for (let calendar of aCalendars) {
+ this.mLoadedCalendars[calendar.id] = false;
+ this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]);
+ }
+
+ // Total refresh similar to startup. We're going to look for
+ // alarms +/- 1 month from now. If someone sets an alarm more than
+ // a month ahead of an event, or doesn't start Thunderbird
+ // for a month, they'll miss some, but that's a slim chance
+ let start = nowUTC();
+ let until = start.clone();
+ start.month -= Ci.calIAlarmService.MAX_SNOOZE_MONTHS;
+ until.month += Ci.calIAlarmService.MAX_SNOOZE_MONTHS;
+ this.findAlarms(aCalendars, start, until);
+ },
+
+ alarmFired(aItem, aAlarm) {
+ if (
+ !aItem.calendar.getProperty("suppressAlarms") &&
+ !aItem.calendar.getProperty("disabled") &&
+ aItem.getProperty("STATUS") != "CANCELLED"
+ ) {
+ this.mObservers.notify("onAlarm", [aItem, aAlarm]);
+ }
+ },
+
+ get isLoading() {
+ for (let calId in this.mLoadedCalendars) {
+ // we need to exclude calendars which failed to load explicitly to
+ // prevent the alaram dialog to stay opened after dismissing all
+ // alarms if there is a network calendar that failed to load
+ let currentStatus = cal.manager.getCalendarById(calId).getProperty("currentStatus");
+ if (!this.mLoadedCalendars[calId] && Components.isSuccessCode(currentStatus)) {
+ return true;
+ }
+ }
+ return false;
+ },
+};
diff --git a/comm/calendar/base/src/CalAttachment.jsm b/comm/calendar/base/src/CalAttachment.jsm
new file mode 100644
index 0000000000..df21188c8e
--- /dev/null
+++ b/comm/calendar/base/src/CalAttachment.jsm
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalAttachment"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Constructor for `calIAttachment` objects.
+ *
+ * @class
+ * @implements {calIAttachment}
+ * @param {string} [icalString] - Optional iCal string for initializing existing attachments.
+ */
+function CalAttachment(icalString) {
+ this.wrappedJSObject = this;
+ this.mProperties = new Map();
+ if (icalString) {
+ this.icalString = icalString;
+ }
+}
+
+CalAttachment.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIAttachment"]),
+ classID: Components.ID("{5f76b352-ab75-4c2b-82c9-9206dbbf8571}"),
+
+ mData: null,
+ mHashId: null,
+
+ get hashId() {
+ if (!this.mHashId) {
+ let cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+ let data = new TextEncoder().encode(this.rawData);
+ cryptoHash.init(cryptoHash.MD5);
+ cryptoHash.update(data, data.length);
+ this.mHashId = cryptoHash.finish(true);
+ }
+ return this.mHashId;
+ },
+
+ /**
+ * calIAttachment
+ */
+
+ get uri() {
+ let uri = null;
+ if (this.getParameter("VALUE") != "BINARY") {
+ // If this is not binary data, its likely an uri. Attempt to convert
+ // and throw otherwise.
+ try {
+ uri = Services.io.newURI(this.mData);
+ } catch (e) {
+ // Its possible that the uri contains malformed data. Often
+ // callers don't expect an exception here, so we just catch
+ // it and return null.
+ }
+ }
+
+ return uri;
+ },
+ set uri(aUri) {
+ // An uri is the default format, remove any value type parameters
+ this.deleteParameter("VALUE");
+ this.setData(aUri.spec);
+ },
+
+ get rawData() {
+ return this.mData;
+ },
+ set rawData(aData) {
+ // Setting the raw data lets us assume this is binary data. Make sure
+ // the value parameter is set
+ this.setParameter("VALUE", "BINARY");
+ this.setData(aData);
+ },
+
+ get formatType() {
+ return this.getParameter("FMTTYPE");
+ },
+ set formatType(aType) {
+ this.setParameter("FMTTYPE", aType);
+ },
+
+ get encoding() {
+ return this.getParameter("ENCODING");
+ },
+ set encoding(aValue) {
+ this.setParameter("ENCODING", aValue);
+ },
+
+ get icalProperty() {
+ let icalatt = cal.icsService.createIcalProperty("ATTACH");
+
+ for (let [key, value] of this.mProperties.entries()) {
+ try {
+ icalatt.setParameter(key, value);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG("Warning: Invalid attachment parameter value " + key + "=" + value);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ if (this.mData) {
+ icalatt.value = this.mData;
+ }
+ return icalatt;
+ },
+
+ set icalProperty(attProp) {
+ // Reset the property bag for the parameters, it will be re-initialized
+ // from the ical property.
+ this.mProperties = new Map();
+ this.setData(attProp.value);
+
+ for (let [name, value] of cal.iterate.icalParameter(attProp)) {
+ this.setParameter(name, value);
+ }
+ },
+
+ get icalString() {
+ let comp = this.icalProperty;
+ return comp ? comp.icalString : "";
+ },
+ set icalString(val) {
+ let prop = cal.icsService.createIcalPropertyFromString(val);
+ if (prop.propertyName != "ATTACH") {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ this.icalProperty = prop;
+ },
+
+ getParameter(aName) {
+ return this.mProperties.get(aName);
+ },
+
+ setParameter(aName, aValue) {
+ if (aValue || aValue === 0) {
+ return this.mProperties.set(aName, aValue);
+ }
+ return this.mProperties.delete(aName);
+ },
+
+ deleteParameter(aName) {
+ this.mProperties.delete(aName);
+ },
+
+ clone() {
+ let newAttachment = new CalAttachment();
+ newAttachment.mData = this.mData;
+ newAttachment.mHashId = this.mHashId;
+ for (let [name, value] of this.mProperties.entries()) {
+ newAttachment.mProperties.set(name, value);
+ }
+ return newAttachment;
+ },
+
+ setData(aData) {
+ // Sets the data and invalidates the hash so it will be recalculated
+ this.mHashId = null;
+ this.mData = aData;
+ return this.mData;
+ },
+};
diff --git a/comm/calendar/base/src/CalAttendee.jsm b/comm/calendar/base/src/CalAttendee.jsm
new file mode 100644
index 0000000000..5edceabded
--- /dev/null
+++ b/comm/calendar/base/src/CalAttendee.jsm
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from calItemBase.js */
+
+var EXPORTED_SYMBOLS = ["CalAttendee"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+Services.scriptloader.loadSubScript("resource:///components/calItemBase.js");
+
+/**
+ * Constructor for `calIAttendee` objects.
+ *
+ * @class
+ * @implements {calIAttendee}
+ * @param {string} [icalString] - Optional iCal string for initializing existing attendees.
+ */
+function CalAttendee(icalString) {
+ this.wrappedJSObject = this;
+ this.mProperties = new Map();
+ if (icalString) {
+ this.icalString = icalString;
+ }
+}
+
+CalAttendee.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIAttendee"]),
+ classID: Components.ID("{5c8dcaa3-170c-4a73-8142-d531156f664d}"),
+
+ mImmutable: false,
+ get isMutable() {
+ return !this.mImmutable;
+ },
+
+ modify() {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+
+ makeImmutable() {
+ this.mImmutable = true;
+ },
+
+ clone() {
+ let a = new CalAttendee();
+
+ if (this.mIsOrganizer) {
+ a.isOrganizer = true;
+ }
+
+ const allProps = ["id", "commonName", "rsvp", "role", "participationStatus", "userType"];
+ for (let prop of allProps) {
+ a[prop] = this[prop];
+ }
+
+ for (let [key, value] of this.mProperties.entries()) {
+ a.setProperty(key, value);
+ }
+
+ return a;
+ },
+ // XXX enforce legal values for our properties;
+
+ icalAttendeePropMap: [
+ { cal: "rsvp", ics: "RSVP" },
+ { cal: "commonName", ics: "CN" },
+ { cal: "participationStatus", ics: "PARTSTAT" },
+ { cal: "userType", ics: "CUTYPE" },
+ { cal: "role", ics: "ROLE" },
+ ],
+
+ mIsOrganizer: false,
+ get isOrganizer() {
+ return this.mIsOrganizer;
+ },
+ set isOrganizer(bool) {
+ this.mIsOrganizer = bool;
+ },
+
+ // icalatt is a calIcalProperty of type attendee
+ set icalProperty(icalatt) {
+ this.modify();
+ this.id = icalatt.valueAsIcalString;
+ this.mIsOrganizer = icalatt.propertyName == "ORGANIZER";
+
+ let promotedProps = {};
+ for (let prop of this.icalAttendeePropMap) {
+ this[prop.cal] = icalatt.getParameter(prop.ics);
+ // Don't copy these to the property bag.
+ promotedProps[prop.ics] = true;
+ }
+
+ // Reset the property bag for the parameters, it will be re-initialized
+ // from the ical property.
+ this.mProperties = new Map();
+
+ for (let [name, value] of cal.iterate.icalParameter(icalatt)) {
+ if (!promotedProps[name]) {
+ this.setProperty(name, value);
+ }
+ }
+ },
+
+ get icalProperty() {
+ let icalatt;
+ if (this.mIsOrganizer) {
+ icalatt = cal.icsService.createIcalProperty("ORGANIZER");
+ } else {
+ icalatt = cal.icsService.createIcalProperty("ATTENDEE");
+ }
+
+ if (!this.id) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ icalatt.valueAsIcalString = this.id;
+ for (let i = 0; i < this.icalAttendeePropMap.length; i++) {
+ let prop = this.icalAttendeePropMap[i];
+ if (this[prop.cal]) {
+ try {
+ icalatt.setParameter(prop.ics, this[prop.cal]);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG("Warning: Invalid attendee parameter value " + prop.ics + "=" + this[prop.cal]);
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ for (let [key, value] of this.mProperties.entries()) {
+ try {
+ icalatt.setParameter(key, value);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG("Warning: Invalid attendee parameter value " + key + "=" + value);
+ } else {
+ throw e;
+ }
+ }
+ }
+ return icalatt;
+ },
+
+ get icalString() {
+ let comp = this.icalProperty;
+ return comp ? comp.icalString : "";
+ },
+ set icalString(val) {
+ let prop = cal.icsService.createIcalPropertyFromString(val);
+ if (prop.propertyName != "ORGANIZER" && prop.propertyName != "ATTENDEE") {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ this.icalProperty = prop;
+ },
+
+ get properties() {
+ return [...this.mProperties.entries()];
+ },
+
+ // The has/get/set/deleteProperty methods are case-insensitive.
+ getProperty(aName) {
+ return this.mProperties.get(aName.toUpperCase());
+ },
+ setProperty(aName, aValue) {
+ this.modify();
+ if (aValue || !isNaN(parseInt(aValue, 10))) {
+ this.mProperties.set(aName.toUpperCase(), aValue);
+ } else {
+ this.mProperties.delete(aName.toUpperCase());
+ }
+ },
+ deleteProperty(aName) {
+ this.modify();
+ this.mProperties.delete(aName.toUpperCase());
+ },
+
+ mId: null,
+ get id() {
+ return this.mId;
+ },
+ set id(aId) {
+ this.modify();
+ // RFC 1738 para 2.1 says we should be using lowercase mailto: urls
+ // we enforce prepending the mailto prefix for email type ids as migration code bug 1199942
+ this.mId = aId ? cal.email.prependMailTo(aId) : null;
+ },
+
+ toString() {
+ const emailRE = new RegExp("^mailto:", "i");
+ let stringRep = (this.id || "").replace(emailRE, "");
+ let commonName = this.commonName;
+
+ if (commonName) {
+ stringRep = commonName + " <" + stringRep + ">";
+ }
+
+ return stringRep;
+ },
+};
+
+makeMemberAttr(CalAttendee, "mCommonName", "commonName", null);
+makeMemberAttr(CalAttendee, "mRsvp", "rsvp", null);
+makeMemberAttr(CalAttendee, "mRole", "role", null);
+makeMemberAttr(CalAttendee, "mParticipationStatus", "participationStatus", "NEEDS-ACTION");
+makeMemberAttr(CalAttendee, "mUserType", "userType", "INDIVIDUAL");
diff --git a/comm/calendar/base/src/CalCalendarManager.jsm b/comm/calendar/base/src/CalCalendarManager.jsm
new file mode 100644
index 0000000000..117737a635
--- /dev/null
+++ b/comm/calendar/base/src/CalCalendarManager.jsm
@@ -0,0 +1,1076 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from calCachedCalendar.js */
+
+var EXPORTED_SYMBOLS = ["CalCalendarManager"];
+
+const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
+const { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs");
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { calCachedCalendar } = ChromeUtils.import("resource:///components/calCachedCalendar.js");
+const { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+var REGISTRY_BRANCH = "calendar.registry.";
+var MAX_INT = Math.pow(2, 31) - 1;
+var MIN_INT = -MAX_INT;
+
+function CalCalendarManager() {
+ this.wrappedJSObject = this;
+ this.mObservers = new cal.data.ListenerSet(Ci.calICalendarManagerObserver);
+ this.mCalendarObservers = new cal.data.ListenerSet(Ci.calIObserver);
+
+ this.providerImplementations = {};
+}
+
+var calCalendarManagerClassID = Components.ID("{f42585e7-e736-4600-985d-9624c1c51992}");
+var calCalendarManagerInterfaces = [Ci.calICalendarManager, Ci.calIStartupService, Ci.nsIObserver];
+CalCalendarManager.prototype = {
+ classID: calCalendarManagerClassID,
+ QueryInterface: cal.generateQI(["calICalendarManager", "calIStartupService", "nsIObserver"]),
+ classInfo: cal.generateCI({
+ classID: calCalendarManagerClassID,
+ contractID: "@mozilla.org/calendar/manager;1",
+ classDescription: "Calendar Manager",
+ interfaces: calCalendarManagerInterfaces,
+ flags: Ci.nsIClassInfo.SINGLETON,
+ }),
+
+ get networkCalendarCount() {
+ return this.mNetworkCalendarCount;
+ },
+ get readOnlyCalendarCount() {
+ return this.mReadonlyCalendarCount;
+ },
+ get calendarCount() {
+ return this.mCalendarCount;
+ },
+
+ // calIStartupService:
+ startup(aCompleteListener) {
+ AddonManager.addAddonListener(gCalendarManagerAddonListener);
+ this.mCache = null;
+ this.mCalObservers = null;
+ this.mRefreshTimer = {};
+ this.setupOfflineObservers();
+ this.mNetworkCalendarCount = 0;
+ this.mReadonlyCalendarCount = 0;
+ this.mCalendarCount = 0;
+
+ // We only add the observer if the pref is set and only check for the
+ // pref on startup to avoid checking for every http request
+ if (Services.prefs.getBoolPref("calendar.network.multirealm", false)) {
+ Services.obs.addObserver(this, "http-on-examine-response");
+ }
+
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+
+ shutdown(aCompleteListener) {
+ for (let id in this.mCache) {
+ let calendar = this.mCache[id];
+ calendar.removeObserver(this.mCalObservers[calendar.id]);
+ }
+
+ this.cleanupOfflineObservers();
+
+ AddonManager.removeAddonListener(gCalendarManagerAddonListener);
+
+ // Remove the observer if the pref is set. This might fail when the
+ // user flips the pref, but we assume he is going to restart anyway
+ // afterwards.
+ if (Services.prefs.getBoolPref("calendar.network.multirealm", false)) {
+ Services.obs.removeObserver(this, "http-on-examine-response");
+ }
+
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+
+ setupOfflineObservers() {
+ Services.obs.addObserver(this, "network:offline-status-changed");
+ },
+
+ cleanupOfflineObservers() {
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "timer-callback": {
+ // Refresh all the calendars that can be refreshed.
+ for (let calendar of this.getCalendars()) {
+ maybeRefreshCalendar(calendar);
+ }
+ break;
+ }
+ case "network:offline-status-changed": {
+ for (let id in this.mCache) {
+ let calendar = this.mCache[id];
+ if (calendar instanceof calCachedCalendar) {
+ calendar.onOfflineStatusChanged(aData == "offline");
+ }
+ }
+ break;
+ }
+ case "http-on-examine-response": {
+ try {
+ let channel = aSubject.QueryInterface(Ci.nsIHttpChannel);
+ if (channel.notificationCallbacks) {
+ // We use the notification callbacks to get the calendar interface, which likely works
+ // for our requests since getInterface is called from the calendar provider context.
+ let authHeader = channel.getResponseHeader("WWW-Authenticate");
+ let calendar = channel.notificationCallbacks.getInterface(Ci.calICalendar);
+ if (calendar && !calendar.getProperty("capabilities.realmrewrite.disabled")) {
+ // The provider may choose to explicitly disable the rewriting, for example if all
+ // calendars on a domain have the same credentials
+ let escapedName = calendar.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ authHeader = appendToRealm(authHeader, "(" + escapedName + ")");
+ channel.setResponseHeader("WWW-Authenticate", authHeader, false);
+ }
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_NOINTERFACE && e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ throw e;
+ }
+ // Possible reasons we got here:
+ // - Its not a http channel (wtf? Oh well)
+ // - The owner is not a calICalendar (looks like its not our deal)
+ // - The WWW-Authenticate header is missing (that's ok)
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * calICalendarManager interface
+ */
+ createCalendar(type, uri) {
+ try {
+ let calendar;
+ if (Cc["@mozilla.org/calendar/calendar;1?type=" + type]) {
+ calendar = Cc["@mozilla.org/calendar/calendar;1?type=" + type].createInstance(
+ Ci.calICalendar
+ );
+ } else if (this.providerImplementations[type]) {
+ let CalendarProvider = this.providerImplementations[type];
+ calendar = new CalendarProvider();
+ if (calendar.QueryInterface) {
+ calendar = calendar.QueryInterface(Ci.calICalendar);
+ }
+ } else {
+ // Don't notify the user with an extra dialog if the provider interface is missing.
+ return null;
+ }
+
+ calendar.uri = uri;
+ return calendar;
+ } catch (ex) {
+ let rc = ex;
+ if (ex instanceof Ci.nsIException) {
+ rc = ex.result;
+ }
+
+ let uiMessage = cal.l10n.getCalString("unableToCreateProvider", [uri.spec]);
+
+ // Log the original exception via error console to provide more debug info
+ cal.ERROR(ex);
+
+ // Log the possibly translated message via the UI.
+ let paramBlock = Cc["@mozilla.org/embedcomp/dialogparam;1"].createInstance(
+ Ci.nsIDialogParamBlock
+ );
+ paramBlock.SetNumberStrings(3);
+ paramBlock.SetString(0, uiMessage);
+ paramBlock.SetString(1, "0x" + rc.toString(0x10));
+ paramBlock.SetString(2, ex);
+ Services.ww.openWindow(
+ null,
+ "chrome://calendar/content/calendar-error-prompt.xhtml",
+ "_blank",
+ "chrome,dialog=yes,alwaysRaised=yes",
+ paramBlock
+ );
+ return null;
+ }
+ },
+
+ /**
+ * Creates a calendar and takes care of initial setup, including enabled/disabled properties and
+ * cached calendars. If the provider doesn't exist, returns a dummy calendar that is
+ * force-disabled.
+ *
+ * @param {string} id - The calendar id.
+ * @param {string} ctype - The calendar type. See {@link calICalendar#type}.
+ * @param {string} uri - The calendar uri.
+ * @returns {calICalendar} The initialized calendar or dummy calendar.
+ */
+ initializeCalendar(id, ctype, uri) {
+ let calendar = this.createCalendar(ctype, uri);
+ if (calendar) {
+ calendar.id = id;
+ if (calendar.getProperty("auto-enabled")) {
+ calendar.deleteProperty("disabled");
+ calendar.deleteProperty("auto-enabled");
+ }
+
+ calendar = maybeWrapCachedCalendar(calendar);
+ } else {
+ // Create dummy calendar that stays disabled for this run.
+ calendar = new calDummyCalendar(ctype);
+ calendar.id = id;
+ calendar.uri = uri;
+ // Try to enable on next startup if calendar has been enabled.
+ if (!calendar.getProperty("disabled")) {
+ calendar.setProperty("auto-enabled", true);
+ }
+ calendar.setProperty("disabled", true);
+ }
+
+ return calendar;
+ },
+
+ /**
+ * Update calendar registrations for the given type. If the provider is missing then the calendars
+ * are replaced with a dummy calendar, and vice versa.
+ *
+ * @param {string} type - The calendar type to update. See {@link calICalendar#type}.
+ * @param {boolean} [clearCache=false] - If true, the calendar cache is also cleared.
+ */
+ updateDummyCalendarRegistration(type, clearCache = false) {
+ let hasImplementation = !!this.providerImplementations[type];
+
+ let calendars = Object.values(this.mCache).filter(calendar => {
+ // Calendars backed by providers despite missing provider implementation, or dummy calendars
+ // despite having a provider implementation.
+ let isDummyCalendar = calendar instanceof calDummyCalendar;
+ return calendar.type == type && hasImplementation == isDummyCalendar;
+ });
+ this.updateCalendarRegistration(calendars, clearCache);
+ },
+
+ /**
+ * Update the calendar registrations for the given set of calendars. This essentially unregisters
+ * the calendar, then sets it up again using id, type and uri. This is similar to what happens on
+ * startup.
+ *
+ * @param {calICalendar[]} calendars - The calendars to update.
+ * @param {boolean} [clearCache=false] - If true, the calendar cache is also cleared.
+ */
+ updateCalendarRegistration(calendars, clearCache = false) {
+ let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" ");
+ let sortOrder = {};
+ for (let i = 0; i < sortOrderPref.length; i++) {
+ sortOrder[sortOrderPref[i]] = i;
+ }
+
+ let needsRefresh = [];
+ for (let calendar of calendars) {
+ try {
+ this.notifyObservers("onCalendarUnregistering", [calendar]);
+ this.unsetupCalendar(calendar, clearCache);
+
+ let replacement = this.initializeCalendar(calendar.id, calendar.type, calendar.uri);
+ replacement.setProperty("initialSortOrderPos", sortOrder[calendar.id]);
+
+ this.setupCalendar(replacement);
+ needsRefresh.push(replacement);
+ } catch (e) {
+ cal.ERROR(
+ `Can't create calendar for ${calendar.id} (${calendar.type}, ${calendar.uri.spec}): ${e}`
+ );
+ }
+ }
+
+ // Do this in a second pass so that all provider calendars are available.
+ for (let calendar of needsRefresh) {
+ maybeRefreshCalendar(calendar);
+ this.notifyObservers("onCalendarRegistered", [calendar]);
+ }
+ },
+
+ /**
+ * Register a calendar provider with the given JavaScript implementation.
+ *
+ * @param {string} type - The calendar type string, see {@link calICalendar#type}.
+ * @param {object} impl - The class that implements calICalendar.
+ */
+ registerCalendarProvider(type, impl) {
+ this.assureCache();
+
+ cal.ASSERT(
+ !this.providerImplementations.hasOwnProperty(type),
+ "[CalCalendarManager::registerCalendarProvider] provider already exists",
+ true
+ );
+
+ this.providerImplementations[type] = impl;
+ this.updateDummyCalendarRegistration(type);
+ },
+
+ /**
+ * Unregister a calendar provider by type. Already registered calendars will be replaced by a
+ * dummy calendar that is force-disabled.
+ *
+ * @param {string} type - The calendar type string, see {@link calICalendar#type}.
+ * @param {boolean} temporary - If true, cached calendars will not be cleared.
+ */
+ unregisterCalendarProvider(type, temporary = false) {
+ cal.ASSERT(
+ this.providerImplementations.hasOwnProperty(type),
+ "[CalCalendarManager::unregisterCalendarProvider] provider doesn't exist or is builtin",
+ true
+ );
+ delete this.providerImplementations[type];
+ this.updateDummyCalendarRegistration(type, !temporary);
+ },
+
+ /**
+ * Checks if a calendar provider has been dynamically registered with the given type. This does
+ * not check for the built-in XPCOM providers.
+ *
+ * @param {string} type - The calendar type string, see {@link calICalendar#type}.
+ * @returns {boolean} True, if the calendar provider type is registered.
+ */
+ hasCalendarProvider(type) {
+ return !!this.providerImplementations[type];
+ },
+
+ registerCalendar(calendar) {
+ this.assureCache();
+
+ // If the calendar is already registered, bail out
+ cal.ASSERT(
+ !calendar.id || !(calendar.id in this.mCache),
+ "[CalCalendarManager::registerCalendar] calendar already registered!",
+ true
+ );
+
+ if (!calendar.id) {
+ calendar.id = cal.getUUID();
+ }
+
+ Services.prefs.setStringPref(getPrefBranchFor(calendar.id) + "type", calendar.type);
+ Services.prefs.setStringPref(getPrefBranchFor(calendar.id) + "uri", calendar.uri.spec);
+
+ calendar = maybeWrapCachedCalendar(calendar);
+
+ this.setupCalendar(calendar);
+ flushPrefs();
+
+ maybeRefreshCalendar(calendar);
+ this.notifyObservers("onCalendarRegistered", [calendar]);
+ },
+
+ /**
+ * Sets up a calendar, this is the initialization required during calendar registration. See
+ * {@link #unsetupCalendar} to revert these steps.
+ *
+ * @param {calICalendar} calendar - The calendar to set up.
+ */
+ setupCalendar(calendar) {
+ this.mCache[calendar.id] = calendar;
+
+ // Add an observer to track readonly-mode triggers
+ let newObserver = new calMgrCalendarObserver(calendar, this);
+ calendar.addObserver(newObserver);
+ this.mCalObservers[calendar.id] = newObserver;
+
+ // Set up statistics
+ if (calendar.getProperty("requiresNetwork") !== false) {
+ this.mNetworkCalendarCount++;
+ }
+ if (calendar.readOnly) {
+ this.mReadonlyCalendarCount++;
+ }
+ this.mCalendarCount++;
+
+ // Set up the refresh timer
+ this.setupRefreshTimer(calendar);
+ },
+
+ /**
+ * Reverts the calendar registration setup steps from {@link #setupCalendar}.
+ *
+ * @param {calICalendar} calendar - The calendar to undo setup for.
+ * @param {boolean} [clearCache=false] - If true, the cache is cleared for this calendar.
+ */
+ unsetupCalendar(calendar, clearCache = false) {
+ if (this.mCache) {
+ delete this.mCache[calendar.id];
+ }
+
+ if (clearCache && calendar.wrappedJSObject instanceof calCachedCalendar) {
+ calendar.wrappedJSObject.onCalendarUnregistering();
+ }
+
+ calendar.removeObserver(this.mCalObservers[calendar.id]);
+
+ if (calendar.readOnly) {
+ this.mReadonlyCalendarCount--;
+ }
+
+ if (calendar.getProperty("requiresNetwork") !== false) {
+ this.mNetworkCalendarCount--;
+ }
+ this.mCalendarCount--;
+
+ this.clearRefreshTimer(calendar);
+ },
+
+ setupRefreshTimer(aCalendar) {
+ // Add the refresh timer for this calendar
+ let refreshInterval = aCalendar.getProperty("refreshInterval");
+ if (refreshInterval === null) {
+ // Default to 30 minutes, in case the value is missing
+ refreshInterval = 30;
+ }
+
+ this.clearRefreshTimer(aCalendar);
+
+ if (refreshInterval > 0) {
+ this.mRefreshTimer[aCalendar.id] = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ this.mRefreshTimer[aCalendar.id].initWithCallback(
+ new timerCallback(aCalendar),
+ refreshInterval * 60000,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ }
+ },
+
+ clearRefreshTimer(aCalendar) {
+ if (aCalendar.id in this.mRefreshTimer && this.mRefreshTimer[aCalendar.id]) {
+ this.mRefreshTimer[aCalendar.id].cancel();
+ delete this.mRefreshTimer[aCalendar.id];
+ }
+ },
+
+ unregisterCalendar(calendar) {
+ this.notifyObservers("onCalendarUnregistering", [calendar]);
+ this.unsetupCalendar(calendar, true);
+
+ deletePrefBranch(calendar.id);
+ flushPrefs();
+ },
+
+ removeCalendar(calendar, mode = 0) {
+ const cICM = Ci.calICalendarManager;
+
+ let removeModes = new Set(calendar.getProperty("capabilities.removeModes") || ["unsubscribe"]);
+ if (!removeModes.has("unsubscribe") && !removeModes.has("delete")) {
+ // Removing is not allowed
+ return;
+ }
+
+ if (mode & cICM.REMOVE_NO_UNREGISTER && this.mCache && calendar.id in this.mCache) {
+ throw new Components.Exception("Can't remove a registered calendar");
+ } else if (!(mode & cICM.REMOVE_NO_UNREGISTER)) {
+ this.unregisterCalendar(calendar);
+ }
+
+ // This observer notification needs to be fired for both unsubscribe
+ // and delete, we don't differ this at the moment.
+ this.notifyObservers("onCalendarDeleting", [calendar]);
+
+ // For deleting, we also call the deleteCalendar method from the provider.
+ if (removeModes.has("delete") && (mode & cICM.REMOVE_NO_DELETE) == 0) {
+ let wrappedCalendar = calendar.QueryInterface(Ci.calICalendarProvider);
+ wrappedCalendar.deleteCalendar(calendar, null);
+ }
+ },
+
+ getCalendarById(aId) {
+ if (aId in this.mCache) {
+ return this.mCache[aId];
+ }
+ return null;
+ },
+
+ getCalendars() {
+ this.assureCache();
+ let calendars = [];
+ for (let id in this.mCache) {
+ let calendar = this.mCache[id];
+ calendars.push(calendar);
+ }
+ return calendars;
+ },
+
+ /**
+ * Load calendars from the pref branch, if they haven't already been loaded. The calendar
+ * instances will end up in mCache and are refreshed when complete.
+ */
+ assureCache() {
+ if (this.mCache) {
+ return;
+ }
+
+ this.mCache = {};
+ this.mCalObservers = {};
+
+ let allCals = {};
+ for (let key of Services.prefs.getChildList(REGISTRY_BRANCH)) {
+ // merge down all keys
+ allCals[key.substring(0, key.indexOf(".", REGISTRY_BRANCH.length))] = true;
+ }
+
+ for (let calBranch in allCals) {
+ let id = calBranch.substring(REGISTRY_BRANCH.length);
+ let ctype = Services.prefs.getStringPref(calBranch + ".type", null);
+ let curi = Services.prefs.getStringPref(calBranch + ".uri", null);
+
+ try {
+ if (!ctype || !curi) {
+ // sanity check
+ deletePrefBranch(id);
+ continue;
+ }
+
+ let uri = Services.io.newURI(curi);
+ let calendar = this.initializeCalendar(id, ctype, uri);
+ this.setupCalendar(calendar);
+ } catch (exc) {
+ cal.ERROR(`Can't create calendar for ${id} (${ctype}, ${curi}): ${exc}`);
+ }
+ }
+
+ let shouldResyncGoogleCalDav = false;
+ if (!Services.prefs.prefHasUserValue("calendar.caldav.googleResync")) {
+ // Some users' calendars got into a bad state due to Google rate-limit
+ // problems so this code triggers a full resync.
+ shouldResyncGoogleCalDav = true;
+ }
+
+ // do refreshing in a second step, when *all* calendars are already available
+ // via getCalendars():
+ for (let calendar of Object.values(this.mCache)) {
+ let delay = 0;
+
+ // The special-casing of ICS here is a very ugly hack. We can delay most
+ // cached calendars without an issue, but the ICS implementation has two
+ // properties which make that dangerous in its case:
+ //
+ // 1) ICS files can only be written whole cloth. Since it's a plain file,
+ // we need to know the entire contents of what we want to write.
+ //
+ // 2) It is backed by a memory calendar which it regards as its source of
+ // truth, and the backing calendar is only populated on a refresh.
+ //
+ // The combination of these two means that any update to the ICS calendar
+ // before the memory calendar is populated will erase everything in the
+ // calendar (except potentially the added item if that's what we're
+ // doing). A 15 second window for data loss-inducing updates isn't huge,
+ // but it's more than we should bet on.
+ //
+ // Why not fix this a different way? Trying to populate the memory
+ // calendar outside of a refresh causes the caching calendar to get
+ // confused about event ownership and identity, leading to bogus observer
+ // notifications and potential duplication of events in some parts of the
+ // interface. Having the ICS calendar refresh itself internally can cause
+ // disabled calendars to behave improperly, since calendars don't actually
+ // enforce their own disablement and may not know if they're disabled
+ // until after we try to refresh. Having the ICS calendar ensure it has
+ // refreshed itself before trying to make updates would require a fair bit
+ // of refactoring in its processing queue and, while it should probably
+ // happen, fingers crossed we can rework the provider architecture to make
+ // many of these problems less of an issue first.
+ const canDelay = calendar.getProperty("cache.enabled") && calendar.type != "ics";
+
+ if (canDelay) {
+ // If the calendar is cached, we don't need to refresh it RIGHT NOW, so let's wait a
+ // while and let other things happen first.
+ delay = 15000;
+
+ if (
+ shouldResyncGoogleCalDav &&
+ calendar.type == "caldav" &&
+ calendar.uri.prePath == "https://apidata.googleusercontent.com"
+ ) {
+ cal.LOG(`CalDAV: Resetting sync token of ${calendar.name} to perform a full resync`);
+ let calCachedCalendar = calendar.wrappedJSObject;
+ let calDavCalendar = calCachedCalendar.mUncachedCalendar.wrappedJSObject;
+ calDavCalendar.mWebdavSyncToken = null;
+ calDavCalendar.saveCalendarProperties();
+ }
+ }
+ setTimeout(() => maybeRefreshCalendar(calendar), delay);
+ }
+
+ if (shouldResyncGoogleCalDav) {
+ // Record the fact that we've scheduled a resync, so that we only do it once.
+ // Store the date instead of a boolean because we might want to use this again some day.
+ Services.prefs.setIntPref("calendar.caldav.googleResync", Date.now() / 1000);
+ }
+ },
+
+ getCalendarPref_(calendar, name) {
+ cal.ASSERT(calendar, "Invalid Calendar!");
+ cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!");
+ cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!");
+
+ let branch = getPrefBranchFor(calendar.id) + name;
+ let value = Preferences.get(branch, null);
+
+ if (typeof value == "string" && value.startsWith("bignum:")) {
+ let converted = Number(value.substr(7));
+ if (!isNaN(converted)) {
+ value = converted;
+ }
+ }
+ return value;
+ },
+
+ setCalendarPref_(calendar, name, value) {
+ cal.ASSERT(calendar, "Invalid Calendar!");
+ cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!");
+ cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!");
+
+ let branch = getPrefBranchFor(calendar.id) + name;
+
+ if (
+ typeof value == "number" &&
+ (value > MAX_INT || value < MIN_INT || !Number.isInteger(value))
+ ) {
+ // This is something the preferences service can't store directly.
+ // Convert to string and tag it so we know how to handle it.
+ value = "bignum:" + value;
+ }
+
+ // Delete before to allow pref-type changes, then set the pref.
+ Services.prefs.clearUserPref(branch);
+ if (value !== null && value !== undefined) {
+ Preferences.set(branch, value);
+ }
+ },
+
+ deleteCalendarPref_(calendar, name) {
+ cal.ASSERT(calendar, "Invalid Calendar!");
+ cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!");
+ cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!");
+ Services.prefs.clearUserPref(getPrefBranchFor(calendar.id) + name);
+ },
+
+ mObservers: null,
+ addObserver(aObserver) {
+ this.mObservers.add(aObserver);
+ },
+ removeObserver(aObserver) {
+ this.mObservers.delete(aObserver);
+ },
+ notifyObservers(functionName, args) {
+ this.mObservers.notify(functionName, args);
+ },
+
+ mCalendarObservers: null,
+ addCalendarObserver(aObserver) {
+ return this.mCalendarObservers.add(aObserver);
+ },
+ removeCalendarObserver(aObserver) {
+ return this.mCalendarObservers.delete(aObserver);
+ },
+ notifyCalendarObservers(functionName, args) {
+ this.mCalendarObservers.notify(functionName, args);
+ },
+};
+
+function equalMessage(msg1, msg2) {
+ if (
+ msg1.GetString(0) == msg2.GetString(0) &&
+ msg1.GetString(1) == msg2.GetString(1) &&
+ msg1.GetString(2) == msg2.GetString(2)
+ ) {
+ return true;
+ }
+ return false;
+}
+
+function calMgrCalendarObserver(calendar, calMgr) {
+ this.calendar = calendar;
+ // We compare this to determine if the state actually changed.
+ this.storedReadOnly = calendar.readOnly;
+ this.announcedMessages = [];
+ this.calMgr = calMgr;
+}
+
+calMgrCalendarObserver.prototype = {
+ calendar: null,
+ storedReadOnly: null,
+ calMgr: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowMediatorListener", "calIObserver"]),
+
+ // calIObserver:
+ onStartBatch() {
+ return this.calMgr.notifyCalendarObservers("onStartBatch", arguments);
+ },
+ onEndBatch() {
+ return this.calMgr.notifyCalendarObservers("onEndBatch", arguments);
+ },
+ onLoad(calendar) {
+ return this.calMgr.notifyCalendarObservers("onLoad", arguments);
+ },
+ onAddItem(aItem) {
+ return this.calMgr.notifyCalendarObservers("onAddItem", arguments);
+ },
+ onModifyItem(aNewItem, aOldItem) {
+ return this.calMgr.notifyCalendarObservers("onModifyItem", arguments);
+ },
+ onDeleteItem(aDeletedItem) {
+ return this.calMgr.notifyCalendarObservers("onDeleteItem", arguments);
+ },
+ onError(aCalendar, aErrNo, aMessage) {
+ this.calMgr.notifyCalendarObservers("onError", arguments);
+ this.announceError(aCalendar, aErrNo, aMessage);
+ },
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.calMgr.notifyCalendarObservers("onPropertyChanged", arguments);
+ switch (aName) {
+ case "requiresNetwork":
+ this.calMgr.mNetworkCalendarCount += aValue ? 1 : -1;
+ break;
+ case "readOnly":
+ this.calMgr.mReadonlyCalendarCount += aValue ? 1 : -1;
+ break;
+ case "refreshInterval":
+ this.calMgr.setupRefreshTimer(aCalendar);
+ break;
+ case "cache.enabled":
+ this.changeCalendarCache(...arguments);
+ break;
+ case "disabled":
+ if (!aValue && aCalendar.canRefresh) {
+ aCalendar.refresh();
+ }
+ break;
+ }
+ },
+
+ changeCalendarCache(aCalendar, aName, aValue, aOldValue) {
+ const cICM = Ci.calICalendarManager;
+ aOldValue = aOldValue || false;
+ aValue = aValue || false;
+
+ // hack for bug 1182264 to deal with calendars, which have set cache.enabled, but in fact do
+ // not support caching (like storage calendars) - this also prevents enabling cache again
+ if (aCalendar.getProperty("cache.supported") === false) {
+ if (aCalendar.getProperty("cache.enabled") === true) {
+ aCalendar.deleteProperty("cache.enabled");
+ }
+ return;
+ }
+
+ if (aOldValue != aValue) {
+ // Try to find the current sort order
+ let sortOrderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "").split(" ");
+ let initialSortOrderPos = null;
+ for (let i = 0; i < sortOrderPref.length; ++i) {
+ if (sortOrderPref[i] == aCalendar.id) {
+ initialSortOrderPos = i;
+ }
+ }
+ // Enabling or disabling cache on a calendar re-creates
+ // it so the registerCalendar call can wrap/unwrap the
+ // calCachedCalendar facade saving the user the need to
+ // restart Thunderbird and making sure a new Id is used.
+ this.calMgr.removeCalendar(aCalendar, cICM.REMOVE_NO_DELETE);
+ let newCal = this.calMgr.createCalendar(aCalendar.type, aCalendar.uri);
+ newCal.name = aCalendar.name;
+
+ // TODO: if properties get added this list will need to be adjusted,
+ // ideally we should add a "getProperties" method to calICalendar.idl
+ // to retrieve all non-transient properties for a calendar.
+ let propsToCopy = [
+ "color",
+ "disabled",
+ "auto-enabled",
+ "cache.enabled",
+ "refreshInterval",
+ "suppressAlarms",
+ "calendar-main-in-composite",
+ "calendar-main-default",
+ "readOnly",
+ "imip.identity.key",
+ "username",
+ ];
+ for (let prop of propsToCopy) {
+ newCal.setProperty(prop, aCalendar.getProperty(prop));
+ }
+
+ if (initialSortOrderPos != null) {
+ newCal.setProperty("initialSortOrderPos", initialSortOrderPos);
+ }
+ this.calMgr.registerCalendar(newCal);
+ } else if (aCalendar.wrappedJSObject instanceof calCachedCalendar) {
+ // any attempt to switch this flag will reset the cached calendar;
+ // could be useful for users in case the cache may be corrupted.
+ aCalendar.wrappedJSObject.setupCachedCalendar();
+ }
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.onPropertyChanged(aCalendar, aName, false, true);
+ },
+
+ // Error announcer specific functions
+ announceError(aCalendar, aErrNo, aMessage) {
+ let paramBlock = Cc["@mozilla.org/embedcomp/dialogparam;1"].createInstance(
+ Ci.nsIDialogParamBlock
+ );
+ let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties");
+ let errMsg;
+ paramBlock.SetNumberStrings(3);
+ if (!this.storedReadOnly && this.calendar.readOnly) {
+ // Major errors change the calendar to readOnly
+ errMsg = props.formatStringFromName("readOnlyMode", [this.calendar.name]);
+ } else if (!this.storedReadOnly && !this.calendar.readOnly) {
+ // Minor errors don't, but still tell the user something went wrong
+ errMsg = props.formatStringFromName("minorError", [this.calendar.name]);
+ } else {
+ // The calendar was already in readOnly mode, but still tell the user
+ errMsg = props.formatStringFromName("stillReadOnlyError", [this.calendar.name]);
+ }
+
+ // When possible, change the error number into its name, to
+ // make it slightly more readable.
+ let errCode = "0x" + aErrNo.toString(16);
+ const calIErrors = Ci.calIErrors;
+ // Check if it is worth enumerating all the error codes.
+ if (aErrNo & calIErrors.ERROR_BASE) {
+ for (let err in calIErrors) {
+ if (calIErrors[err] == aErrNo) {
+ errCode = err;
+ }
+ }
+ }
+
+ let message;
+ switch (aErrNo) {
+ case calIErrors.CAL_UTF8_DECODING_FAILED:
+ message = props.GetStringFromName("utf8DecodeError");
+ break;
+ case calIErrors.ICS_MALFORMEDDATA:
+ message = props.GetStringFromName("icsMalformedError");
+ break;
+ case calIErrors.MODIFICATION_FAILED:
+ errMsg = cal.l10n.getCalString("errorWriting2", [aCalendar.name]);
+ message = cal.l10n.getCalString("errorWritingDetails");
+ if (aMessage) {
+ message = aMessage + "\n" + message;
+ }
+ break;
+ default:
+ message = aMessage;
+ }
+
+ paramBlock.SetString(0, errMsg);
+ paramBlock.SetString(1, errCode);
+ paramBlock.SetString(2, message);
+
+ this.storedReadOnly = this.calendar.readOnly;
+ let errorCode = cal.l10n.getCalString("errorCode", [errCode]);
+ let errorDescription = cal.l10n.getCalString("errorDescription", [message]);
+ let summary = errMsg + " " + errorCode + ". " + errorDescription;
+
+ // Log warnings in error console.
+ // Report serious errors in both error console and in prompt window.
+ if (aErrNo == calIErrors.MODIFICATION_FAILED) {
+ console.error(summary);
+ this.announceParamBlock(paramBlock);
+ } else {
+ cal.WARN(summary);
+ }
+ },
+
+ announceParamBlock(paramBlock) {
+ function awaitLoad(event) {
+ promptWindow.addEventListener("unload", awaitUnload, { capture: false, once: true });
+ }
+ let awaitUnload = event => {
+ // unloaded (user closed prompt window),
+ // remove paramBlock and unload listener.
+ try {
+ // remove the message that has been shown from
+ // the list of all announced messages.
+ this.announcedMessages = this.announcedMessages.filter(msg => {
+ return !equalMessage(msg, paramBlock);
+ });
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ // silently don't do anything if this message already has been
+ // announced without being acknowledged.
+ if (this.announcedMessages.some(equalMessage.bind(null, paramBlock))) {
+ return;
+ }
+
+ // this message hasn't been announced recently, remember the details of
+ // the message for future reference.
+ this.announcedMessages.push(paramBlock);
+
+ // Will remove paramBlock from announced messages when promptWindow is
+ // closed. (Closing fires unloaded event, but promptWindow is also
+ // unloaded [to clean it?] before loading, so wait for detected load
+ // event before detecting unload event that signifies user closed this
+ // prompt window.)
+ let promptUrl = "chrome://calendar/content/calendar-error-prompt.xhtml";
+ let features = "chrome,dialog=yes,alwaysRaised=yes";
+ let promptWindow = Services.ww.openWindow(null, promptUrl, "_blank", features, paramBlock);
+ promptWindow.addEventListener("load", awaitLoad, { capture: false, once: true });
+ },
+};
+
+function calDummyCalendar(type) {
+ this.initProviderBase();
+ this.type = type;
+}
+calDummyCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+
+ getProperty(aName) {
+ switch (aName) {
+ case "force-disabled":
+ return true;
+ default:
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ }
+ },
+};
+
+function getPrefBranchFor(id) {
+ return REGISTRY_BRANCH + id + ".";
+}
+
+/**
+ * Removes a calendar from the preferences.
+ *
+ * @param {string} id - ID of the calendar to remove.
+ */
+function deletePrefBranch(id) {
+ for (let prefName of Services.prefs.getChildList(getPrefBranchFor(id))) {
+ Services.prefs.clearUserPref(prefName);
+ }
+}
+
+/**
+ * Helper to refresh a calendar, if it can be refreshed and isn't disabled.
+ *
+ * @param {calICalendar} calendar - The calendar to refresh.
+ */
+function maybeRefreshCalendar(calendar) {
+ if (!calendar.getProperty("disabled") && calendar.canRefresh) {
+ let refreshInterval = calendar.getProperty("refreshInterval");
+ if (refreshInterval != "0") {
+ calendar.refresh();
+ }
+ }
+}
+
+/**
+ * Wrap a calendar using {@link calCachedCalendar}, if the cache is supported and enabled.
+ * Otherwise just return the passed in calendar.
+ *
+ * @param {calICalendar} calendar - The calendar to potentially wrap.
+ * @returns {calICalendar} The potentially wrapped calendar.
+ */
+function maybeWrapCachedCalendar(calendar) {
+ if (
+ calendar.getProperty("cache.supported") !== false &&
+ (calendar.getProperty("cache.enabled") || calendar.getProperty("cache.always"))
+ ) {
+ calendar = new calCachedCalendar(calendar);
+ }
+ return calendar;
+}
+
+/**
+ * Helper function to flush the preferences file. If the application crashes
+ * after a calendar has been created using the prefs registry, then the calendar
+ * won't show up. Writing the prefs helps counteract.
+ */
+function flushPrefs() {
+ Services.prefs.savePrefFile(null);
+}
+
+/**
+ * Callback object for the refresh timer. Should be called as an object, i.e
+ * let foo = new timerCallback(calendar);
+ *
+ * @param aCalendar The calendar to refresh on notification
+ */
+function timerCallback(aCalendar) {
+ this.notify = function (aTimer) {
+ if (!aCalendar.getProperty("disabled") && aCalendar.canRefresh) {
+ aCalendar.refresh();
+ }
+ };
+}
+
+var gCalendarManagerAddonListener = {
+ onDisabling(aAddon, aNeedsRestart) {
+ if (!this.queryUninstallProvider(aAddon)) {
+ // If the addon should not be disabled, then re-enable it.
+ aAddon.userDisabled = false;
+ }
+ },
+
+ onUninstalling(aAddon, aNeedsRestart) {
+ if (!this.queryUninstallProvider(aAddon)) {
+ // If the addon should not be uninstalled, then cancel the uninstall.
+ aAddon.cancelUninstall();
+ }
+ },
+
+ queryUninstallProvider(aAddon) {
+ const uri = "chrome://calendar/content/calendar-providerUninstall-dialog.xhtml";
+ const features = "chrome,titlebar,resizable,modal";
+ let affectedCalendars = cal.manager
+ .getCalendars()
+ .filter(calendar => calendar.providerID == aAddon.id);
+ if (!affectedCalendars.length) {
+ // If no calendars are affected, then everything is fine.
+ return true;
+ }
+
+ let args = { shouldUninstall: false, extension: aAddon };
+
+ // Now find a window. The best choice would be the most recent
+ // addons window, otherwise the most recent calendar window, or we
+ // create a new toplevel window.
+ let win =
+ Services.wm.getMostRecentWindow("Extension:Manager") || cal.window.getCalendarWindow();
+ if (win) {
+ win.openDialog(uri, "CalendarProviderUninstallDialog", features, args);
+ } else {
+ // Use the window watcher to open a parentless window.
+ Services.ww.openWindow(null, uri, "CalendarProviderUninstallWindow", features, args);
+ }
+
+ // Now that we are done, check if the dialog was accepted or canceled.
+ return args.shouldUninstall;
+ },
+};
+
+function appendToRealm(authHeader, appendStr) {
+ let isEscaped = false;
+ let idx = authHeader.search(/realm="(.*?)(\\*)"/);
+ if (idx > -1) {
+ let remain = authHeader.substr(idx + 7);
+ idx += 7;
+ while (remain.length && !isEscaped) {
+ let match = remain.match(/(.*?)(\\*)"/);
+ idx += match[0].length;
+
+ isEscaped = match[2].length % 2 == 0;
+ if (!isEscaped) {
+ remain = remain.substr(match[0].length);
+ }
+ }
+ return authHeader.substr(0, idx - 1) + " " + appendStr + authHeader.substr(idx - 1);
+ }
+ return authHeader;
+}
diff --git a/comm/calendar/base/src/CalDateTime.jsm b/comm/calendar/base/src/CalDateTime.jsm
new file mode 100644
index 0000000000..8c7daefa32
--- /dev/null
+++ b/comm/calendar/base/src/CalDateTime.jsm
@@ -0,0 +1,202 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDateTime"];
+
+const { ICAL, unwrap, unwrapSetter } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm");
+ChromeUtils.defineModuleGetter(lazy, "CalTimezone", "resource:///modules/CalTimezone.jsm");
+
+var UNIX_TIME_TO_PRTIME = 1000000;
+
+function CalDateTime(innerObject) {
+ this.wrappedJSObject = this;
+ this.innerObject = innerObject || ICAL.Time.epochTime.clone();
+}
+
+CalDateTime.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIDateTime"]),
+ classID: Components.ID("{36783242-ec94-4d8a-9248-d2679edd55b9}"),
+
+ isMutable: true,
+ makeImmutable() {
+ this.isMutable = false;
+ },
+ clone() {
+ return new CalDateTime(this.innerObject.clone());
+ },
+
+ isValid: true,
+ innerObject: null,
+
+ get nativeTime() {
+ return this.innerObject.toUnixTime() * UNIX_TIME_TO_PRTIME;
+ },
+ set nativeTime(val) {
+ this.innerObject.fromUnixTime(val / UNIX_TIME_TO_PRTIME);
+ },
+
+ get year() {
+ return this.innerObject.year;
+ },
+ set year(val) {
+ this.innerObject.year = parseInt(val, 10);
+ },
+
+ get month() {
+ return this.innerObject.month - 1;
+ },
+ set month(val) {
+ this.innerObject.month = val + 1;
+ },
+
+ get day() {
+ return this.innerObject.day;
+ },
+ set day(val) {
+ this.innerObject.day = parseInt(val, 10);
+ },
+
+ get hour() {
+ return this.innerObject.hour;
+ },
+ set hour(val) {
+ this.innerObject.hour = parseInt(val, 10);
+ },
+
+ get minute() {
+ return this.innerObject.minute;
+ },
+ set minute(val) {
+ this.innerObject.minute = parseInt(val, 10);
+ },
+
+ get second() {
+ return this.innerObject.second;
+ },
+ set second(val) {
+ this.innerObject.second = parseInt(val, 10);
+ },
+
+ get timezone() {
+ return new lazy.CalTimezone(this.innerObject.zone);
+ },
+ set timezone(rawval) {
+ unwrapSetter(
+ ICAL.Timezone,
+ rawval,
+ function (val) {
+ this.innerObject.zone = val;
+ return val;
+ },
+ this
+ );
+ },
+
+ resetTo(year, month, day, hour, minute, second, timezone) {
+ this.innerObject.fromData({
+ year,
+ month: month + 1,
+ day,
+ hour,
+ minute,
+ second,
+ });
+ this.timezone = timezone;
+ },
+
+ reset() {
+ this.innerObject.reset();
+ },
+
+ get timezoneOffset() {
+ return this.innerObject.utcOffset();
+ },
+ get isDate() {
+ return this.innerObject.isDate;
+ },
+ set isDate(val) {
+ this.innerObject.isDate = !!val;
+ },
+
+ get weekday() {
+ return this.innerObject.dayOfWeek() - 1;
+ },
+ get yearday() {
+ return this.innerObject.dayOfYear();
+ },
+
+ toString() {
+ return this.innerObject.toString();
+ },
+
+ toJSON() {
+ return this.toString();
+ },
+
+ getInTimezone: unwrap(ICAL.Timezone, function (val) {
+ return new CalDateTime(this.innerObject.convertToZone(val));
+ }),
+
+ addDuration: unwrap(ICAL.Duration, function (val) {
+ this.innerObject.addDuration(val);
+ }),
+
+ subtractDate: unwrap(ICAL.Time, function (val) {
+ return new lazy.CalDuration(this.innerObject.subtractDateTz(val));
+ }),
+
+ compare: unwrap(ICAL.Time, function (val) {
+ let a = this.innerObject;
+ let b = val;
+
+ // If either this or aOther is floating, both objects are treated
+ // as floating for the comparison.
+ if (a.zone == ICAL.Timezone.localTimezone || b.zone == ICAL.Timezone.localTimezone) {
+ a = a.convertToZone(ICAL.Timezone.localTimezone);
+ b = b.convertToZone(ICAL.Timezone.localTimezone);
+ }
+
+ if (a.isDate || b.isDate) {
+ // Calendar expects 20120101 and 20120101T010101 to be equal
+ return a.compareDateOnlyTz(b, a.zone);
+ }
+ // If both are dates or date-times, then just do the normal compare
+ return a.compare(b);
+ }),
+
+ get startOfWeek() {
+ return new CalDateTime(this.innerObject.startOfWeek());
+ },
+ get endOfWeek() {
+ return new CalDateTime(this.innerObject.endOfWeek());
+ },
+ get startOfMonth() {
+ return new CalDateTime(this.innerObject.startOfMonth());
+ },
+ get endOfMonth() {
+ return new CalDateTime(this.innerObject.endOfMonth());
+ },
+ get startOfYear() {
+ return new CalDateTime(this.innerObject.startOfYear());
+ },
+ get endOfYear() {
+ return new CalDateTime(this.innerObject.endOfYear());
+ },
+
+ get icalString() {
+ return this.innerObject.toICALString();
+ },
+ set icalString(val) {
+ let jcalString;
+ if (val.length > 10) {
+ jcalString = ICAL.design.icalendar.value["date-time"].fromICAL(val);
+ } else {
+ jcalString = ICAL.design.icalendar.value.date.fromICAL(val);
+ }
+ this.innerObject = ICAL.Time.fromString(jcalString);
+ },
+};
diff --git a/comm/calendar/base/src/CalDefaultACLManager.jsm b/comm/calendar/base/src/CalDefaultACLManager.jsm
new file mode 100644
index 0000000000..ca660f8e67
--- /dev/null
+++ b/comm/calendar/base/src/CalDefaultACLManager.jsm
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDefaultACLManager"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function CalDefaultACLManager() {
+ this.mCalendarEntries = {};
+}
+
+CalDefaultACLManager.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarACLManager"]),
+ classID: Components.ID("{7463258c-6ef3-40a2-89a9-bb349596e927}"),
+
+ mCalendarEntries: null,
+
+ /* calICalendarACLManager */
+ _getCalendarEntryCached(aCalendar) {
+ let calUri = aCalendar.uri.spec;
+ if (!(calUri in this.mCalendarEntries)) {
+ this.mCalendarEntries[calUri] = new calDefaultCalendarACLEntry(this, aCalendar);
+ }
+
+ return this.mCalendarEntries[calUri];
+ },
+ getCalendarEntry(aCalendar, aListener) {
+ let entry = this._getCalendarEntryCached(aCalendar);
+ aListener.onOperationComplete(aCalendar, Cr.NS_OK, Ci.calIOperationListener.GET, null, entry);
+ },
+ getItemEntry(aItem) {
+ let calEntry = this._getCalendarEntryCached(aItem.calendar);
+ return new calDefaultItemACLEntry(calEntry);
+ },
+};
+
+function calDefaultCalendarACLEntry(aMgr, aCalendar) {
+ this.mACLManager = aMgr;
+ this.mCalendar = aCalendar;
+}
+
+calDefaultCalendarACLEntry.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarACLEntry"]),
+
+ mACLManager: null,
+
+ /* calICalendarACLCalendarEntry */
+ get aclManager() {
+ return this.mACLManager;
+ },
+
+ hasAccessControl: false,
+ userIsOwner: true,
+ userCanAddItems: true,
+ userCanDeleteItems: true,
+
+ _getIdentities() {
+ let identities = [];
+ cal.email.iterateIdentities(id => identities.push(id));
+ return identities;
+ },
+
+ getUserAddresses() {
+ let identities = this.getUserIdentities();
+ let addresses = identities.map(id => id.email);
+ return addresses;
+ },
+
+ getUserIdentities() {
+ let identity = cal.provider.getEmailIdentityOfCalendar(this.mCalendar);
+ if (identity) {
+ return [identity];
+ }
+ return this._getIdentities();
+ },
+ getOwnerIdentities() {
+ return this._getIdentities();
+ },
+
+ refresh() {},
+};
+
+function calDefaultItemACLEntry(aCalendarEntry) {
+ this.calendarEntry = aCalendarEntry;
+}
+
+calDefaultItemACLEntry.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]),
+
+ /* calIItemACLEntry */
+ calendarEntry: null,
+ userCanModify: true,
+ userCanRespond: true,
+ userCanViewAll: true,
+ userCanViewDateAndTime: true,
+};
diff --git a/comm/calendar/base/src/CalDeletedItems.jsm b/comm/calendar/base/src/CalDeletedItems.jsm
new file mode 100644
index 0000000000..2db386b7c5
--- /dev/null
+++ b/comm/calendar/base/src/CalDeletedItems.jsm
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDeletedItems"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs");
+
+/**
+ * Handles remembering deleted items.
+ *
+ * This is (currently) not a real trashcan. Only ids and time deleted is stored.
+ * Note also that the code doesn't strictly check the calendar of the item,
+ * except when a calendar id is passed to getDeletedDate.
+ */
+function CalDeletedItems() {
+ this.wrappedJSObject = this;
+
+ this.completedNotifier = {
+ handleResult() {},
+ handleError() {},
+ handleCompletion() {},
+ };
+}
+
+var calDeletedItemsClassID = Components.ID("{8e6799af-e7e9-4e6c-9a82-a2413e86d8c3}");
+var calDeletedItemsInterfaces = [Ci.calIDeletedItems, Ci.nsIObserver, Ci.calIObserver];
+CalDeletedItems.prototype = {
+ classID: calDeletedItemsClassID,
+ QueryInterface: cal.generateQI(["calIDeletedItems", "nsIObserver", "calIObserver"]),
+ classInfo: cal.generateCI({
+ classID: calDeletedItemsClassID,
+ contractID: "@mozilla.org/calendar/deleted-items-manager;1",
+ classDescription: "Database containing information about deleted items",
+ interfaces: calDeletedItemsInterfaces,
+ flags: Ci.nsIClassInfo.SINGLETON,
+ }),
+
+ DB_SCHEMA_VERSION: 1,
+ STALE_TIME: (30 * 24 * 60 * 60) / 1000 /* 30 days */,
+
+ // To make the tests more failsafe, we have an internal notifier function.
+ // As the deleted items store is just meant to be a hint, this should not
+ // be used in real code.
+ completedNotifier: null,
+
+ flush() {
+ this.ensureStatements();
+ this.stmtFlush.params.stale_time = cal.dtz.now().nativeTime - this.STALE_TIME;
+ this.stmtFlush.executeAsync(this.completedNotifier);
+ },
+
+ getDeletedDate(aId, aCalId) {
+ this.ensureStatements();
+ let stmt;
+ if (aCalId) {
+ stmt = this.stmtGetWithCal;
+ stmt.params.calId = aCalId;
+ } else {
+ stmt = this.stmtGet;
+ }
+
+ stmt.params.id = aId;
+ try {
+ if (stmt.executeStep()) {
+ let date = cal.createDateTime();
+ date.nativeTime = stmt.row.time_deleted;
+ return date.getInTimezone(cal.dtz.defaultTimezone);
+ }
+ } catch (e) {
+ cal.ERROR(e);
+ } finally {
+ stmt.reset();
+ }
+ return null;
+ },
+
+ markDeleted(aItem) {
+ this.ensureStatements();
+ this.stmtMarkDelete.params.calId = aItem.calendar.id;
+ this.stmtMarkDelete.params.id = aItem.id;
+ this.stmtMarkDelete.params.time = cal.dtz.now().nativeTime;
+ this.stmtMarkDelete.params.rid = (aItem.recurrenceId && aItem.recurrenceId.nativeTime) || "";
+ this.stmtMarkDelete.executeAsync(this.completedNotifier);
+ },
+
+ unmarkDeleted(aItem) {
+ this.ensureStatements();
+ this.stmtUnmarkDelete.params.id = aItem.id;
+ this.stmtUnmarkDelete.executeAsync(this.completedNotifier);
+ },
+
+ initDB() {
+ if (this.mDB) {
+ // Looks like we've already initialized, exit early
+ return;
+ }
+
+ let file = FileUtils.getFile("ProfD", ["calendar-data", "deleted.sqlite"]);
+ this.mDB = Services.storage.openDatabase(file);
+
+ // If this database needs changing, please start using a real schema
+ // management, i.e using PRAGMA user_version and upgrading
+ if (!this.mDB.tableExists("cal_deleted_items")) {
+ const v1_schema = "cal_id TEXT, id TEXT, time_deleted INTEGER, recurrence_id INTEGER";
+ const v1_index =
+ "CREATE INDEX idx_deleteditems ON cal_deleted_items(id,cal_id,recurrence_id)";
+
+ this.mDB.createTable("cal_deleted_items", v1_schema);
+ this.mDB.executeSimpleSQL(v1_index);
+ this.mDB.executeSimpleSQL("PRAGMA user_version = 1");
+ }
+
+ // We will not init the statements now, we can still do that the
+ // first time this interface is used. What we should do though is
+ // to clean up at shutdown
+ cal.addShutdownObserver(this.shutdown.bind(this));
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "profile-after-change") {
+ // Make sure to observe calendar changes so we know when things are
+ // deleted. We don't initialize the statements until first use.
+ cal.manager.addCalendarObserver(this);
+ }
+ },
+
+ ensureStatements() {
+ if (!this.mDB) {
+ this.initDB();
+ }
+
+ if (!this.stmtMarkDelete) {
+ let stmt =
+ "INSERT OR REPLACE INTO cal_deleted_items (cal_id, id, time_deleted, recurrence_id) VALUES(:calId, :id, :time, :rid)";
+ this.stmtMarkDelete = this.mDB.createStatement(stmt);
+ }
+ if (!this.stmtUnmarkDelete) {
+ let stmt = "DELETE FROM cal_deleted_items WHERE id = :id";
+ this.stmtUnmarkDelete = this.mDB.createStatement(stmt);
+ }
+ if (!this.stmtGetWithCal) {
+ let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE cal_id = :calId AND id = :id";
+ this.stmtGetWithCal = this.mDB.createStatement(stmt);
+ }
+ if (!this.stmtGet) {
+ let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE id = :id";
+ this.stmtGet = this.mDB.createStatement(stmt);
+ }
+ if (!this.stmtFlush) {
+ let stmt = "DELETE FROM cal_deleted_items WHERE time_deleted < :stale_time";
+ this.stmtFlush = this.mDB.createStatement(stmt);
+ }
+ },
+
+ shutdown() {
+ try {
+ let stmts = [
+ this.stmtMarkDelete,
+ this.stmtUnmarkDelete,
+ this.stmtGet,
+ this.stmtGetWithCal,
+ this.stmtFlush,
+ ];
+ for (let stmt of stmts) {
+ stmt.finalize();
+ }
+
+ if (this.mDB) {
+ this.mDB.asyncClose();
+ this.mDB = null;
+ }
+ } catch (e) {
+ cal.ERROR("Error closing deleted items database: " + e);
+ }
+
+ cal.manager.removeCalendarObserver(this);
+ },
+
+ // calIObserver
+ onStartBatch() {},
+ onEndBatch() {},
+ onModifyItem() {},
+ onError() {},
+ onPropertyChanged() {},
+ onPropertyDeleting() {},
+
+ onAddItem(aItem) {
+ this.unmarkDeleted(aItem);
+ },
+
+ onDeleteItem(aItem) {
+ this.markDeleted(aItem);
+ },
+
+ onLoad() {
+ this.flush();
+ },
+};
diff --git a/comm/calendar/base/src/CalDuration.jsm b/comm/calendar/base/src/CalDuration.jsm
new file mode 100644
index 0000000000..cb289bdde4
--- /dev/null
+++ b/comm/calendar/base/src/CalDuration.jsm
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDuration"];
+
+const { ICAL, unwrap } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+function CalDuration(innerObject) {
+ this.innerObject = innerObject || new ICAL.Duration();
+ this.wrappedJSObject = this;
+}
+
+CalDuration.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIDuration"]),
+ classID: Components.ID("{7436f480-c6fc-4085-9655-330b1ee22288}"),
+
+ get icalDuration() {
+ return this.innerObject;
+ },
+ set icalDuration(val) {
+ this.innerObject = val;
+ },
+
+ isMutable: true,
+ makeImmutable() {
+ this.isMutable = false;
+ },
+ clone() {
+ return new CalDuration(this.innerObject.clone());
+ },
+
+ get isNegative() {
+ return this.innerObject.isNegative;
+ },
+ set isNegative(val) {
+ this.innerObject.isNegative = !!val;
+ },
+
+ get weeks() {
+ return this.innerObject.weeks;
+ },
+ set weeks(val) {
+ this.innerObject.weeks = parseInt(val, 10);
+ },
+
+ get days() {
+ return this.innerObject.days;
+ },
+ set days(val) {
+ this.innerObject.days = parseInt(val, 10);
+ },
+
+ get hours() {
+ return this.innerObject.hours;
+ },
+ set hours(val) {
+ this.innerObject.hours = parseInt(val, 10);
+ },
+
+ get minutes() {
+ return this.innerObject.minutes;
+ },
+ set minutes(val) {
+ this.innerObject.minutes = parseInt(val, 10);
+ },
+
+ get seconds() {
+ return this.innerObject.seconds;
+ },
+ set seconds(val) {
+ this.innerObject.seconds = parseInt(val, 10);
+ },
+
+ get inSeconds() {
+ return this.innerObject.toSeconds();
+ },
+ set inSeconds(val) {
+ this.innerObject.fromSeconds(val);
+ },
+
+ addDuration: unwrap(ICAL.Duration, function (val) {
+ this.innerObject.fromSeconds(this.innerObject.toSeconds() + val.toSeconds());
+ }),
+
+ compare: unwrap(ICAL.Duration, function (val) {
+ return this.innerObject.compare(val);
+ }),
+
+ reset() {
+ this.innerObject.reset();
+ },
+ normalize() {
+ this.innerObject.normalize();
+ },
+ toString() {
+ return this.innerObject.toString();
+ },
+
+ get icalString() {
+ return this.innerObject.toString();
+ },
+ set icalString(val) {
+ this.innerObject = ICAL.Duration.fromString(val);
+ },
+};
diff --git a/comm/calendar/base/src/CalEvent.jsm b/comm/calendar/base/src/CalEvent.jsm
new file mode 100644
index 0000000000..fa5225e520
--- /dev/null
+++ b/comm/calendar/base/src/CalEvent.jsm
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from calItemBase.js */
+
+var EXPORTED_SYMBOLS = ["CalEvent"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+Services.scriptloader.loadSubScript("resource:///components/calItemBase.js");
+
+/**
+ * Constructor for `calIEvent` objects.
+ *
+ * @class
+ * @implements {calIEvent}
+ * @param {string} [icalString] - Optional iCal string for initializing existing events.
+ */
+function CalEvent(icalString) {
+ this.initItemBase();
+
+ this.eventPromotedProps = {
+ DTSTART: true,
+ DTEND: true,
+ __proto__: this.itemBasePromotedProps,
+ };
+
+ if (icalString) {
+ this.icalString = icalString;
+ }
+}
+var calEventClassID = Components.ID("{974339d5-ab86-4491-aaaf-2b2ca177c12b}");
+var calEventInterfaces = [Ci.calIItemBase, Ci.calIEvent, Ci.calIInternalShallowCopy];
+CalEvent.prototype = {
+ __proto__: calItemBase.prototype,
+
+ classID: calEventClassID,
+ QueryInterface: cal.generateQI(["calIItemBase", "calIEvent", "calIInternalShallowCopy"]),
+ classInfo: cal.generateCI({
+ classID: calEventClassID,
+ contractID: "@mozilla.org/calendar/event;1",
+ classDescription: "Calendar Event",
+ interfaces: calEventInterfaces,
+ }),
+
+ cloneShallow(aNewParent) {
+ let cloned = new CalEvent();
+ this.cloneItemBaseInto(cloned, aNewParent);
+ return cloned;
+ },
+
+ createProxy(aRecurrenceId) {
+ cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true);
+
+ let proxy = new CalEvent();
+
+ // override proxy's DTSTART/DTEND/RECURRENCE-ID
+ // before master is set (and item might get immutable):
+ let endDate = aRecurrenceId.clone();
+ endDate.addDuration(this.duration);
+ proxy.endDate = endDate;
+ proxy.startDate = aRecurrenceId;
+
+ proxy.initializeProxy(this, aRecurrenceId);
+ proxy.mDirty = false;
+
+ return proxy;
+ },
+
+ makeImmutable() {
+ this.makeItemBaseImmutable();
+ },
+
+ isEvent() {
+ return true;
+ },
+
+ get duration() {
+ if (this.endDate && this.startDate) {
+ return this.endDate.subtractDate(this.startDate);
+ }
+ // Return a null-duration if we don't have an end date
+ return cal.createDuration();
+ },
+
+ get recurrenceStartDate() {
+ return this.startDate;
+ },
+
+ icsEventPropMap: [
+ { cal: "DTSTART", ics: "startTime" },
+ { cal: "DTEND", ics: "endTime" },
+ ],
+
+ set icalString(value) {
+ this.icalComponent = cal.icsService.parseICS(value);
+ },
+
+ get icalString() {
+ let calcomp = cal.icsService.createIcalComponent("VCALENDAR");
+ cal.item.setStaticProps(calcomp);
+ calcomp.addSubcomponent(this.icalComponent);
+ return calcomp.serializeToICS();
+ },
+
+ get icalComponent() {
+ let icalcomp = cal.icsService.createIcalComponent("VEVENT");
+ this.fillIcalComponentFromBase(icalcomp);
+ this.mapPropsToICS(icalcomp, this.icsEventPropMap);
+
+ for (let [name, value] of this.properties) {
+ try {
+ // When deleting a property of an occurrence, the property is not deleted
+ // but instead set to null, so we need to prevent adding those properties.
+ let wasReset = this.mIsProxy && value === null;
+ if (!this.eventPromotedProps[name] && !wasReset) {
+ let icalprop = cal.icsService.createIcalProperty(name);
+ icalprop.value = value;
+ let propBucket = this.mPropertyParams[name];
+ if (propBucket) {
+ for (let paramName in propBucket) {
+ try {
+ icalprop.setParameter(paramName, propBucket[paramName]);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG(
+ "Warning: Invalid event parameter value " +
+ paramName +
+ "=" +
+ propBucket[paramName]
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ icalcomp.addProperty(icalprop);
+ }
+ } catch (e) {
+ cal.ERROR("failed to set " + name + " to " + value + ": " + e + "\n");
+ }
+ }
+ return icalcomp;
+ },
+
+ eventPromotedProps: null,
+
+ set icalComponent(event) {
+ this.modify();
+ if (event.componentType != "VEVENT") {
+ event = event.getFirstSubcomponent("VEVENT");
+ if (!event) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+
+ this.mEndDate = undefined;
+ this.setItemBaseFromICS(event);
+ this.mapPropsFromICS(event, this.icsEventPropMap);
+
+ this.importUnpromotedProperties(event, this.eventPromotedProps);
+
+ // Importing didn't really change anything
+ this.mDirty = false;
+ },
+
+ isPropertyPromoted(name) {
+ // avoid strict undefined property warning
+ return this.eventPromotedProps[name] || false;
+ },
+
+ set startDate(value) {
+ this.modify();
+
+ // We're about to change the start date of an item which probably
+ // could break the associated calIRecurrenceInfo. We're calling
+ // the appropriate method here to adjust the internal structure in
+ // order to free clients from worrying about such details.
+ if (this.parentItem == this) {
+ let rec = this.recurrenceInfo;
+ if (rec) {
+ rec.onStartDateChange(value, this.startDate);
+ }
+ }
+
+ this.setProperty("DTSTART", value);
+ },
+
+ get startDate() {
+ return this.getProperty("DTSTART");
+ },
+
+ mEndDate: undefined,
+ get endDate() {
+ let endDate = this.mEndDate;
+ if (endDate === undefined) {
+ endDate = this.getProperty("DTEND");
+ if (!endDate && this.startDate) {
+ endDate = this.startDate.clone();
+ let dur = this.getProperty("DURATION");
+ if (dur) {
+ // If there is a duration set on the event, calculate the right end time.
+ endDate.addDuration(cal.createDuration(dur));
+ } else if (endDate.isDate) {
+ // If the start time is a date-time the event ends on the same calendar
+ // date and time of day. If the start time is a date the events
+ // non-inclusive end is the end of the calendar date.
+ endDate.day += 1;
+ }
+ }
+ this.mEndDate = endDate;
+ }
+ return endDate;
+ },
+
+ set endDate(value) {
+ this.deleteProperty("DURATION"); // setting endDate once removes DURATION
+ this.setProperty("DTEND", value);
+ this.mEndDate = value;
+ },
+};
diff --git a/comm/calendar/base/src/CalFreeBusyService.jsm b/comm/calendar/base/src/CalFreeBusyService.jsm
new file mode 100644
index 0000000000..822f37acda
--- /dev/null
+++ b/comm/calendar/base/src/CalFreeBusyService.jsm
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalFreeBusyService"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function CalFreeBusyListener(numOperations, finalListener) {
+ this.mFinalListener = finalListener;
+ this.mNumOperations = numOperations;
+
+ this.opGroup = new cal.data.OperationGroup(() => {
+ this.notifyResult(null);
+ });
+}
+CalFreeBusyListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIGenericOperationListener"]),
+
+ mFinalListener: null,
+ mNumOperations: 0,
+ opGroup: null,
+
+ notifyResult(result) {
+ let listener = this.mFinalListener;
+ if (listener) {
+ if (!this.opGroup.isPending) {
+ this.mFinalListener = null;
+ }
+ listener.onResult(this.opGroup, result);
+ }
+ },
+
+ // calIGenericOperationListener:
+ onResult(aOperation, aResult) {
+ if (this.mFinalListener) {
+ if (!aOperation || !aOperation.isPending) {
+ --this.mNumOperations;
+ if (this.mNumOperations <= 0) {
+ this.opGroup.notifyCompleted();
+ }
+ }
+ let opStatus = aOperation ? aOperation.status : Cr.NS_OK;
+ if (Components.isSuccessCode(opStatus) && aResult && Array.isArray(aResult)) {
+ this.notifyResult(aResult);
+ } else {
+ this.notifyResult([]);
+ }
+ }
+ },
+};
+
+function CalFreeBusyService() {
+ this.wrappedJSObject = this;
+ this.mProviders = new Set();
+}
+CalFreeBusyService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIFreeBusyProvider", "calIFreeBusyService"]),
+ classID: Components.ID("{29c56cd5-d36e-453a-acde-0083bd4fe6d3}"),
+
+ mProviders: null,
+
+ // calIFreeBusyProvider:
+ getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) {
+ let groupListener = new CalFreeBusyListener(this.mProviders.size, aListener);
+ if (this.mProviders.size == 0) {
+ groupListener.onResult(null, []);
+ }
+ for (let provider of this.mProviders.values()) {
+ let operation = provider.getFreeBusyIntervals(
+ aCalId,
+ aRangeStart,
+ aRangeEnd,
+ aBusyTypes,
+ groupListener
+ );
+ groupListener.opGroup.add(operation);
+ }
+ return groupListener.opGroup;
+ },
+
+ // calIFreeBusyService:
+ addProvider(aProvider) {
+ this.mProviders.add(aProvider.QueryInterface(Ci.calIFreeBusyProvider));
+ },
+ removeProvider(aProvider) {
+ this.mProviders.delete(aProvider.QueryInterface(Ci.calIFreeBusyProvider));
+ },
+};
diff --git a/comm/calendar/base/src/CalICSService.jsm b/comm/calendar/base/src/CalICSService.jsm
new file mode 100644
index 0000000000..fc9d53500e
--- /dev/null
+++ b/comm/calendar/base/src/CalICSService.jsm
@@ -0,0 +1,604 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalIcalProperty", "CalICSService"];
+
+const { ICAL, unwrapSetter, unwrapSingle, wrapGetter } = ChromeUtils.import(
+ "resource:///modules/calendar/Ical.jsm"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm");
+ChromeUtils.defineModuleGetter(lazy, "CalTimezone", "resource:///modules/CalTimezone.jsm");
+
+function CalIcalProperty(innerObject) {
+ this.innerObject = innerObject || new ICAL.Property();
+ this.wrappedJSObject = this;
+}
+
+CalIcalProperty.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIIcalProperty"]),
+ classID: Components.ID("{423ac3f0-f612-48b3-953f-47f7f8fd705b}"),
+
+ get icalString() {
+ return this.innerObject.toICALString() + ICAL.newLineChar;
+ },
+ get icalProperty() {
+ return this.innerObject;
+ },
+ set icalProperty(val) {
+ this.innerObject = val;
+ },
+
+ get parent() {
+ return this.innerObject.parent;
+ },
+ toString() {
+ return this.innerObject.toICAL();
+ },
+
+ get value() {
+ // Unescaped value for properties of TEXT, escaped otherwise.
+ if (this.innerObject.type == "text") {
+ return this.innerObject.getValues().join(",");
+ }
+ return this.valueAsIcalString;
+ },
+ set value(val) {
+ // Unescaped value for properties of TEXT, escaped otherwise.
+ if (this.innerObject.type == "text") {
+ this.innerObject.setValue(val);
+ return;
+ }
+ this.valueAsIcalString = val;
+ },
+
+ get valueAsIcalString() {
+ let propertyStr = this.innerObject.toICALString();
+ if (propertyStr.match(/:/g).length == 1) {
+ // For property containing only one colon, e.g. `GEO:latitude;longitude`,
+ // the left hand side must be the property name, the right hand side must
+ // be property value.
+ return propertyStr.slice(propertyStr.indexOf(":") + 1);
+ }
+ // For property containing many or no colons, retrieve the property value
+ // according to its type. An example is
+ // `ATTENDEE;MEMBER="mailto:foo@example.com": mailto:bar@example.com`
+ let type = this.innerObject.type;
+ return this.innerObject
+ .getValues()
+ .map(val => {
+ if (type == "text") {
+ return ICAL.stringify.value(val, type, ICAL.design.icalendar);
+ } else if (typeof val == "number" || typeof val == "string") {
+ return val;
+ } else if ("toICALString" in val) {
+ return val.toICALString();
+ }
+ return val.toString();
+ })
+ .join(",");
+ },
+ set valueAsIcalString(val) {
+ let mockLine = this.propertyName + ":" + val;
+ let prop = ICAL.Property.fromString(mockLine, ICAL.design.icalendar);
+
+ if (this.innerObject.isMultiValue) {
+ this.innerObject.setValues(prop.getValues());
+ } else {
+ this.innerObject.setValue(prop.getFirstValue());
+ }
+ },
+
+ get valueAsDatetime() {
+ let val = this.innerObject.getFirstValue();
+ let isIcalTime =
+ val && typeof val == "object" && "icalclass" in val && val.icalclass == "icaltime";
+ return isIcalTime ? new lazy.CalDateTime(val) : null;
+ },
+ set valueAsDatetime(rawval) {
+ unwrapSetter(
+ ICAL.Time,
+ rawval,
+ function (val) {
+ if (
+ val &&
+ val.zone &&
+ val.zone != ICAL.Timezone.utcTimezone &&
+ val.zone != ICAL.Timezone.localTimezone
+ ) {
+ this.innerObject.setParameter("TZID", val.zone.tzid);
+ if (this.parent) {
+ let tzref = wrapGetter(lazy.CalTimezone, val.zone);
+ this.parent.addTimezoneReference(tzref);
+ }
+ } else {
+ this.innerObject.removeParameter("TZID");
+ }
+ this.innerObject.setValue(val);
+ },
+ this
+ );
+ },
+
+ get propertyName() {
+ return this.innerObject.name.toUpperCase();
+ },
+
+ getParameter(name) {
+ // Unfortunately getting the "VALUE" parameter won't work, since in
+ // jCal it has been translated to the value type id.
+ if (name == "VALUE") {
+ let defaultType = this.innerObject.getDefaultType();
+ if (this.innerObject.type != defaultType) {
+ // Default type doesn't match object type, so we have a VALUE
+ // parameter
+ return this.innerObject.type.toUpperCase();
+ }
+ }
+
+ return this.innerObject.getParameter(name.toLowerCase());
+ },
+ setParameter(name, value) {
+ // Similar problems for setting the value parameter. Calendar code
+ // expects setting the value parameter to just change the value type
+ // and attempt to use the previous value as the new one. To do this in
+ // ICAL.js we need to save the value, reset the type and then try to
+ // set the value again.
+ if (name == "VALUE") {
+ let oldValues;
+ let type = this.innerObject.type;
+ let designSet = this.innerObject._designSet;
+
+ let wasMultiValue = this.innerObject.isMultiValue;
+ if (wasMultiValue) {
+ oldValues = this.innerObject.getValues();
+ } else {
+ let oldValue = this.innerObject.getFirstValue();
+ oldValues = oldValue ? [oldValue] : [];
+ }
+
+ this.innerObject.resetType(value.toLowerCase());
+ try {
+ oldValues = oldValues.map(oldValue => {
+ let strvalue = ICAL.stringify.value(oldValue.toString(), type, designSet);
+ return ICAL.parse._parseValue(strvalue, value, designSet);
+ });
+ } catch (e) {
+ // If there was an error reparsing the value, then just keep it
+ // empty.
+ oldValues = null;
+ }
+
+ if (oldValues && oldValues.length) {
+ if (wasMultiValue && this.innerObject.isMultiValue) {
+ this.innerObject.setValues(oldValues);
+ } else {
+ this.innerObject.setValue(oldValues.join(","));
+ }
+ }
+ } else {
+ this.innerObject.setParameter(name.toLowerCase(), value);
+ }
+ },
+ removeParameter(name) {
+ // Again, VALUE needs special handling. Removing the value parameter is
+ // kind of like resetting it to the default type. So find out the
+ // default type and then set the value parameter to it.
+ if (name == "VALUE") {
+ let propname = this.innerObject.name.toLowerCase();
+ if (propname in ICAL.design.icalendar.property) {
+ let details = ICAL.design.icalendar.property[propname];
+ if ("defaultType" in details) {
+ this.setParameter("VALUE", details.defaultType);
+ }
+ }
+ } else {
+ this.innerObject.removeParameter(name.toLowerCase());
+ }
+ },
+
+ clearXParameters() {
+ cal.WARN(
+ "calIICSService::clearXParameters is no longer implemented, please use removeParameter"
+ );
+ },
+
+ paramIterator: null,
+ getFirstParameterName() {
+ let innerObject = this.innerObject;
+ this.paramIterator = (function* () {
+ let defaultType = innerObject.getDefaultType();
+ if (defaultType != innerObject.type) {
+ yield "VALUE";
+ }
+
+ let paramNames = Object.keys(innerObject.jCal[1] || {});
+ for (let name of paramNames) {
+ yield name.toUpperCase();
+ }
+ })();
+ return this.getNextParameterName();
+ },
+
+ getNextParameterName() {
+ if (this.paramIterator) {
+ let next = this.paramIterator.next();
+ if (next.done) {
+ this.paramIterator = null;
+ }
+
+ return next.value;
+ }
+ return this.getFirstParameterName();
+ },
+};
+
+function calIcalComponent(innerObject) {
+ this.innerObject = innerObject || new ICAL.Component();
+ this.wrappedJSObject = this;
+ this.mReferencedZones = {};
+}
+
+calIcalComponent.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIIcalComponent"]),
+ classID: Components.ID("{51ac96fd-1279-4439-a85b-6947b37f4cea}"),
+
+ clone() {
+ return new calIcalComponent(new ICAL.Component(this.innerObject.toJSON()));
+ },
+
+ get parent() {
+ return wrapGetter(calIcalComponent, this.innerObject.parent);
+ },
+
+ get icalTimezone() {
+ return this.innerObject.name == "vtimezone" ? this.innerObject : null;
+ },
+ get icalComponent() {
+ return this.innerObject;
+ },
+ set icalComponent(val) {
+ this.innerObject = val;
+ },
+
+ componentIterator: null,
+ getFirstSubcomponent(kind) {
+ if (kind == "ANY") {
+ kind = null;
+ } else if (kind) {
+ kind = kind.toLowerCase();
+ }
+ let innerObject = this.innerObject;
+ this.componentIterator = (function* () {
+ let comps = innerObject.getAllSubcomponents(kind);
+ if (comps) {
+ for (let comp of comps) {
+ yield new calIcalComponent(comp);
+ }
+ }
+ })();
+ return this.getNextSubcomponent(kind);
+ },
+ getNextSubcomponent(kind) {
+ if (this.componentIterator) {
+ let next = this.componentIterator.next();
+ if (next.done) {
+ this.componentIterator = null;
+ }
+
+ return next.value;
+ }
+ return this.getFirstSubcomponent(kind);
+ },
+
+ get componentType() {
+ return this.innerObject.name.toUpperCase();
+ },
+
+ get uid() {
+ return this.innerObject.getFirstPropertyValue("uid");
+ },
+ set uid(val) {
+ this.innerObject.updatePropertyWithValue("uid", val);
+ },
+
+ get prodid() {
+ return this.innerObject.getFirstPropertyValue("prodid");
+ },
+ set prodid(val) {
+ this.innerObject.updatePropertyWithValue("prodid", val);
+ },
+
+ get version() {
+ return this.innerObject.getFirstPropertyValue("version");
+ },
+ set version(val) {
+ this.innerObject.updatePropertyWithValue("version", val);
+ },
+
+ get method() {
+ return this.innerObject.getFirstPropertyValue("method");
+ },
+ set method(val) {
+ this.innerObject.updatePropertyWithValue("method", val);
+ },
+
+ get status() {
+ return this.innerObject.getFirstPropertyValue("status");
+ },
+ set status(val) {
+ this.innerObject.updatePropertyWithValue("status", val);
+ },
+
+ get summary() {
+ return this.innerObject.getFirstPropertyValue("summary");
+ },
+ set summary(val) {
+ this.innerObject.updatePropertyWithValue("summary", val);
+ },
+
+ get description() {
+ return this.innerObject.getFirstPropertyValue("description");
+ },
+ set description(val) {
+ this.innerObject.updatePropertyWithValue("description", val);
+ },
+
+ get location() {
+ return this.innerObject.getFirstPropertyValue("location");
+ },
+ set location(val) {
+ this.innerObject.updatePropertyWithValue("location", val);
+ },
+
+ get categories() {
+ return this.innerObject.getFirstPropertyValue("categories");
+ },
+ set categories(val) {
+ this.innerObject.updatePropertyWithValue("categories", val);
+ },
+
+ get URL() {
+ return this.innerObject.getFirstPropertyValue("url");
+ },
+ set URL(val) {
+ this.innerObject.updatePropertyWithValue("url", val);
+ },
+
+ get priority() {
+ // If there is no value for this integer property, then we must return
+ // the designated INVALID_VALUE.
+ const INVALID_VALUE = Ci.calIIcalComponent.INVALID_VALUE;
+ let prop = this.innerObject.getFirstProperty("priority");
+ let val = prop ? prop.getFirstValue() : null;
+ return val === null ? INVALID_VALUE : val;
+ },
+ set priority(val) {
+ this.innerObject.updatePropertyWithValue("priority", val);
+ },
+
+ _setTimeAttr(propName, val) {
+ let prop = this.innerObject.updatePropertyWithValue(propName, val);
+ if (
+ val &&
+ val.zone &&
+ val.zone != ICAL.Timezone.utcTimezone &&
+ val.zone != ICAL.Timezone.localTimezone
+ ) {
+ prop.setParameter("TZID", val.zone.tzid);
+ this.addTimezoneReference(wrapGetter(lazy.CalTimezone, val.zone));
+ } else {
+ prop.removeParameter("TZID");
+ }
+ },
+
+ get startTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtstart"));
+ },
+ set startTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstart"), this);
+ },
+
+ get endTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtend"));
+ },
+ set endTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtend"), this);
+ },
+
+ get duration() {
+ return wrapGetter(lazy.CalDuration, this.innerObject.getFirstPropertyValue("duration"));
+ },
+
+ get dueTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("due"));
+ },
+ set dueTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "due"), this);
+ },
+
+ get stampTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("dtstamp"));
+ },
+ set stampTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "dtstamp"), this);
+ },
+
+ get createdTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("created"));
+ },
+ set createdTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "created"), this);
+ },
+
+ get completedTime() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("completed"));
+ },
+ set completedTime(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "completed"), this);
+ },
+
+ get lastModified() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("last-modified"));
+ },
+ set lastModified(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "last-modified"), this);
+ },
+
+ get recurrenceId() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getFirstPropertyValue("recurrence-id"));
+ },
+ set recurrenceId(val) {
+ unwrapSetter(ICAL.Time, val, this._setTimeAttr.bind(this, "recurrence-id"), this);
+ },
+
+ serializeToICS() {
+ return this.innerObject.toString() + ICAL.newLineChar;
+ },
+ toString() {
+ return this.innerObject.toString();
+ },
+
+ addSubcomponent(comp) {
+ comp.getReferencedTimezones().forEach(this.addTimezoneReference, this);
+ let jscomp = unwrapSingle(ICAL.Component, comp);
+ this.innerObject.addSubcomponent(jscomp);
+ },
+
+ propertyIterator: null,
+ getFirstProperty(kind) {
+ if (kind == "ANY") {
+ kind = null;
+ } else if (kind) {
+ kind = kind.toLowerCase();
+ }
+ let innerObject = this.innerObject;
+ this.propertyIterator = (function* () {
+ let props = innerObject.getAllProperties(kind);
+ if (!props) {
+ return;
+ }
+ for (let prop of props) {
+ let hell = prop.getValues();
+ if (hell.length > 1) {
+ // Uh oh, multiple property values. Our code expects each as one
+ // property. I hate API incompatibility!
+ for (let devil of hell) {
+ let thisprop = new ICAL.Property(prop.toJSON(), prop.parent);
+ thisprop.removeAllValues();
+ thisprop.setValue(devil);
+ yield new CalIcalProperty(thisprop);
+ }
+ } else {
+ yield new CalIcalProperty(prop);
+ }
+ }
+ })();
+
+ return this.getNextProperty(kind);
+ },
+
+ getNextProperty(kind) {
+ if (this.propertyIterator) {
+ let next = this.propertyIterator.next();
+ if (next.done) {
+ this.propertyIterator = null;
+ }
+
+ return next.value;
+ }
+ return this.getFirstProperty(kind);
+ },
+
+ _getNextParentVCalendar() {
+ let vcalendar = this; // eslint-disable-line consistent-this
+ while (vcalendar && vcalendar.componentType != "VCALENDAR") {
+ vcalendar = vcalendar.parent;
+ }
+ return vcalendar || this;
+ },
+
+ addProperty(prop) {
+ try {
+ let datetime = prop.valueAsDatetime;
+ if (datetime && datetime.timezone) {
+ this._getNextParentVCalendar().addTimezoneReference(datetime.timezone);
+ }
+ } catch (e) {
+ // If there is an issue adding the timezone reference, don't make
+ // that break adding the property.
+ }
+
+ let jsprop = unwrapSingle(ICAL.Property, prop);
+ this.innerObject.addProperty(jsprop);
+ },
+
+ addTimezoneReference(timezone) {
+ if (timezone) {
+ if (!(timezone.tzid in this.mReferencedZones) && this.componentType == "VCALENDAR") {
+ let comp = timezone.icalComponent;
+ if (comp) {
+ this.addSubcomponent(comp);
+ }
+ }
+
+ this.mReferencedZones[timezone.tzid] = timezone;
+ }
+ },
+
+ getReferencedTimezones(aCount) {
+ return Object.keys(this.mReferencedZones).map(timezone => this.mReferencedZones[timezone]);
+ },
+
+ serializeToICSStream() {
+ let data = this.innerObject.toString();
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setUTF8Data(data, data.length);
+ return stream;
+ },
+};
+
+function CalICSService() {
+ this.wrappedJSObject = this;
+}
+
+CalICSService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIICSService"]),
+ classID: Components.ID("{c61cb903-4408-41b3-bc22-da0b27efdfe1}"),
+
+ parseICS(serialized) {
+ let comp = ICAL.parse(serialized);
+ return new calIcalComponent(new ICAL.Component(comp));
+ },
+
+ parseICSAsync(serialized, listener) {
+ let worker = new ChromeWorker("resource:///components/calICSService-worker.js");
+ worker.onmessage = function (event) {
+ let icalComp = new calIcalComponent(new ICAL.Component(event.data));
+ listener.onParsingComplete(Cr.OK, icalComp);
+ };
+ worker.onerror = function (event) {
+ cal.ERROR(`Parsing failed; ${event.message}. ICS data:\n${serialized}`);
+ listener.onParsingComplete(Cr.NS_ERROR_FAILURE, null);
+ };
+ worker.postMessage(serialized);
+ },
+
+ createIcalComponent(kind) {
+ return new calIcalComponent(new ICAL.Component(kind.toLowerCase()));
+ },
+
+ createIcalProperty(kind) {
+ return new CalIcalProperty(new ICAL.Property(kind.toLowerCase()));
+ },
+
+ createIcalPropertyFromString(str) {
+ return new CalIcalProperty(ICAL.Property.fromString(str.trim(), ICAL.design.icalendar));
+ },
+};
diff --git a/comm/calendar/base/src/CalIcsParser.jsm b/comm/calendar/base/src/CalIcsParser.jsm
new file mode 100644
index 0000000000..67ea32ba24
--- /dev/null
+++ b/comm/calendar/base/src/CalIcsParser.jsm
@@ -0,0 +1,334 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalIcsParser"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+function CalIcsParser() {
+ this.wrappedJSObject = this;
+ this.mItems = [];
+ this.mParentlessItems = [];
+ this.mComponents = [];
+ this.mProperties = [];
+}
+CalIcsParser.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIIcsParser"]),
+ classID: Components.ID("{6fe88047-75b6-4874-80e8-5f5800f14984}"),
+
+ processIcalComponent(rootComp, aAsyncParsing) {
+ let calComp;
+ // libical returns the vcalendar component if there is just one vcalendar.
+ // If there are multiple vcalendars, it returns an xroot component, with
+ // vcalendar children. We need to handle both cases.
+ if (rootComp) {
+ if (rootComp.componentType == "VCALENDAR") {
+ calComp = rootComp;
+ } else {
+ calComp = rootComp.getFirstSubcomponent("VCALENDAR");
+ }
+ }
+
+ if (!calComp) {
+ let message = "Parser Error. Could not find 'VCALENDAR' component.\n";
+ try {
+ // we try to also provide the parsed component - if that fails due to an error in
+ // libical, we append the error message of the caught exception, which includes
+ // already a stack trace.
+ cal.ERROR(message + rootComp + "\n" + cal.STACK(10));
+ } catch (e) {
+ cal.ERROR(message + e);
+ }
+ }
+
+ let self = this;
+ let state = new parserState(this, aAsyncParsing);
+
+ while (calComp) {
+ // Get unknown properties from the VCALENDAR
+ for (let prop of cal.iterate.icalProperty(calComp)) {
+ if (prop.propertyName != "VERSION" && prop.propertyName != "PRODID") {
+ this.mProperties.push(prop);
+ }
+ }
+
+ let isGCal = /^-\/\/Google Inc\/\/Google Calendar /.test(calComp.prodid);
+ for (let subComp of cal.iterate.icalSubcomponent(calComp)) {
+ state.submit(subComp, isGCal);
+ }
+ calComp = rootComp.getNextSubcomponent("VCALENDAR");
+ }
+
+ // eslint-disable-next-line mozilla/use-returnValue
+ state.join(() => {
+ let fakedParents = {};
+ // tag "exceptions", i.e. items with rid:
+ for (let item of state.excItems) {
+ let parent = state.uid2parent[item.id];
+
+ if (!parent) {
+ // a parentless one, fake a master and override it's occurrence
+ parent = item.isEvent() ? new lazy.CalEvent() : new lazy.CalTodo();
+ parent.id = item.id;
+ parent.setProperty("DTSTART", item.recurrenceId);
+ parent.setProperty("X-MOZ-FAKED-MASTER", "1"); // this tag might be useful in the future
+ parent.recurrenceInfo = new lazy.CalRecurrenceInfo(parent);
+ fakedParents[item.id] = true;
+ state.uid2parent[item.id] = parent;
+ state.items.push(parent);
+ }
+ if (item.id in fakedParents) {
+ let rdate = cal.createRecurrenceDate();
+ rdate.date = item.recurrenceId;
+ parent.recurrenceInfo.appendRecurrenceItem(rdate);
+ // we'll keep the parentless-API until we switch over using itip-process for import (e.g. in dnd code)
+ self.mParentlessItems.push(item);
+ }
+
+ parent.recurrenceInfo.modifyException(item, true);
+ }
+
+ if (Object.keys(state.tzErrors).length > 0) {
+ // Use an alert rather than a prompt because problems may appear in
+ // remote subscribed calendars the user cannot change.
+ if (Cc["@mozilla.org/alerts-service;1"]) {
+ let notifier = Cc["@mozilla.org/alerts-service;1"].getService(Ci.nsIAlertsService);
+ let title = cal.l10n.getCalString("TimezoneErrorsAlertTitle");
+ let text = cal.l10n.getCalString("TimezoneErrorsSeeConsole");
+ try {
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ alert.init(title, "", title, text);
+ notifier.showAlert(alert);
+ } catch (e) {
+ // The notifier may not be available, e.g. on xpcshell tests
+ }
+ }
+ }
+
+ // We are done, push the items to the parser and notify the listener
+ self.mItems = self.mItems.concat(state.items);
+ self.mComponents = self.mComponents.concat(state.extraComponents);
+
+ if (aAsyncParsing) {
+ aAsyncParsing.onParsingComplete(Cr.NS_OK, self);
+ }
+ });
+ },
+
+ parseString(aICSString, aAsyncParsing) {
+ if (aAsyncParsing) {
+ let self = this;
+
+ // We are using two types of very similar listeners here:
+ // aAsyncParsing is a calIcsParsingListener that returns the ics
+ // parser containing the processed items.
+ // The listener passed to parseICSAsync is a calICsComponentParsingListener
+ // required by the ics service, that receives the parsed root component.
+ cal.icsService.parseICSAsync(aICSString, {
+ onParsingComplete(rc, rootComp) {
+ if (Components.isSuccessCode(rc)) {
+ self.processIcalComponent(rootComp, aAsyncParsing);
+ } else {
+ cal.ERROR("Error Parsing ICS: " + rc);
+ aAsyncParsing.onParsingComplete(rc, self);
+ }
+ },
+ });
+ } else {
+ try {
+ let icalComp = cal.icsService.parseICS(aICSString);
+ this.processIcalComponent(icalComp);
+ } catch (exc) {
+ cal.ERROR(exc.message + " when parsing\n" + aICSString);
+ }
+ }
+ },
+
+ parseFromStream(aStream, aAsyncParsing) {
+ // Read in the string. Note that it isn't a real string at this point,
+ // because likely, the file is utf8. The multibyte chars show up as multiple
+ // 'chars' in this string. So call it an array of octets for now.
+
+ let stringData = NetUtil.readInputStreamToString(aStream, aStream.available(), {
+ charset: "utf-8",
+ });
+ this.parseString(stringData, aAsyncParsing);
+ },
+
+ getItems() {
+ return this.mItems.concat([]);
+ },
+
+ getParentlessItems() {
+ return this.mParentlessItems.concat([]);
+ },
+
+ getProperties() {
+ return this.mProperties.concat([]);
+ },
+
+ getComponents() {
+ return this.mComponents.concat([]);
+ },
+};
+
+/**
+ * The parser state, which helps process ical components without clogging up the
+ * event queue.
+ *
+ * @param aParser The parser that is using this state
+ */
+function parserState(aParser, aListener) {
+ this.parser = aParser;
+ this.listener = aListener;
+
+ this.extraComponents = [];
+ this.items = [];
+ this.uid2parent = {};
+ this.excItems = [];
+ this.tzErrors = {};
+}
+
+parserState.prototype = {
+ parser: null,
+ joinFunc: null,
+ threadCount: 0,
+
+ extraComponents: null,
+ items: null,
+ uid2parent: null,
+ excItems: null,
+ tzErrors: null,
+ listener: null,
+
+ /**
+ * Checks if the timezones are missing and notifies the user via error console
+ *
+ * @param item The item to check for
+ * @param date The datetime object to check with
+ */
+ checkTimezone(item, date) {
+ function isPhantomTimezone(timezone) {
+ return !timezone.icalComponent && !timezone.isUTC && !timezone.isFloating;
+ }
+
+ if (date && isPhantomTimezone(date.timezone)) {
+ let tzid = date.timezone.tzid;
+ let hid = item.hashId + "#" + tzid;
+ if (!(hid in this.tzErrors)) {
+ // For now, publish errors to console and alert user.
+ // In future, maybe make them available through an interface method
+ // so this UI code can be removed from the parser, and caller can
+ // choose whether to alert, or show user the problem items and ask
+ // for fixes, or something else.
+ let msgArgs = [tzid, item.title, cal.dtz.formatter.formatDateTime(date)];
+ let msg = cal.l10n.getCalString("unknownTimezoneInItem", msgArgs);
+
+ cal.ERROR(msg + "\n" + item.icalString);
+ this.tzErrors[hid] = true;
+ }
+ }
+ },
+
+ /**
+ * Submit processing of a subcomponent to the event queue
+ *
+ * @param subComp The component to process
+ * @param isGCal If this is a Google Calendar invitation
+ */
+ submit(subComp, isGCal) {
+ let self = this;
+ let runner = {
+ run() {
+ let item = null;
+ switch (subComp.componentType) {
+ case "VEVENT":
+ item = new lazy.CalEvent();
+ item.icalComponent = subComp;
+ if (isGCal) {
+ cal.view.fixGoogleCalendarDescription(item);
+ }
+ self.checkTimezone(item, item.startDate);
+ self.checkTimezone(item, item.endDate);
+ break;
+ case "VTODO":
+ item = new lazy.CalTodo();
+ item.icalComponent = subComp;
+ self.checkTimezone(item, item.entryDate);
+ self.checkTimezone(item, item.dueDate);
+ // completed is defined to be in UTC
+ break;
+ case "VTIMEZONE":
+ // this should already be attached to the relevant
+ // events in the calendar, so there's no need to
+ // do anything with it here.
+ break;
+ default:
+ self.extraComponents.push(subComp);
+ break;
+ }
+
+ if (item) {
+ let rid = item.recurrenceId;
+ if (rid) {
+ self.excItems.push(item);
+ } else {
+ self.items.push(item);
+ if (item.recurrenceInfo) {
+ self.uid2parent[item.id] = item;
+ }
+ }
+ }
+ self.threadCount--;
+ self.checkCompletion();
+ },
+ };
+
+ this.threadCount++;
+ if (this.listener) {
+ // If we have a listener, we are doing this asynchronously. Go ahead
+ // and use the thread manager to dispatch the above runner
+ Services.tm.currentThread.dispatch(runner, Ci.nsIEventTarget.DISPATCH_NORMAL);
+ } else {
+ // No listener means synchonous. Just run the runner instead
+ runner.run();
+ }
+ },
+
+ /**
+ * Checks if the processing of all events has completed. If a join function
+ * has been set, this function is called.
+ *
+ * @returns True, if all tasks have been completed
+ */
+ checkCompletion() {
+ if (this.joinFunc && this.threadCount == 0) {
+ this.joinFunc();
+ return true;
+ }
+ return false;
+ },
+
+ /**
+ * Sets a join function that is called when all tasks have been completed
+ *
+ * @param joinFunc The join function to call
+ */
+ join(joinFunc) {
+ this.joinFunc = joinFunc;
+ this.checkCompletion();
+ },
+};
diff --git a/comm/calendar/base/src/CalIcsSerializer.jsm b/comm/calendar/base/src/CalIcsSerializer.jsm
new file mode 100644
index 0000000000..ebdf84f9e7
--- /dev/null
+++ b/comm/calendar/base/src/CalIcsSerializer.jsm
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalIcsSerializer"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function CalIcsSerializer() {
+ this.wrappedJSObject = this;
+ this.mItems = [];
+ this.mProperties = [];
+ this.mComponents = [];
+}
+CalIcsSerializer.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIIcsSerializer"]),
+ classID: Components.ID("{207a6682-8ff1-4203-9160-729ec28c8766}"),
+
+ addItems(aItems) {
+ if (aItems.length > 0) {
+ this.mItems = this.mItems.concat(aItems);
+ }
+ },
+
+ addProperty(aProperty) {
+ this.mProperties.push(aProperty);
+ },
+
+ addComponent(aComponent) {
+ this.mComponents.push(aComponent);
+ },
+
+ serializeToString() {
+ let calComp = this.getIcalComponent();
+ return calComp.serializeToICS();
+ },
+
+ serializeToInputStream(aStream) {
+ let calComp = this.getIcalComponent();
+ return calComp.serializeToICSStream();
+ },
+
+ serializeToStream(aStream) {
+ let str = this.serializeToString();
+
+ // Convert the javascript string to an array of bytes, using the
+ // UTF8 encoder
+ let convStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(
+ Ci.nsIConverterOutputStream
+ );
+ convStream.init(aStream, "UTF-8");
+
+ convStream.writeString(str);
+ convStream.close();
+ },
+
+ getIcalComponent() {
+ let calComp = cal.icsService.createIcalComponent("VCALENDAR");
+ cal.item.setStaticProps(calComp);
+
+ // xxx todo: think about that the below code doesn't clone the properties/components,
+ // thus ownership is moved to returned VCALENDAR...
+
+ for (let prop of this.mProperties) {
+ calComp.addProperty(prop);
+ }
+ for (let comp of this.mComponents) {
+ calComp.addSubcomponent(comp);
+ }
+
+ for (let item of cal.iterate.items(this.mItems)) {
+ calComp.addSubcomponent(item.icalComponent);
+ }
+
+ return calComp;
+ },
+};
diff --git a/comm/calendar/base/src/CalItipItem.jsm b/comm/calendar/base/src/CalItipItem.jsm
new file mode 100644
index 0000000000..aecf8671a1
--- /dev/null
+++ b/comm/calendar/base/src/CalItipItem.jsm
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalItipItem"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function CalItipItem() {
+ this.wrappedJSObject = this;
+ this.mCurrentItemIndex = 0;
+}
+CalItipItem.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIItipItem"]),
+ classID: Components.ID("{f41392ab-dcad-4bad-818f-b3d1631c4d93}"),
+
+ mIsInitialized: false,
+
+ mSender: null,
+ get sender() {
+ return this.mSender;
+ },
+ set sender(aValue) {
+ this.mSender = aValue;
+ },
+
+ mIsSend: false,
+ get isSend() {
+ return this.mIsSend;
+ },
+ set isSend(aValue) {
+ this.mIsSend = aValue;
+ },
+
+ mReceivedMethod: "REQUEST",
+ get receivedMethod() {
+ return this.mReceivedMethod;
+ },
+ set receivedMethod(aMethod) {
+ this.mReceivedMethod = aMethod.toUpperCase();
+ },
+
+ mResponseMethod: "REPLY",
+ get responseMethod() {
+ if (!this.mIsInitialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ return this.mResponseMethod;
+ },
+ set responseMethod(aMethod) {
+ this.mResponseMethod = aMethod.toUpperCase();
+ },
+
+ mAutoResponse: null,
+ get autoResponse() {
+ return this.mAutoResponse;
+ },
+ set autoResponse(aValue) {
+ this.mAutoResponse = aValue;
+ },
+
+ mTargetCalendar: null,
+ get targetCalendar() {
+ return this.mTargetCalendar;
+ },
+ set targetCalendar(aValue) {
+ this.mTargetCalendar = aValue;
+ },
+
+ mIdentity: null,
+ get identity() {
+ return this.mIdentity;
+ },
+ set identity(aValue) {
+ this.mIdentity = aValue;
+ },
+
+ mLocalStatus: null,
+ get localStatus() {
+ return this.mLocalStatus;
+ },
+ set localStatus(aValue) {
+ this.mLocalStatus = aValue;
+ },
+
+ mItemList: {},
+
+ init(aIcalString) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(aIcalString);
+
+ // - User specific alarms as well as X-MOZ- properties are irrelevant w.r.t. iTIP messages,
+ // should not be sent out and should not be relevant for incoming messages
+ // - faked master items
+ // so clean them out:
+
+ function cleanItem(item) {
+ // the following changes will bump LAST-MODIFIED/DTSTAMP, we want to preserve the originals:
+ let stamp = item.stampTime;
+ let lastModified = item.lastModifiedTime;
+ item.clearAlarms();
+ item.alarmLastAck = null;
+ item.deleteProperty("RECEIVED-SEQUENCE");
+ item.deleteProperty("RECEIVED-DTSTAMP");
+ for (let [name] of item.properties) {
+ if (name != "X-MOZ-FAKED-MASTER" && name.substr(0, "X-MOZ-".length) == "X-MOZ-") {
+ item.deleteProperty(name);
+ }
+ }
+ // never publish an organizer's RECEIVED params:
+ item.getAttendees().forEach(att => {
+ att.deleteProperty("RECEIVED-SEQUENCE");
+ att.deleteProperty("RECEIVED-DTSTAMP");
+ });
+
+ // according to RfC 6638, the following items must not be exposed in client side
+ // email scheduling messages, so let's remove it if present
+ let removeSchedulingParams = aCalUser => {
+ aCalUser.deleteProperty("SCHEDULE-AGENT");
+ aCalUser.deleteProperty("SCHEDULE-FORCE-SEND");
+ aCalUser.deleteProperty("SCHEDULE-STATUS");
+ };
+ item.getAttendees().forEach(removeSchedulingParams);
+ // we're graceful here as some PUBLISHed events may violate RfC by having no organizer
+ if (item.organizer) {
+ removeSchedulingParams(item.organizer);
+ }
+
+ item.setProperty("DTSTAMP", stamp);
+ item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item
+ }
+
+ this.mItemList = [];
+ for (let item of cal.iterate.items(parser.getItems())) {
+ cleanItem(item);
+ // only push non-faked master items or
+ // the overridden instances of faked master items
+ // to the list:
+ if (item == item.parentItem) {
+ if (!item.hasProperty("X-MOZ-FAKED-MASTER")) {
+ this.mItemList.push(item);
+ }
+ } else if (item.parentItem.hasProperty("X-MOZ-FAKED-MASTER")) {
+ this.mItemList.push(item);
+ }
+ }
+
+ // We set both methods now for safety's sake. It's the ItipProcessor's
+ // responsibility to properly ascertain what the correct response
+ // method is (using user feedback, prefs, etc.) for the given
+ // receivedMethod. The RFC tells us to treat items without a METHOD
+ // as if they were METHOD:REQUEST.
+ for (let prop of parser.getProperties()) {
+ if (prop.propertyName == "METHOD") {
+ this.mReceivedMethod = prop.value;
+ this.mResponseMethod = prop.value;
+ break;
+ }
+ }
+
+ this.mIsInitialized = true;
+ },
+
+ clone() {
+ let newItem = new CalItipItem();
+ newItem.mItemList = this.mItemList.map(item => item.clone());
+ newItem.mReceivedMethod = this.mReceivedMethod;
+ newItem.mResponseMethod = this.mResponseMethod;
+ newItem.mAutoResponse = this.mAutoResponse;
+ newItem.mTargetCalendar = this.mTargetCalendar;
+ newItem.mIdentity = this.mIdentity;
+ newItem.mLocalStatus = this.mLocalStatus;
+ newItem.mSender = this.mSender;
+ newItem.mIsSend = this.mIsSend;
+ newItem.mIsInitialized = this.mIsInitialized;
+ return newItem;
+ },
+
+ /**
+ * This returns both the array and the number of items. An easy way to
+ * call it is: let itemArray = itipItem.getItemList();
+ */
+ getItemList() {
+ if (!this.mIsInitialized) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ return this.mItemList;
+ },
+
+ /**
+ * Note that this code forces the user to respond to all items in the same
+ * way, which is a current limitation of the spec.
+ */
+ setAttendeeStatus(aAttendeeId, aStatus) {
+ // Append "mailto:" to the attendee if it is missing it.
+ if (!aAttendeeId.match(/^mailto:/i)) {
+ aAttendeeId = "mailto:" + aAttendeeId;
+ }
+
+ for (let item of this.mItemList) {
+ let attendee = item.getAttendeeById(aAttendeeId);
+ if (attendee) {
+ // Replies should not have the RSVP property.
+ // XXX BUG 351589: workaround for updating an attendee
+ item.removeAttendee(attendee);
+ attendee = attendee.clone();
+ attendee.rsvp = null;
+ item.addAttendee(attendee);
+ }
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalMetronome.jsm b/comm/calendar/base/src/CalMetronome.jsm
new file mode 100644
index 0000000000..b675e5bc0a
--- /dev/null
+++ b/comm/calendar/base/src/CalMetronome.jsm
@@ -0,0 +1,142 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalMetronome"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { EventEmitter } = ChromeUtils.importESModule("resource://gre/modules/EventEmitter.sys.mjs");
+
+const MINUTE_IN_MS = 60000;
+const HOUR_IN_MS = 3600000;
+const DAY_IN_MS = 86400000;
+
+/**
+ * Keeps calendar UI/components in sync by ticking regularly. Fires a "minute"
+ * event every minute on the minute, an "hour" event on the hour, and a "day"
+ * event at midnight. Each event also fires if longer than the time period in
+ * question has elapsed since the last event, e.g. because the computer has
+ * been asleep.
+ *
+ * It automatically corrects clock skew: if a minute event is more than one
+ * second late, the time to the next event is recalculated and should fire a
+ * few milliseconds late at worst.
+ *
+ * @implements nsIObserver
+ * @implements EventEmitter
+ */
+var CalMetronome = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ /**
+ * The time when the minute event last fired, in milliseconds since the epoch.
+ *
+ * @type integer
+ */
+ _lastFireTime: 0,
+
+ /**
+ * The last minute for which the minute event fired.
+ *
+ * @type integer (0-59)
+ */
+ _lastMinute: -1,
+
+ /**
+ * The last hour for which the hour event fired.
+ *
+ * @type integer (0-23)
+ */
+ _lastHour: -1,
+
+ /**
+ * The last day of the week for which the day event fired.
+ *
+ * @type integer (0-7)
+ */
+ _lastDay: -1,
+
+ /**
+ * The timer running everything.
+ *
+ * @type nsITimer
+ */
+ _timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer),
+
+ init() {
+ let now = new Date();
+ this._lastFireTime = now.valueOf();
+ this._lastHour = now.getHours();
+ this._lastDay = now.getDay();
+
+ EventEmitter.decorate(this);
+
+ Services.obs.addObserver(this, "wake_notification");
+ Services.obs.addObserver(this, "quit-application");
+ this._startNext();
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "wake_notification") {
+ cal.LOGverbose("[CalMetronome] Observed wake_notification");
+ this.notify();
+ } else if (topic == "quit-application") {
+ this._timer.cancel();
+ Services.obs.removeObserver(this, "wake_notification");
+ Services.obs.removeObserver(this, "quit-application");
+ }
+ },
+
+ _startNext() {
+ this._timer.cancel();
+
+ let now = new Date();
+ let next = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ now.getHours(),
+ now.getMinutes() + 1,
+ 0
+ );
+ cal.LOGverbose(`[CalMetronome] Scheduling one-off event in ${next - now}ms`);
+ this._timer.initWithCallback(this, next - now, Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ _startRepeating() {
+ cal.LOGverbose(`[CalMetronome] Starting repeating events`);
+ this._timer.initWithCallback(this, MINUTE_IN_MS, Ci.nsITimer.TYPE_REPEATING_SLACK);
+ },
+
+ notify() {
+ let now = new Date();
+ let elapsedSinceLastFire = now.valueOf() - this._lastFireTime;
+ this._lastFireTime = now.valueOf();
+
+ let minute = now.getMinutes();
+ if (minute != this._lastMinute || elapsedSinceLastFire > MINUTE_IN_MS) {
+ this._lastMinute = minute;
+ this.emit("minute", now);
+ }
+
+ let hour = now.getHours();
+ if (hour != this._lastHour || elapsedSinceLastFire > HOUR_IN_MS) {
+ this._lastHour = hour;
+ this.emit("hour", now);
+ }
+
+ let day = now.getDay();
+ if (day != this._lastDay || elapsedSinceLastFire > DAY_IN_MS) {
+ this._lastDay = day;
+ this.emit("day", now);
+ }
+
+ let slack = now.getSeconds();
+ if (slack >= 1 && slack < 59) {
+ this._startNext();
+ } else if (this._timer.type == Ci.nsITimer.TYPE_ONE_SHOT) {
+ this._startRepeating();
+ }
+ },
+};
+CalMetronome.init();
diff --git a/comm/calendar/base/src/CalMimeConverter.jsm b/comm/calendar/base/src/CalMimeConverter.jsm
new file mode 100644
index 0000000000..be09812817
--- /dev/null
+++ b/comm/calendar/base/src/CalMimeConverter.jsm
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalMimeConverter"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function CalMimeConverter() {
+ this.wrappedJSObject = this;
+}
+
+CalMimeConverter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleMimeConverter"]),
+ classID: Components.ID("{c70acb08-464e-4e55-899d-b2c84c5409fa}"),
+
+ mailChannel: null,
+ uri: null,
+
+ convertToHTML(contentType, data) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(data);
+ let event = null;
+ for (let item of parser.getItems()) {
+ if (item.isEvent()) {
+ if (item.hasProperty("X-MOZ-FAKED-MASTER")) {
+ // if it's a faked master, take any overridden item to get a real occurrence:
+ let exc = item.recurrenceInfo.getExceptionFor(item.startDate);
+ cal.ASSERT(exc, "unexpected!");
+ if (exc) {
+ item = exc;
+ }
+ }
+ event = item;
+ break;
+ }
+ }
+ if (!event) {
+ return "";
+ }
+
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ itipItem.init(data);
+
+ // this.uri is the message URL that we are processing.
+ if (this.uri) {
+ try {
+ let msgUrl = this.uri.QueryInterface(Ci.nsIMsgMailNewsUrl);
+ itipItem.sender = msgUrl.mimeHeaders.extractHeader("From", false);
+ } catch (exc) {
+ // msgWindow is optional in some scenarios
+ // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
+ }
+ }
+
+ // msgOverlay needs to be defined irrespectively of the existence of msgWindow to not break
+ // printing of invitation emails
+ let msgOverlay = "";
+
+ if (!Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ let dom = cal.invitation.createInvitationOverlay(event, itipItem);
+ msgOverlay = cal.xml.serializeDOM(dom);
+ }
+
+ this.mailChannel.imipItem = itipItem;
+
+ return msgOverlay;
+ },
+};
diff --git a/comm/calendar/base/src/CalPeriod.jsm b/comm/calendar/base/src/CalPeriod.jsm
new file mode 100644
index 0000000000..7c3ca3c5e3
--- /dev/null
+++ b/comm/calendar/base/src/CalPeriod.jsm
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalPeriod"];
+
+const { ICAL, unwrapSetter, wrapGetter } = ChromeUtils.import(
+ "resource:///modules/calendar/Ical.jsm"
+);
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+ChromeUtils.defineModuleGetter(lazy, "CalDuration", "resource:///modules/CalDuration.jsm");
+
+function CalPeriod(innerObject) {
+ this.innerObject = innerObject || new ICAL.Period({});
+ this.wrappedJSObject = this;
+}
+
+CalPeriod.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIPeriod"]),
+ classID: Components.ID("{394a281f-7299-45f7-8b1f-cce21258972f}"),
+
+ isMutable: true,
+ innerObject: null,
+
+ get icalPeriod() {
+ return this.innerObject;
+ },
+ set icalPeriod(val) {
+ this.innerObject = val;
+ },
+
+ makeImmutable() {
+ this.isMutable = false;
+ },
+ clone() {
+ return new CalPeriod(this.innerObject.clone());
+ },
+
+ get start() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.start);
+ },
+ set start(rawval) {
+ unwrapSetter(
+ ICAL.Time,
+ rawval,
+ function (val) {
+ this.innerObject.start = val;
+ },
+ this
+ );
+ },
+
+ get end() {
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getEnd());
+ },
+ set end(rawval) {
+ unwrapSetter(
+ ICAL.Time,
+ rawval,
+ function (val) {
+ if (this.innerObject.duration) {
+ this.innerObject.duration = null;
+ }
+ this.innerObject.end = val;
+ },
+ this
+ );
+ },
+
+ get duration() {
+ return wrapGetter(lazy.CalDuration, this.innerObject.getDuration());
+ },
+
+ get icalString() {
+ return this.innerObject.toICALString();
+ },
+ set icalString(val) {
+ let dates = ICAL.parse._parseValue(val, "period", ICAL.design.icalendar);
+ this.innerObject = ICAL.Period.fromString(dates.join("/"));
+ },
+
+ toString() {
+ return this.innerObject.toString();
+ },
+};
diff --git a/comm/calendar/base/src/CalProtocolHandler.jsm b/comm/calendar/base/src/CalProtocolHandler.jsm
new file mode 100644
index 0000000000..09632355a0
--- /dev/null
+++ b/comm/calendar/base/src/CalProtocolHandler.jsm
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalProtocolHandlerWebcal", "CalProtocolHandlerWebcals"];
+
+/**
+ * CalProtocolHandler.
+ *
+ * @param {string} scheme - The scheme to init for (webcal, webcals).
+ * @implements {nsIProtocolHandler}
+ */
+class CalProtocolHandlerWebcal {
+ QueryInterface = ChromeUtils.generateQI(["nsIProtocolHandler"]);
+
+ scheme = "webcal";
+ httpScheme = "http";
+ httpPort = 80;
+
+ newURI(aSpec, anOriginalCharset, aBaseURI) {
+ return Cc["@mozilla.org/network/standard-url-mutator;1"]
+ .createInstance(Ci.nsIStandardURLMutator)
+ .init(Ci.nsIStandardURL.URLTYPE_STANDARD, this.httpPort, aSpec, anOriginalCharset, aBaseURI)
+ .finalize()
+ .QueryInterface(Ci.nsIStandardURL);
+ }
+
+ newChannel(aUri, aLoadInfo) {
+ let uri = aUri.mutate().setScheme(this.httpScheme).finalize();
+
+ let channel;
+ if (aLoadInfo) {
+ channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo);
+ } else {
+ channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ }
+ channel.originalURI = aUri;
+ return channel;
+ }
+
+ allowPort(aPort, aScheme) {
+ return false; // We are not overriding any special ports.
+ }
+}
+CalProtocolHandlerWebcal.prototype.classID = Components.ID(
+ "{1153c73a-39be-46aa-9ba9-656d188865ca}"
+);
+
+class CalProtocolHandlerWebcals extends CalProtocolHandlerWebcal {
+ scheme = "webcals";
+ httpScheme = "http";
+ httpPort = 443;
+}
+CalProtocolHandlerWebcals.prototype.classID = Components.ID(
+ "{bdf71224-365d-4493-856a-a7e74026f766}"
+);
diff --git a/comm/calendar/base/src/CalReadableStreamFactory.jsm b/comm/calendar/base/src/CalReadableStreamFactory.jsm
new file mode 100644
index 0000000000..c41d20dc8f
--- /dev/null
+++ b/comm/calendar/base/src/CalReadableStreamFactory.jsm
@@ -0,0 +1,314 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* global ReadableStream */
+
+const EXPORTED_SYMBOLS = ["CalReadableStreamFactory"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Function used to transform each value received from a stream.
+ *
+ * @callback MapStreamFunction
+ * @param {any} value
+ * @returns {Promise<any>|any}
+ */
+
+/**
+ * A version of UnderlyingSource that accepts a CalBoundedReadableStreamController
+ * as the controller argument.
+ *
+ * @typedef {object} CalBoundedReadableStreamUnderlyingSource
+ */
+
+/**
+ * Wrapper class for a ReadableStreamDefaultController that keeps track of how
+ * many items have been added to the queue before closing. This controller also
+ * buffers items to reduce the amount of times items are added to the queue.
+ */
+class CalBoundedReadableStreamController {
+ /**
+ * @type {ReadableStreamDefaultController}
+ */
+ _controller = null;
+
+ /**
+ * @type {CalBoundedReadableStreamUnderlyingSource}
+ */
+ _src = null;
+
+ /**
+ * @type {number}
+ */
+ _maxTotalItems;
+
+ /**
+ * @type {number}
+ */
+ _maxQueuedItems;
+
+ /**
+ * @type {calIItemBase[]}
+ */
+ _buffer = [];
+
+ /**
+ * @type {boolean}
+ */
+ _closed = false;
+
+ /**
+ * The count of items enqueued so far.
+ *
+ * @type {number}
+ */
+ count = 0;
+
+ /**
+ * @param {number} maxTotalItems
+ * @param {number} maxQueuedItems
+ * @param {CalBoundedReadableStreamUnderlyingSource} src
+ */
+ constructor(maxTotalItems, maxQueuedItems, src) {
+ this._maxTotalItems = maxTotalItems;
+ this._maxQueuedItems = maxQueuedItems;
+ this._src = src;
+ }
+
+ /**
+ * Indicates whether the maximum number of items have been added to the queue
+ * after which no more will be allowed.
+ *
+ * @type {number}
+ */
+ get maxTotalItemsReached() {
+ return this._maxTotalItems && this.count >= this._maxTotalItems;
+ }
+
+ /**
+ * Indicates whether the queue is full or not.
+ *
+ * @type {boolean}
+ */
+ get queueFull() {
+ return this._buffer.length >= this._maxQueuedItems;
+ }
+
+ /**
+ * Indicates how many more items can be enqueued based on the internal count
+ * kept.
+ *
+ * @type {number}
+ */
+ get remainingItemCount() {
+ return this._maxTotalItems ? this._maxTotalItems - this.count : Infinity;
+ }
+
+ /**
+ * Provides the value of the same property from the controller.
+ *
+ * @type {number}
+ */
+ get desiredSize() {
+ return this._controller.desiredSize;
+ }
+
+ /**
+ * Called by the ReadableStream to begin queueing items. This delegates to
+ * the provided underlying source.
+ *
+ * @param {ReadableStreamDefaultController} controller
+ */
+ async start(controller) {
+ this._controller = controller;
+ if (this._src.start) {
+ await this._src.start(this);
+ }
+ }
+
+ /**
+ * Called by the ReadableStream to receive more items when the queue has not
+ * been filled.
+ */
+ async pull() {
+ if (this._src.pull) {
+ await this._src.pull(this);
+ }
+ }
+
+ /**
+ * Called by the ReadableStream when reading has been cancelled.
+ *
+ * @param {string} reason
+ */
+ async cancel(reason) {
+ this._closed = true;
+ if (this._src.cancel) {
+ await this._src.cancel(reason);
+ }
+ }
+
+ /**
+ * Called by start() of the underlying source to add items to the queue. Items
+ * will only be added if maxTotalItemsReached returns false at which point
+ * the stream is automatically closed.
+ *
+ * @param {calIItemBase[]} items
+ */
+ enqueue(items) {
+ for (let item of items) {
+ if (this.queueFull) {
+ this.flush();
+ }
+ if (this.maxTotalItemsReached) {
+ return;
+ }
+ this._buffer.push(item);
+ }
+ this.flush();
+ }
+
+ /**
+ * Flushes the internal buffer if the number of buffered items have reached
+ * the threshold.
+ *
+ * @param {boolean} [force] - If true, will flush all items regardless of the
+ * threshold.
+ */
+ flush(force) {
+ if (force || this.queueFull) {
+ if (this.maxTotalItemsReached) {
+ return;
+ }
+ let buffer = this._buffer.slice(0, this.remainingItemCount);
+ this._controller.enqueue(buffer);
+ this.count += buffer.length;
+ this._buffer = [];
+ if (this.maxTotalItemsReached) {
+ this._controller.close();
+ }
+ }
+ }
+
+ /**
+ * Puts the stream in the error state.
+ *
+ * @param {Error} err
+ */
+ error(err) {
+ this._closed = true;
+ this._controller.error(err);
+ }
+
+ /**
+ * Closes the stream preventing any further items from being added to the queue.
+ */
+ close() {
+ if (!this._closed) {
+ if (this._buffer.length) {
+ this.flush(true);
+ }
+ this._closed = true;
+ this._controller.close();
+ }
+ }
+}
+
+/**
+ * Factory object for creating ReadableStreams of calIItemBase instances. This
+ * is used by the providers to satisfy getItems() calls from their respective
+ * backing stores.
+ */
+class CalReadableStreamFactory {
+ /**
+ * The default amount of items to queue before providing via the reader.
+ */
+ static defaultQueueSize = 10;
+
+ /**
+ * Creates a generic ReadableStream using the passed object as the
+ * UnderlyingSource. Use this method instead of creating streams directly
+ * until the API is more stable.
+ *
+ * @param {UnderlyingSource} src
+ *
+ * @returns {ReadableStream}
+ */
+ static createReadableStream(src) {
+ return new ReadableStream(src);
+ }
+
+ /**
+ * Creates a ReadableStream of calIItemBase items that tracks how many
+ * have been added to the queue. If maxTotalItems or more are enqueued, the
+ * stream will close ignoring further additions.
+ *
+ * @param {number} maxTotalItems
+ * @param {number} maxQueuedItems
+ * @param {UnderlyingSource} src
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ static createBoundedReadableStream(maxTotalItems, maxQueuedItems, src) {
+ return new ReadableStream(
+ new CalBoundedReadableStreamController(maxTotalItems, maxQueuedItems, src)
+ );
+ }
+
+ /**
+ * Creates a ReadableStream that will provide no actual items.
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ static createEmptyReadableStream() {
+ return new ReadableStream({
+ start(controller) {
+ controller.close();
+ },
+ });
+ }
+
+ /**
+ * Creates a ReadableStream that uses the one or more provided ReadableStreams
+ * for the source of its data. Each stream is read to completion one at a time
+ * and an error occurring while reading any will cause the main stream to end
+ * with in an error state.
+ *
+ * @param {ReadableStream[]} streams
+ * @returns {ReadableStream}
+ */
+ static createCombinedReadableStream(streams) {
+ return new ReadableStream({
+ async start(controller) {
+ for (let stream of streams) {
+ for await (let chunk of cal.iterate.streamValues(stream)) {
+ controller.enqueue(chunk);
+ }
+ }
+ controller.close();
+ },
+ });
+ }
+
+ /**
+ * Creates a ReadableStream from another stream where each chunk of the source
+ * stream is passed to a MapStreamFunction before enqueuing in the final stream.
+ *
+ * @param {ReadableStream}
+ * @param {MapStreamFunction}
+ *
+ * @returns {ReadableStream}
+ */
+ static createMappedReadableStream(stream, func) {
+ return new ReadableStream({
+ async start(controller) {
+ for await (let chunk of cal.iterate.streamValues(stream)) {
+ controller.enqueue(await func(chunk));
+ }
+ controller.close();
+ },
+ });
+ }
+}
diff --git a/comm/calendar/base/src/CalRecurrenceDate.jsm b/comm/calendar/base/src/CalRecurrenceDate.jsm
new file mode 100644
index 0000000000..cd43979a38
--- /dev/null
+++ b/comm/calendar/base/src/CalRecurrenceDate.jsm
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalRecurrenceDate"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalPeriod", "resource:///modules/CalPeriod.jsm");
+
+function CalRecurrenceDate() {
+ this.wrappedJSObject = this;
+}
+
+var calRecurrenceDateClassID = Components.ID("{806b6423-3aaa-4b26-afa3-de60563e9cec}");
+var calRecurrenceDateInterfaces = [Ci.calIRecurrenceItem, Ci.calIRecurrenceDate];
+CalRecurrenceDate.prototype = {
+ isMutable: true,
+
+ mIsNegative: false,
+ mDate: null,
+
+ classID: calRecurrenceDateClassID,
+ QueryInterface: cal.generateQI(["calIRecurrenceItem", "calIRecurrenceDate"]),
+ classInfo: cal.generateCI({
+ classID: calRecurrenceDateClassID,
+ contractID: "@mozilla.org/calendar/recurrence-date;1",
+ classDescription: "The date of an occurrence of a recurring item",
+ interfaces: calRecurrenceDateInterfaces,
+ }),
+
+ makeImmutable() {
+ this.isMutable = false;
+ },
+
+ ensureMutable() {
+ if (!this.isMutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+
+ clone() {
+ let other = new CalRecurrenceDate();
+ other.mDate = this.mDate ? this.mDate.clone() : null;
+ other.mIsNegative = this.mIsNegative;
+ return other;
+ },
+
+ get isNegative() {
+ return this.mIsNegative;
+ },
+ set isNegative(val) {
+ this.ensureMutable();
+ this.mIsNegative = val;
+ },
+
+ get isFinite() {
+ return true;
+ },
+
+ get date() {
+ return this.mDate;
+ },
+ set date(val) {
+ this.ensureMutable();
+ this.mDate = val;
+ },
+
+ getNextOccurrence(aStartTime, aOccurrenceTime) {
+ if (this.mDate && this.mDate.compare(aStartTime) > 0) {
+ return this.mDate;
+ }
+ return null;
+ },
+
+ getOccurrences(aStartTime, aRangeStart, aRangeEnd, aMaxCount) {
+ if (
+ this.mDate &&
+ this.mDate.compare(aRangeStart) >= 0 &&
+ (!aRangeEnd || this.mDate.compare(aRangeEnd) < 0)
+ ) {
+ return [this.mDate];
+ }
+ return [];
+ },
+
+ get icalString() {
+ let comp = this.icalProperty;
+ return comp ? comp.icalString : "";
+ },
+ set icalString(val) {
+ let prop = cal.icsService.createIcalPropertyFromString(val);
+ let propName = prop.propertyName;
+ if (propName != "RDATE" && propName != "EXDATE") {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ this.icalProperty = prop;
+ },
+
+ get icalProperty() {
+ let prop = cal.icsService.createIcalProperty(this.mIsNegative ? "EXDATE" : "RDATE");
+ prop.valueAsDatetime = this.mDate;
+ return prop;
+ },
+ set icalProperty(prop) {
+ if (prop.propertyName == "RDATE") {
+ this.mIsNegative = false;
+ if (prop.getParameter("VALUE") == "PERIOD") {
+ let period = new lazy.CalPeriod();
+ period.icalString = prop.valueAsIcalString;
+ this.mDate = period.start;
+ } else {
+ this.mDate = prop.valueAsDatetime;
+ }
+ } else if (prop.propertyName == "EXDATE") {
+ this.mIsNegative = true;
+ this.mDate = prop.valueAsDatetime;
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalRecurrenceInfo.jsm b/comm/calendar/base/src/CalRecurrenceInfo.jsm
new file mode 100644
index 0000000000..f24f210a30
--- /dev/null
+++ b/comm/calendar/base/src/CalRecurrenceInfo.jsm
@@ -0,0 +1,847 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalRecurrenceInfo"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function getRidKey(date) {
+ if (!date) {
+ return null;
+ }
+ let timezone = date.timezone;
+ if (!timezone.isUTC && !timezone.isFloating) {
+ date = date.getInTimezone(cal.dtz.UTC);
+ }
+ return date.icalString;
+}
+
+/**
+ * Constructor for `calIRecurrenceInfo` objects.
+ *
+ * @class
+ * @implements {calIRecurrenceInfo}
+ * @param {calIItemBase} [item] - Optional calendar item for which this recurrence applies.
+ */
+function CalRecurrenceInfo(item) {
+ this.wrappedJSObject = this;
+ this.mRecurrenceItems = [];
+ this.mExceptionMap = {};
+ if (item) {
+ this.item = item;
+ }
+}
+
+CalRecurrenceInfo.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIRecurrenceInfo"]),
+ classID: Components.ID("{04027036-5884-4a30-b4af-f2cad79f6edf}"),
+
+ mImmutable: false,
+ mBaseItem: null,
+ mEndDate: null,
+ mRecurrenceItems: null,
+ mPositiveRules: null,
+ mNegativeRules: null,
+ mExceptionMap: null,
+
+ /**
+ * Helpers
+ */
+ ensureBaseItem() {
+ if (!this.mBaseItem) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
+ }
+ },
+ ensureMutable() {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+ ensureSortedRecurrenceRules() {
+ if (!this.mPositiveRules || !this.mNegativeRules) {
+ this.mPositiveRules = [];
+ this.mNegativeRules = [];
+ for (let ritem of this.mRecurrenceItems) {
+ if (ritem.isNegative) {
+ this.mNegativeRules.push(ritem);
+ } else {
+ this.mPositiveRules.push(ritem);
+ }
+ }
+ }
+ },
+
+ /**
+ * Mutability bits
+ */
+ get isMutable() {
+ return !this.mImmutable;
+ },
+ makeImmutable() {
+ if (this.mImmutable) {
+ return;
+ }
+
+ for (let ritem of this.mRecurrenceItems) {
+ if (ritem.isMutable) {
+ ritem.makeImmutable();
+ }
+ }
+
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ if (item.isMutable) {
+ item.makeImmutable();
+ }
+ }
+
+ this.mImmutable = true;
+ },
+
+ clone() {
+ let cloned = new CalRecurrenceInfo();
+ cloned.mBaseItem = this.mBaseItem;
+
+ let clonedItems = [];
+ for (let ritem of this.mRecurrenceItems) {
+ clonedItems.push(ritem.clone());
+ }
+ cloned.mRecurrenceItems = clonedItems;
+
+ let clonedExceptions = {};
+ for (let exitem in this.mExceptionMap) {
+ clonedExceptions[exitem] = this.mExceptionMap[exitem].cloneShallow(this.mBaseItem);
+ }
+ cloned.mExceptionMap = clonedExceptions;
+
+ return cloned;
+ },
+
+ /*
+ * calIRecurrenceInfo
+ */
+ get item() {
+ return this.mBaseItem;
+ },
+ set item(value) {
+ this.ensureMutable();
+
+ value = cal.unwrapInstance(value);
+ this.mBaseItem = value;
+ // patch exception's parentItem:
+ for (let ex in this.mExceptionMap) {
+ let exitem = this.mExceptionMap[ex];
+ exitem.parentItem = value;
+ }
+ },
+
+ get isFinite() {
+ this.ensureBaseItem();
+
+ for (let ritem of this.mRecurrenceItems) {
+ if (!ritem.isFinite) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Get the item ending date (end date for an event, due date or entry date if available for a task).
+ *
+ * @param {calIEvent | calITodo} item - The item.
+ * @returns {calIDateTime | null} The ending date or null.
+ */
+ getItemEndingDate(item) {
+ if (item.isEvent()) {
+ if (item.endDate) {
+ return item.endDate;
+ }
+ } else if (item.isTodo()) {
+ // Due date must be considered since it is used when displaying the task in agenda view.
+ if (item.dueDate) {
+ return item.dueDate;
+ } else if (item.entryDate) {
+ return item.entryDate;
+ }
+ }
+ return null;
+ },
+
+ get recurrenceEndDate() {
+ // The lowest and highest possible values of a PRTime (64-bit integer) when in javascript,
+ // which stores them as floating-point values.
+ const MIN_PRTIME = -9223372036854775000;
+ const MAX_PRTIME = 9223372036854775000;
+
+ // If this object is mutable, skip this optimisation, so that we don't have to work out every
+ // possible modification and invalidate the cached value. Immutable objects are unlikely to
+ // exist for long enough to really benefit anyway.
+ if (this.isMutable) {
+ return MAX_PRTIME;
+ }
+
+ if (this.mEndDate === null) {
+ if (this.isFinite) {
+ this.mEndDate = MIN_PRTIME;
+ let lastOccurrence = this.getPreviousOccurrence(cal.createDateTime("99991231T235959Z"));
+ if (lastOccurrence) {
+ let endingDate = this.getItemEndingDate(lastOccurrence);
+ if (endingDate) {
+ this.mEndDate = endingDate.nativeTime;
+ }
+ }
+
+ // A modified occurrence may have a new ending date positioned after last occurrence one.
+ for (let rid in this.mExceptionMap) {
+ let item = this.mExceptionMap[rid];
+
+ let endingDate = this.getItemEndingDate(item);
+ if (endingDate && this.mEndDate < endingDate.nativeTime) {
+ this.mEndDate = endingDate.nativeTime;
+ }
+ }
+ } else {
+ this.mEndDate = MAX_PRTIME;
+ }
+ }
+
+ return this.mEndDate;
+ },
+
+ getRecurrenceItems() {
+ this.ensureBaseItem();
+
+ return this.mRecurrenceItems;
+ },
+
+ setRecurrenceItems(aItems) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ // XXX should we clone these?
+ this.mRecurrenceItems = aItems;
+ this.mPositiveRules = null;
+ this.mNegativeRules = null;
+ },
+
+ countRecurrenceItems() {
+ this.ensureBaseItem();
+
+ return this.mRecurrenceItems.length;
+ },
+
+ getRecurrenceItemAt(aIndex) {
+ this.ensureBaseItem();
+
+ if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return this.mRecurrenceItems[aIndex];
+ },
+
+ appendRecurrenceItem(aItem) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ aItem = cal.unwrapInstance(aItem);
+ this.mRecurrenceItems.push(aItem);
+ if (aItem.isNegative) {
+ this.mNegativeRules.push(aItem);
+ } else {
+ this.mPositiveRules.push(aItem);
+ }
+ },
+
+ deleteRecurrenceItemAt(aIndex) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (this.mRecurrenceItems[aIndex].isNegative) {
+ this.mNegativeRules = null;
+ } else {
+ this.mPositiveRules = null;
+ }
+
+ this.mRecurrenceItems.splice(aIndex, 1);
+ },
+
+ deleteRecurrenceItem(aItem) {
+ aItem = cal.unwrapInstance(aItem);
+ let pos = this.mRecurrenceItems.indexOf(aItem);
+ if (pos > -1) {
+ this.deleteRecurrenceItemAt(pos);
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ },
+
+ insertRecurrenceItemAt(aItem, aIndex) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ if (aIndex < 0 || aIndex > this.mRecurrenceItems.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ aItem = cal.unwrapInstance(aItem);
+ if (aItem.isNegative) {
+ this.mNegativeRules.push(aItem);
+ } else {
+ this.mPositiveRules.push(aItem);
+ }
+
+ this.mRecurrenceItems.splice(aIndex, 0, aItem);
+ },
+
+ clearRecurrenceItems() {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ this.mRecurrenceItems = [];
+ this.mPositiveRules = [];
+ this.mNegativeRules = [];
+ },
+
+ /*
+ * calculations
+ */
+ getNextOccurrence(aTime) {
+ this.ensureBaseItem();
+ this.ensureSortedRecurrenceRules();
+
+ let startDate = this.mBaseItem.recurrenceStartDate;
+ let nextOccurrences = [];
+ let invalidOccurrences;
+ let negMap = {};
+ let minOccRid;
+
+ // Go through all negative rules to create a map of occurrences that
+ // should be skipped when going through occurrences.
+ for (let ritem of this.mNegativeRules) {
+ // TODO Infinite rules (i.e EXRULE) are not taken into account,
+ // because its very performance hungry and could potentially
+ // lead to a deadlock (i.e RRULE is canceled out by an EXRULE).
+ // This is ok for now, since EXRULE is deprecated anyway.
+ if (ritem.isFinite) {
+ // Get all occurrences starting at our recurrence start date.
+ // This is fine, since there will never be an EXDATE that
+ // occurs before the event started and its illegal to EXDATE an
+ // RDATE.
+ let rdates = ritem.getOccurrences(startDate, startDate, null, 0);
+ // Map all negative dates.
+ for (let rdate of rdates) {
+ negMap[getRidKey(rdate)] = true;
+ }
+ } else {
+ cal.WARN(
+ "Item '" +
+ this.mBaseItem.title +
+ "'" +
+ (this.mBaseItem.calendar ? " (" + this.mBaseItem.calendar.name + ")" : "") +
+ " has an infinite negative rule (EXRULE)"
+ );
+ }
+ }
+
+ let bailCounter = 0;
+ do {
+ invalidOccurrences = 0;
+ // Go through all positive rules and get the next recurrence id
+ // according to that rule. If for all rules the rid is "invalid",
+ // (i.e an EXDATE removed it, or an exception moved it somewhere
+ // else), then get the respective next rid.
+ //
+ // If in a loop at least one rid is valid (i.e not an exception, not
+ // an exdate, is after aTime), then remember the lowest one.
+ for (let i = 0; i < this.mPositiveRules.length; i++) {
+ let rDateInstance = cal.wrapInstance(this.mPositiveRules[i], Ci.calIRecurrenceDate);
+ let rRuleInstance = cal.wrapInstance(this.mPositiveRules[i], Ci.calIRecurrenceRule);
+ if (rDateInstance) {
+ // RDATEs are special. there is only one date in this rule,
+ // so no need to search anything.
+ let rdate = rDateInstance.date;
+ if (!nextOccurrences[i] && rdate.compare(aTime) > 0) {
+ // The RDATE falls into range, save it.
+ nextOccurrences[i] = rdate;
+ } else {
+ // The RDATE doesn't fall into range. This rule will
+ // always be invalid, since it can't give out a date.
+ nextOccurrences[i] = null;
+ invalidOccurrences++;
+ }
+ } else if (rRuleInstance) {
+ // RRULEs must not start searching before |startDate|, since
+ // the pattern is only valid afterwards. If an occurrence
+ // was found in a previous round, we can go ahead and start
+ // searching from that occurrence.
+ let searchStart = nextOccurrences[i] || startDate;
+
+ // Search for the next occurrence after aTime. If the last
+ // round was invalid, then in this round we need to search
+ // after nextOccurrences[i] to make sure getNextOccurrence()
+ // doesn't find the same occurrence again.
+ let searchDate =
+ nextOccurrences[i] && nextOccurrences[i].compare(aTime) > 0
+ ? nextOccurrences[i]
+ : aTime;
+
+ nextOccurrences[i] = rRuleInstance.getNextOccurrence(searchStart, searchDate);
+ }
+
+ // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME
+ let nextKey = getRidKey(nextOccurrences[i]);
+ let isInExceptionMap =
+ nextKey && (this.mExceptionMap[nextKey.substring(0, 8)] || this.mExceptionMap[nextKey]);
+ let isInNegMap = nextKey && (negMap[nextKey.substring(0, 8)] || negMap[nextKey]);
+ if (nextKey && (isInNegMap || isInExceptionMap)) {
+ // If the found recurrence id points to either an exception
+ // (will handle later) or an EXDATE, then nextOccurrences[i]
+ // is invalid and we might need to try again next round.
+ invalidOccurrences++;
+ } else if (nextOccurrences[i]) {
+ // We have a valid recurrence id (not an exception, not an
+ // EXDATE, falls into range). We only need to save the
+ // earliest occurrence after aTime (checking for aTime is
+ // not needed, since getNextOccurrence() above returns only
+ // occurrences after aTime).
+ if (!minOccRid || minOccRid.compare(nextOccurrences[i]) > 0) {
+ minOccRid = nextOccurrences[i];
+ }
+ }
+ }
+
+ // To make sure users don't just report bugs like "the application
+ // hangs", bail out after 100 runs. If this happens, it is most
+ // likely a bug.
+ if (bailCounter++ > 100) {
+ cal.ERROR("Could not find next occurrence after 100 runs!");
+ return null;
+ }
+
+ // We counted how many positive rules found out that their next
+ // candidate is invalid. If all rules produce invalid next
+ // occurrences, a second round is needed.
+ } while (invalidOccurrences == this.mPositiveRules.length);
+
+ // Since we need to compare occurrences by date, save the rid found
+ // above also as a date. This works out because above we skipped
+ // exceptions.
+ let minOccDate = minOccRid;
+
+ // Scan exceptions for any dates earlier than the above found
+ // minOccDate, but still after aTime.
+ for (let ex in this.mExceptionMap) {
+ let exc = this.mExceptionMap[ex];
+ let start = exc.recurrenceStartDate;
+ if (start.compare(aTime) > 0 && (!minOccDate || start.compare(minOccDate) <= 0)) {
+ // This exception is earlier, save its rid (for getting the
+ // occurrence later on) and its date (for comparing to other
+ // exceptions).
+ minOccRid = exc.recurrenceId;
+ minOccDate = start;
+ }
+ }
+
+ // If we found a recurrence id any time above, then return the
+ // occurrence for it.
+ return minOccRid ? this.getOccurrenceFor(minOccRid) : null;
+ },
+
+ getPreviousOccurrence(aTime) {
+ // HACK We never know how early an RDATE might be before the actual
+ // recurrence start. Since rangeStart cannot be null for recurrence
+ // items like calIRecurrenceRule, we need to work around by supplying a
+ // very early date. Again, this might have a high performance penalty.
+ let early = cal.createDateTime();
+ early.icalString = "00000101T000000Z";
+
+ let rids = this.calculateDates(early, aTime, 0);
+ // The returned dates are sorted, so the last one is a good
+ // candidate, if it exists.
+ return rids.length > 0 ? this.getOccurrenceFor(rids[rids.length - 1].id) : null;
+ },
+
+ // internal helper function;
+ calculateDates(aRangeStart, aRangeEnd, aMaxCount) {
+ this.ensureBaseItem();
+ this.ensureSortedRecurrenceRules();
+
+ // workaround for UTC- timezones
+ let rangeStart = cal.dtz.ensureDateTime(aRangeStart);
+ let rangeEnd = cal.dtz.ensureDateTime(aRangeEnd);
+
+ // If aRangeStart falls in the middle of an occurrence, libical will
+ // not return that occurrence when we go and ask for an
+ // icalrecur_iterator_new. This actually seems fairly rational, so
+ // instead of hacking libical, I'm going to move aRangeStart back far
+ // enough to make sure we get the occurrences we might miss.
+ let searchStart = rangeStart.clone();
+ let baseDuration = this.mBaseItem.duration;
+ if (baseDuration) {
+ let duration = baseDuration.clone();
+ duration.isNegative = true;
+ searchStart.addDuration(duration);
+ }
+
+ let startDate = this.mBaseItem.recurrenceStartDate;
+ if (startDate == null) {
+ // Todo created by other apps may have a saved recurrence but
+ // start and due dates disabled. Since no recurrenceStartDate,
+ // treat as undated task.
+ return [];
+ }
+
+ let dates = [];
+
+ // toss in exceptions first. Save a map of all exceptions ids, so we
+ // don't add the wrong occurrences later on.
+ let occurrenceMap = {};
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ let occDate = cal.item.checkIfInRange(item, aRangeStart, aRangeEnd, true);
+ occurrenceMap[ex] = true;
+ if (occDate) {
+ dates.push({ id: item.recurrenceId, rstart: occDate });
+ }
+ }
+
+ // DTSTART/DUE is always part of the (positive) expanded set:
+ // DTSTART always equals RECURRENCE-ID for items expanded from RRULE
+ let baseOccDate = cal.item.checkIfInRange(this.mBaseItem, aRangeStart, aRangeEnd, true);
+ let baseOccDateKey = getRidKey(baseOccDate);
+ if (baseOccDate && !occurrenceMap[baseOccDateKey]) {
+ occurrenceMap[baseOccDateKey] = true;
+ dates.push({ id: baseOccDate, rstart: baseOccDate });
+ }
+
+ // if both range start and end are specified, we ask for all of the occurrences,
+ // to make sure we catch all possible exceptions. If aRangeEnd isn't specified,
+ // then we have to ask for aMaxCount, and hope for the best.
+ let maxCount;
+ if (rangeStart && rangeEnd) {
+ maxCount = 0;
+ } else {
+ maxCount = aMaxCount;
+ }
+
+ // Apply positive rules
+ for (let ritem of this.mPositiveRules) {
+ let cur_dates = ritem.getOccurrences(startDate, searchStart, rangeEnd, maxCount);
+ if (cur_dates.length == 0) {
+ continue;
+ }
+
+ // if positive, we just add these date to the existing set,
+ // but only if they're not already there
+
+ let index = 0;
+ let len = cur_dates.length;
+
+ // skip items before rangeStart due to searchStart libical hack:
+ if (rangeStart && baseDuration) {
+ for (; index < len; ++index) {
+ let date = cur_dates[index].clone();
+ date.addDuration(baseDuration);
+ if (rangeStart.compare(date) < 0) {
+ break;
+ }
+ }
+ }
+ for (; index < len; ++index) {
+ let date = cur_dates[index];
+ let dateKey = getRidKey(date);
+ if (occurrenceMap[dateKey]) {
+ // Don't add occurrences twice (i.e exception was
+ // already added before)
+ continue;
+ }
+ dates.push({ id: date, rstart: date });
+ occurrenceMap[dateKey] = true;
+ }
+ }
+
+ dates.sort((a, b) => a.rstart.compare(b.rstart));
+
+ // Apply negative rules
+ for (let ritem of this.mNegativeRules) {
+ let cur_dates = ritem.getOccurrences(startDate, searchStart, rangeEnd, maxCount);
+ if (cur_dates.length == 0) {
+ continue;
+ }
+
+ // XXX: i'm pretty sure negative dates can't really have exceptions
+ // (like, you can't make a date "real" by defining an RECURRENCE-ID which
+ // is an EXDATE, and then giving it a real DTSTART) -- so we don't
+ // check exceptions here
+ for (let dateToRemove of cur_dates) {
+ let dateToRemoveKey = getRidKey(dateToRemove);
+ if (dateToRemove.isDate) {
+ // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME
+ let toRemove = [];
+ for (let occurenceKey in occurrenceMap) {
+ if (occurrenceMap[occurenceKey] && occurenceKey.substring(0, 8) == dateToRemoveKey) {
+ dates = dates.filter(date => date.id.compare(dateToRemove) != 0);
+ toRemove.push(occurenceKey);
+ }
+ }
+ for (let i = 0; i < toRemove.length; i++) {
+ delete occurrenceMap[toRemove[i]];
+ }
+ } else if (occurrenceMap[dateToRemoveKey]) {
+ // TODO PERF Theoretically we could use occurrence map
+ // to construct the array of occurrences. Right now I'm
+ // just using the occurrence map to skip the filter
+ // action if the occurrence isn't there anyway.
+ dates = dates.filter(date => date.id.compare(dateToRemove) != 0);
+ delete occurrenceMap[dateToRemoveKey];
+ }
+ }
+ }
+
+ // The list was already sorted above, chop anything over aMaxCount, if
+ // specified.
+ if (aMaxCount && dates.length > aMaxCount) {
+ dates = dates.slice(0, aMaxCount);
+ }
+
+ return dates;
+ },
+
+ getOccurrenceDates(aRangeStart, aRangeEnd, aMaxCount) {
+ let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount);
+ dates = dates.map(date => date.rstart);
+ return dates;
+ },
+
+ getOccurrences(aRangeStart, aRangeEnd, aMaxCount) {
+ let results = [];
+ let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount);
+ if (dates.length) {
+ let count;
+ if (aMaxCount) {
+ count = Math.min(aMaxCount, dates.length);
+ } else {
+ count = dates.length;
+ }
+
+ for (let i = 0; i < count; i++) {
+ results.push(this.getOccurrenceFor(dates[i].id));
+ }
+ }
+ return results;
+ },
+
+ getOccurrenceFor(aRecurrenceId) {
+ let proxy = this.getExceptionFor(aRecurrenceId);
+ if (!proxy) {
+ return this.item.createProxy(aRecurrenceId);
+ }
+ return proxy;
+ },
+
+ removeOccurrenceAt(aRecurrenceId) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+
+ let rdate = cal.createRecurrenceDate();
+ rdate.isNegative = true;
+ rdate.date = aRecurrenceId.clone();
+
+ this.removeExceptionFor(rdate.date);
+
+ this.appendRecurrenceItem(rdate);
+ },
+
+ restoreOccurrenceAt(aRecurrenceId) {
+ this.ensureBaseItem();
+ this.ensureMutable();
+ this.ensureSortedRecurrenceRules();
+
+ for (let i = 0; i < this.mRecurrenceItems.length; i++) {
+ let rdate = cal.wrapInstance(this.mRecurrenceItems[i], Ci.calIRecurrenceDate);
+ if (rdate) {
+ if (rdate.isNegative && rdate.date.compare(aRecurrenceId) == 0) {
+ return this.deleteRecurrenceItemAt(i);
+ }
+ }
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ },
+
+ //
+ // exceptions
+ //
+
+ //
+ // Some notes:
+ //
+ // The way I read ICAL, RECURRENCE-ID is used to specify a
+ // particular instance of a recurring event, according to the
+ // RRULEs/RDATEs/etc. specified in the base event. If one of
+ // these is to be changed ("an exception"), then it can be
+ // referenced via the UID of the original event, and a
+ // RECURRENCE-ID of the start time of the instance to change.
+ // This, to me, means that an event where one of the instances has
+ // changed to a different time has a RECURRENCE-ID of the original
+ // start time, and a DTSTART/DTEND representing the new time.
+ //
+ // ITIP, however, seems to want something different -- you're
+ // supposed to use UID/RECURRENCE-ID to select from the current
+ // set of occurrences of an event. If you change the DTSTART for
+ // an instance, you're supposed to use the old (original) DTSTART
+ // as the RECURRENCE-ID, and put the new time as the DTSTART.
+ // However, after that change, to refer to that instance in the
+ // future, you have to use the modified DTSTART as the
+ // RECURRENCE-ID. This madness is described in ITIP end of
+ // section 3.7.1.
+ //
+ // This implementation does the first approach (RECURRENCE-ID will
+ // never change even if DTSTART for that instance changes), which
+ // I think is the right thing to do for CalDAV; I don't know what
+ // we'll do for incoming ITIP events though.
+ //
+ modifyException(anItem, aTakeOverOwnership) {
+ this.ensureBaseItem();
+
+ anItem = cal.unwrapInstance(anItem);
+
+ if (
+ anItem.parentItem.calendar != this.mBaseItem.calendar &&
+ anItem.parentItem.id != this.mBaseItem.id
+ ) {
+ cal.ERROR("recurrenceInfo::addException: item parentItem != this.mBaseItem (calendar/id)!");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ if (anItem.recurrenceId == null) {
+ cal.ERROR("recurrenceInfo::addException: item with null recurrenceId!");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let itemtoadd;
+ if (aTakeOverOwnership && anItem.isMutable) {
+ itemtoadd = anItem;
+ itemtoadd.parentItem = this.mBaseItem;
+ } else {
+ itemtoadd = anItem.cloneShallow(this.mBaseItem);
+ }
+
+ // we're going to assume that the recurrenceId is valid here,
+ // because presumably the item came from one of our functions
+
+ let exKey = getRidKey(itemtoadd.recurrenceId);
+ this.mExceptionMap[exKey] = itemtoadd;
+ },
+
+ getExceptionFor(aRecurrenceId) {
+ this.ensureBaseItem();
+ // Interface calIRecurrenceInfo specifies result be null if not found.
+ // To avoid strict "reference to undefined property" warning, appending
+ // "|| null" gives explicit result in case where property undefined
+ // (or false, 0, null, or "", but here it should never be those values).
+ return this.mExceptionMap[getRidKey(aRecurrenceId)] || null;
+ },
+
+ removeExceptionFor(aRecurrenceId) {
+ this.ensureBaseItem();
+ delete this.mExceptionMap[getRidKey(aRecurrenceId)];
+ },
+
+ getExceptionIds() {
+ this.ensureBaseItem();
+
+ let ids = [];
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ ids.push(item.recurrenceId);
+ }
+ return ids;
+ },
+
+ // changing the startdate of an item needs to take exceptions into account.
+ // in case we're about to modify a parentItem (aka 'folded' item), we need
+ // to modify the recurrenceId's of all possibly existing exceptions as well.
+ onStartDateChange(aNewStartTime, aOldStartTime) {
+ // passing null for the new starttime would indicate an error condition,
+ // since having a recurrence without a starttime is invalid.
+ cal.ASSERT(aNewStartTime, "invalid arg!", true);
+
+ // no need to check for changes if there's no previous starttime.
+ if (!aOldStartTime) {
+ return;
+ }
+
+ // convert both dates to UTC since subtractDate is not timezone aware.
+ let timeDiff = aNewStartTime
+ .getInTimezone(cal.dtz.UTC)
+ .subtractDate(aOldStartTime.getInTimezone(cal.dtz.UTC));
+
+ let rdates = {};
+
+ // take RDATE's and EXDATE's into account.
+ const kCalIRecurrenceDate = Ci.calIRecurrenceDate;
+ let ritems = this.getRecurrenceItems();
+ for (let ritem of ritems) {
+ let rDateInstance = cal.wrapInstance(ritem, kCalIRecurrenceDate);
+ let rRuleInstance = cal.wrapInstance(ritem, Ci.calIRecurrenceRule);
+ if (rDateInstance) {
+ ritem = rDateInstance;
+ let date = ritem.date;
+ date.addDuration(timeDiff);
+ if (!ritem.isNegative) {
+ rdates[getRidKey(date)] = date;
+ }
+ ritem.date = date;
+ } else if (rRuleInstance) {
+ ritem = rRuleInstance;
+ if (!ritem.isByCount) {
+ let untilDate = ritem.untilDate;
+ if (untilDate) {
+ untilDate.addDuration(timeDiff);
+ ritem.untilDate = untilDate;
+ }
+ }
+ }
+ }
+
+ let startTimezone = aNewStartTime.timezone;
+ let modifiedExceptions = [];
+ for (let exid of this.getExceptionIds()) {
+ let ex = this.getExceptionFor(exid);
+ if (ex) {
+ ex = ex.clone();
+ // track RECURRENCE-IDs in DTSTART's or RDATE's timezone,
+ // otherwise those won't match any longer w.r.t DST:
+ let rid = ex.recurrenceId;
+ let rdate = rdates[getRidKey(rid)];
+ rid = rid.getInTimezone(rdate ? rdate.timezone : startTimezone);
+ rid.addDuration(timeDiff);
+ ex.recurrenceId = rid;
+ cal.item.shiftOffset(ex, timeDiff);
+ modifiedExceptions.push(ex);
+ this.removeExceptionFor(exid);
+ }
+ }
+ for (let modifiedEx of modifiedExceptions) {
+ this.modifyException(modifiedEx, true);
+ }
+ },
+
+ onIdChange(aNewId) {
+ // patch all overridden items' id:
+ for (let ex in this.mExceptionMap) {
+ let item = this.mExceptionMap[ex];
+ item.id = aNewId;
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalRecurrenceRule.jsm b/comm/calendar/base/src/CalRecurrenceRule.jsm
new file mode 100644
index 0000000000..7d713f2ecf
--- /dev/null
+++ b/comm/calendar/base/src/CalRecurrenceRule.jsm
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalRecurrenceRule"];
+
+const { ICAL, unwrapSetter, unwrapSingle, wrapGetter } = ChromeUtils.import(
+ "resource:///modules/calendar/Ical.jsm"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "CalDateTime", "resource:///modules/CalDateTime.jsm");
+ChromeUtils.defineModuleGetter(lazy, "CalIcalProperty", "resource:///modules/CalICSService.jsm");
+
+function CalRecurrenceRule(innerObject) {
+ this.innerObject = innerObject || new ICAL.Recur();
+ this.wrappedJSObject = this;
+}
+
+var calRecurrenceRuleInterfaces = [Ci.calIRecurrenceRule, Ci.calIRecurrenceItem];
+var calRecurrenceRuleClassID = Components.ID("{df19281a-5389-4146-b941-798cb93a7f0d}");
+CalRecurrenceRule.prototype = {
+ QueryInterface: cal.generateQI(["calIRecurrenceRule", "calIRecurrenceItem"]),
+ classID: calRecurrenceRuleClassID,
+ classInfo: cal.generateCI({
+ contractID: "@mozilla.org/calendar/recurrence-rule;1",
+ classDescription: "Calendar Recurrence Rule",
+ classID: calRecurrenceRuleClassID,
+ interfaces: calRecurrenceRuleInterfaces,
+ }),
+
+ innerObject: null,
+
+ isMutable: true,
+ makeImmutable() {
+ this.isMutable = false;
+ },
+ ensureMutable() {
+ if (!this.isMutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ },
+ clone() {
+ return new CalRecurrenceRule(new ICAL.Recur(this.innerObject));
+ },
+
+ isNegative: false, // We don't support EXRULE anymore
+ get isFinite() {
+ return this.innerObject.isFinite();
+ },
+
+ /**
+ * Tests whether the "FREQ" value for this rule is supported or not. A warning
+ * is logged if an unsupported value ("SECONDLY"|"MINUTELY") is encountered.
+ *
+ * @returns {boolean}
+ */
+ freqSupported() {
+ let { freq } = this.innerObject;
+ if (freq == "SECONDLY" || freq == "MINUTELY") {
+ cal.WARN(
+ `The frequency value "${freq}" is currently not supported. No occurrences will be generated.`
+ );
+ return false;
+ }
+ return true;
+ },
+
+ getNextOccurrence(aStartTime, aRecId) {
+ if (!this.freqSupported()) {
+ return null;
+ }
+ aStartTime = unwrapSingle(ICAL.Time, aStartTime);
+ aRecId = unwrapSingle(ICAL.Time, aRecId);
+ return wrapGetter(lazy.CalDateTime, this.innerObject.getNextOccurrence(aStartTime, aRecId));
+ },
+
+ getOccurrences(aStartTime, aRangeStart, aRangeEnd, aMaxCount) {
+ if (!this.freqSupported()) {
+ return [];
+ }
+ aStartTime = unwrapSingle(ICAL.Time, aStartTime);
+ aRangeStart = unwrapSingle(ICAL.Time, aRangeStart);
+ aRangeEnd = unwrapSingle(ICAL.Time, aRangeEnd);
+
+ if (!aMaxCount && !aRangeEnd && this.count == 0 && this.until == null) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let occurrences = [];
+ let rangeStart = aRangeStart.clone();
+ rangeStart.isDate = false;
+
+ let dtend = null;
+
+ if (aRangeEnd) {
+ dtend = aRangeEnd.clone();
+ dtend.isDate = false;
+
+ // If the start of the recurrence is past the end, we have no dates
+ if (aStartTime.compare(dtend) >= 0) {
+ return [];
+ }
+ }
+
+ let iter = this.innerObject.iterator(aStartTime);
+
+ for (let next = iter.next(); next; next = iter.next()) {
+ let dtNext = next.clone();
+ dtNext.isDate = false;
+
+ if (dtNext.compare(rangeStart) < 0) {
+ continue;
+ }
+
+ if (dtend && dtNext.compare(dtend) >= 0) {
+ break;
+ }
+
+ next = next.clone();
+
+ if (aStartTime.zone) {
+ next.zone = aStartTime.zone;
+ }
+
+ occurrences.push(new lazy.CalDateTime(next));
+
+ if (aMaxCount && occurrences.length >= aMaxCount) {
+ break;
+ }
+ }
+
+ return occurrences;
+ },
+
+ get icalString() {
+ return "RRULE:" + this.innerObject.toString() + ICAL.newLineChar;
+ },
+ set icalString(val) {
+ this.ensureMutable();
+ this.innerObject = ICAL.Recur.fromString(val.replace(/^RRULE:/i, ""));
+ },
+
+ get icalProperty() {
+ let prop = new ICAL.Property("rrule");
+ prop.setValue(this.innerObject);
+ return new lazy.CalIcalProperty(prop);
+ },
+ set icalProperty(rawval) {
+ this.ensureMutable();
+ unwrapSetter(
+ ICAL.Property,
+ rawval,
+ function (val) {
+ this.innerObject = val.getFirstValue();
+ },
+ this
+ );
+ },
+
+ get type() {
+ return this.innerObject.freq;
+ },
+ set type(val) {
+ this.ensureMutable();
+ this.innerObject.freq = val;
+ },
+
+ get interval() {
+ return this.innerObject.interval;
+ },
+ set interval(val) {
+ this.ensureMutable();
+ this.innerObject.interval = val;
+ },
+
+ get count() {
+ if (!this.isByCount) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ return this.innerObject.count || -1;
+ },
+ set count(val) {
+ this.ensureMutable();
+ this.innerObject.count = val && val > 0 ? val : null;
+ },
+
+ get untilDate() {
+ if (this.innerObject.until) {
+ return new lazy.CalDateTime(this.innerObject.until);
+ }
+ return null;
+ },
+ set untilDate(rawval) {
+ this.ensureMutable();
+ unwrapSetter(
+ ICAL.Time,
+ rawval,
+ function (val) {
+ if (
+ val.timezone != ICAL.Timezone.utcTimezone &&
+ val.timezone != ICAL.Timezone.localTimezone
+ ) {
+ val = val.convertToZone(ICAL.Timezone.utcTimezone);
+ }
+
+ this.innerObject.until = val;
+ },
+ this
+ );
+ },
+
+ get isByCount() {
+ return this.innerObject.isByCount();
+ },
+
+ get weekStart() {
+ return this.innerObject.wkst - 1;
+ },
+ set weekStart(val) {
+ this.ensureMutable();
+ this.innerObject.wkst = val + 1;
+ },
+
+ getComponent(aType) {
+ let values = this.innerObject.getComponent(aType);
+ if (aType == "BYDAY") {
+ // BYDAY values are alphanumeric: SU, MO, TU, etc..
+ for (let i = 0; i < values.length; i++) {
+ let match = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/.exec(values[i]);
+ if (!match) {
+ cal.ERROR("Malformed BYDAY rule\n" + cal.STACK(10));
+ return [];
+ }
+ values[i] = ICAL.Recur.icalDayToNumericDay(match[3]);
+ if (match[2]) {
+ // match[2] is the week number for this value.
+ values[i] += 8 * match[2];
+ }
+ if (match[1] == "-") {
+ // Week numbers are counted back from the end of the period.
+ values[i] *= -1;
+ }
+ }
+ }
+
+ return values;
+ },
+
+ setComponent(aType, aValues) {
+ let values = aValues;
+ if (aType == "BYDAY") {
+ // BYDAY values are alphanumeric: SU, MO, TU, etc..
+ for (let i = 0; i < values.length; i++) {
+ let absValue = Math.abs(values[i]);
+ if (absValue > 7) {
+ let ordinal = Math.trunc(values[i] / 8);
+ let day = ICAL.Recur.numericDayToIcalDay(absValue % 8);
+ values[i] = ordinal + day;
+ } else {
+ values[i] = ICAL.Recur.numericDayToIcalDay(values[i]);
+ }
+ }
+ }
+ this.innerObject.setComponent(aType, values);
+ },
+};
diff --git a/comm/calendar/base/src/CalRelation.jsm b/comm/calendar/base/src/CalRelation.jsm
new file mode 100644
index 0000000000..78327d962c
--- /dev/null
+++ b/comm/calendar/base/src/CalRelation.jsm
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalRelation"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Constructor for `calIRelation` objects.
+ *
+ * @class
+ * @implements {calIRelation}
+ * @param {string} [icalString] - Optional iCal string for initializing existing relations.
+ */
+function CalRelation(icalString) {
+ this.wrappedJSObject = this;
+ this.mProperties = new Map();
+ if (icalString) {
+ this.icalString = icalString;
+ }
+}
+CalRelation.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIRelation"]),
+ classID: Components.ID("{76810fae-abad-4019-917a-08e95d5bbd68}"),
+
+ mType: null,
+ mId: null,
+
+ /**
+ * @see calIRelation
+ */
+
+ get relType() {
+ return this.mType;
+ },
+ set relType(aType) {
+ this.mType = aType;
+ },
+
+ get relId() {
+ return this.mId;
+ },
+ set relId(aRelId) {
+ this.mId = aRelId;
+ },
+
+ get icalProperty() {
+ let icalatt = cal.icsService.createIcalProperty("RELATED-TO");
+ if (this.mId) {
+ icalatt.value = this.mId;
+ }
+
+ if (this.mType) {
+ icalatt.setParameter("RELTYPE", this.mType);
+ }
+
+ for (let [key, value] of this.mProperties.entries()) {
+ try {
+ icalatt.setParameter(key, value);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG("Warning: Invalid relation property value " + key + "=" + value);
+ } else {
+ throw e;
+ }
+ }
+ }
+ return icalatt;
+ },
+
+ set icalProperty(attProp) {
+ // Reset the property bag for the parameters, it will be re-initialized
+ // from the ical property.
+ this.mProperties = new Map();
+
+ if (attProp.value) {
+ this.mId = attProp.value;
+ }
+ for (let [name, value] of cal.iterate.icalParameter(attProp)) {
+ if (name == "RELTYPE") {
+ this.mType = value;
+ continue;
+ }
+
+ this.setParameter(name, value);
+ }
+ },
+
+ get icalString() {
+ let comp = this.icalProperty;
+ return comp ? comp.icalString : "";
+ },
+ set icalString(val) {
+ let prop = cal.icsService.createIcalPropertyFromString(val);
+ if (prop.propertyName != "RELATED-TO") {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ this.icalProperty = prop;
+ },
+
+ getParameter(aName) {
+ return this.mProperties.get(aName);
+ },
+
+ setParameter(aName, aValue) {
+ return this.mProperties.set(aName, aValue);
+ },
+
+ deleteParameter(aName) {
+ return this.mProperties.delete(aName);
+ },
+
+ clone() {
+ let newRelation = new CalRelation();
+ newRelation.mId = this.mId;
+ newRelation.mType = this.mType;
+ for (let [name, value] of this.mProperties.entries()) {
+ newRelation.mProperties.set(name, value);
+ }
+ return newRelation;
+ },
+};
diff --git a/comm/calendar/base/src/CalStartupService.jsm b/comm/calendar/base/src/CalStartupService.jsm
new file mode 100644
index 0000000000..04b2a53032
--- /dev/null
+++ b/comm/calendar/base/src/CalStartupService.jsm
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalStartupService"];
+
+/**
+ * Helper function to asynchronously call a certain method on the objects passed
+ * in 'services' in order (i.e wait until the first completes before calling the
+ * second
+ *
+ * @param method The method name to call. Usually startup/shutdown.
+ * @param services The array of service objects to call on.
+ */
+function callOrderedServices(method, services) {
+ let service = services.shift();
+ if (service) {
+ service[method]({
+ onResult() {
+ callOrderedServices(method, services);
+ },
+ });
+ }
+}
+
+function CalStartupService() {
+ this.wrappedJSObject = this;
+ this.setupObservers();
+}
+
+CalStartupService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ classID: Components.ID("{2547331f-34c0-4a4b-b93c-b503538ba6d6}"),
+
+ // Startup Service Methods
+
+ /**
+ * Sets up the needed observers for noticing startup/shutdown
+ */
+ setupObservers() {
+ Services.obs.addObserver(this, "profile-after-change");
+ Services.obs.addObserver(this, "profile-before-change");
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ },
+
+ started: false,
+
+ /**
+ * Gets the startup order of services. This is an array of service objects
+ * that should be called in order at startup.
+ *
+ * @returns The startup order as an array.
+ */
+ getStartupOrder() {
+ let self = this;
+
+ let tzService = Cc["@mozilla.org/calendar/timezone-service;1"]
+ .getService(Ci.calITimezoneService)
+ .QueryInterface(Ci.calIStartupService);
+
+ let calMgr = Cc["@mozilla.org/calendar/manager;1"]
+ .getService(Ci.calICalendarManager)
+ .QueryInterface(Ci.calIStartupService);
+
+ // Localization service
+ let locales = {
+ startup(aCompleteListener) {
+ let packaged = Services.locale.packagedLocales;
+ let fileSrc = new L10nFileSource(
+ "calendar",
+ "app",
+ packaged,
+ "resource:///chrome/{locale}/locale/{locale}/calendar/"
+ );
+ L10nRegistry.getInstance().registerSources([fileSrc]);
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aCompleteListener) {
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ // Notification object
+ let notify = {
+ startup(aCompleteListener) {
+ self.started = true;
+ Services.obs.notifyObservers(null, "calendar-startup-done");
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aCompleteListener) {
+ // Argh, it would have all been so pretty! Since we just reverse
+ // the array, the shutdown notification would happen before the
+ // other shutdown calls. For lack of pretty code, I'm
+ // leaving this out! Users can still listen to xpcom-shutdown.
+ self.started = false;
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ // We need to spin up the timezone service before the calendar manager
+ // to ensure we have the timezones initialized. Make sure "notify" is
+ // last in this array!
+ return [locales, tzService, calMgr, notify];
+ },
+
+ /**
+ * Observer notification callback
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "profile-after-change":
+ callOrderedServices("startup", this.getStartupOrder());
+ break;
+ case "profile-before-change":
+ callOrderedServices("shutdown", this.getStartupOrder().reverse());
+ break;
+ case "xpcom-shutdown":
+ Services.obs.removeObserver(this, "profile-after-change");
+ Services.obs.removeObserver(this, "profile-before-change");
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ break;
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalTimezone.jsm b/comm/calendar/base/src/CalTimezone.jsm
new file mode 100644
index 0000000000..094321fdb3
--- /dev/null
+++ b/comm/calendar/base/src/CalTimezone.jsm
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalTimezone"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "l10nBundle", () => {
+ // Prepare localized timezone display values
+ const bundleURL = "chrome://calendar/locale/timezones.properties";
+ return Services.strings.createBundle(bundleURL);
+});
+
+function CalTimezone(innerObject) {
+ this.innerObject = innerObject || new ICAL.Timezone();
+ this.wrappedJSObject = this;
+}
+
+CalTimezone.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calITimezone"]),
+ classID: Components.ID("{6702eb17-a968-4b43-b562-0d0c5f8e9eb5}"),
+
+ innerObject: null,
+
+ get provider() {
+ return cal.timezoneService;
+ },
+
+ get icalComponent() {
+ let innerComp = this.innerObject.component;
+ let comp = null;
+ if (innerComp) {
+ comp = cal.icsService.createIcalComponent("VTIMEZONE");
+ comp.icalComponent = innerComp;
+ }
+ return comp;
+ },
+
+ get tzid() {
+ return this.innerObject.tzid;
+ },
+
+ get isFloating() {
+ return this.innerObject == ICAL.Timezone.localTimezone;
+ },
+
+ get isUTC() {
+ return this.innerObject == ICAL.Timezone.utcTimezone;
+ },
+
+ get displayName() {
+ // Localization is currently only used for floating/UTC until we have a
+ // better story around timezone localization and display
+ let stringName = "pref.timezone." + this.tzid.replace(/\//g, ".");
+ let displayName = this.tzid;
+
+ try {
+ displayName = lazy.l10nBundle.GetStringFromName(stringName);
+ } catch (e) {
+ // Just use the TZID if the string is missing.
+ }
+
+ this.__defineGetter__("displayName", () => {
+ return displayName;
+ });
+ return displayName;
+ },
+
+ tostring() {
+ return this.innerObject.toString();
+ },
+};
diff --git a/comm/calendar/base/src/CalTimezoneService.jsm b/comm/calendar/base/src/CalTimezoneService.jsm
new file mode 100644
index 0000000000..7973435d7c
--- /dev/null
+++ b/comm/calendar/base/src/CalTimezoneService.jsm
@@ -0,0 +1,228 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalTimezoneService"];
+
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { ICAL, unwrapSingle } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm");
+
+const TIMEZONE_CHANGED_TOPIC = "default-timezone-changed";
+
+// CalTimezoneService acts as an implementation of both ICAL.TimezoneService and
+// the XPCOM calITimezoneService used for providing timezone objects to calendar
+// code.
+function CalTimezoneService() {
+ this.wrappedJSObject = this;
+
+ this._timezoneDatabase = Cc["@mozilla.org/calendar/timezone-database;1"].getService(
+ Ci.calITimezoneDatabase
+ );
+
+ this.mZones = new Map();
+ this.mZoneIds = [];
+
+ ICAL.TimezoneService = this.wrappedJSObject;
+}
+
+var calTimezoneServiceClassID = Components.ID("{e736f2bd-7640-4715-ab35-887dc866c587}");
+var calTimezoneServiceInterfaces = [Ci.calITimezoneService, Ci.calIStartupService];
+CalTimezoneService.prototype = {
+ mDefaultTimezone: null,
+ mVersion: null,
+ mZones: null,
+ mZoneIds: null,
+
+ classID: calTimezoneServiceClassID,
+ QueryInterface: cal.generateQI(["calITimezoneService", "calIStartupService"]),
+ classInfo: cal.generateCI({
+ classID: calTimezoneServiceClassID,
+ contractID: "@mozilla.org/calendar/timezone-service;1",
+ classDescription: "Calendar Timezone Service",
+ interfaces: calTimezoneServiceInterfaces,
+ flags: Ci.nsIClassInfo.SINGLETON,
+ }),
+
+ // ical.js TimezoneService methods
+ has(id) {
+ return this.getTimezone(id) != null;
+ },
+ get(id) {
+ return id ? unwrapSingle(ICAL.Timezone, this.getTimezone(id)) : null;
+ },
+ remove() {},
+ register() {},
+
+ // calIStartupService methods
+ startup(aCompleteListener) {
+ // Fetch list of supported canonical timezone IDs from the backing database
+ this.mZoneIds = this._timezoneDatabase.getCanonicalTimezoneIds();
+
+ // Fetch the version of the backing database
+ this.mVersion = this._timezoneDatabase.version;
+ cal.LOG("[CalTimezoneService] Timezones version " + this.version + " loaded");
+
+ // Set up zones for special values
+ const utc = new CalTimezone(ICAL.Timezone.utcTimezone);
+ this.mZones.set("UTC", utc);
+
+ const floating = new CalTimezone(ICAL.Timezone.localTimezone);
+ this.mZones.set("floating", floating);
+
+ // Initialize default timezone and, if unset, user timezone prefs
+ this._initDefaultTimezone();
+
+ // Watch for changes in system timezone or related user preferences
+ Services.prefs.addObserver("calendar.timezone.useSystemTimezone", this);
+ Services.prefs.addObserver("calendar.timezone.local", this);
+ Services.obs.addObserver(this, TIMEZONE_CHANGED_TOPIC);
+
+ // Notify the startup service that startup is complete
+ if (aCompleteListener) {
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ }
+ },
+
+ shutdown(aCompleteListener) {
+ Services.obs.removeObserver(this, TIMEZONE_CHANGED_TOPIC);
+ Services.prefs.removeObserver("calendar.timezone.local", this);
+ Services.prefs.removeObserver("calendar.timezone.useSystemTimezone", this);
+ aCompleteListener.onResult(null, Cr.NS_OK);
+ },
+
+ // calITimezoneService methods
+ get UTC() {
+ return this.mZones.get("UTC");
+ },
+
+ get floating() {
+ return this.mZones.get("floating");
+ },
+
+ getTimezone(tzid) {
+ if (!tzid) {
+ cal.ERROR("Unknown timezone requested\n" + cal.STACK(10));
+ return null;
+ }
+
+ if (tzid.startsWith("/mozilla.org/")) {
+ // We know that our former tzids look like "/mozilla.org/<dtstamp>/continent/..."
+ // The ending of the mozilla prefix is the index of that slash before the
+ // continent. Therefore, we start looking for the prefix-ending slash
+ // after position 13.
+ tzid = tzid.substring(tzid.indexOf("/", 13) + 1);
+ }
+
+ // Per the IANA timezone database, "Z" is _not_ an alias for UTC, but our
+ // previous list of zones included it and Ical.js at a minimum is expecting
+ // it to be valid
+ if (tzid === "Z") {
+ return this.mZones.get("UTC");
+ }
+
+ // First check our cache of timezones
+ let timezone = this.mZones.get(tzid);
+ if (!timezone) {
+ // The requested timezone is not in the cache; ask the backing database
+ // for the timezone definition
+ const tzdef = this._timezoneDatabase.getTimezoneDefinition(tzid);
+
+ if (!tzdef) {
+ cal.ERROR(`Could not find definition for ${tzid}`);
+ return null;
+ }
+
+ timezone = new CalTimezone(
+ ICAL.Timezone.fromData({
+ tzid,
+ component: tzdef,
+ })
+ );
+
+ // Cache the resulting timezone
+ this.mZones.set(tzid, timezone);
+ }
+
+ return timezone;
+ },
+
+ get timezoneIds() {
+ return this.mZoneIds;
+ },
+
+ get version() {
+ return this.mVersion;
+ },
+
+ _initDefaultTimezone() {
+ // If the "use system timezone" preference is unset, we default to enabling
+ // it if the user's system supports it
+ let isSetSystemTimezonePref = Services.prefs.prefHasUserValue(
+ "calendar.timezone.useSystemTimezone"
+ );
+
+ if (!isSetSystemTimezonePref) {
+ let canUseSystemTimezone = AppConstants.MOZ_CAN_FOLLOW_SYSTEM_TIME;
+
+ Services.prefs.setBoolPref("calendar.timezone.useSystemTimezone", canUseSystemTimezone);
+ }
+
+ this._updateDefaultTimezone();
+ },
+
+ _updateDefaultTimezone() {
+ let prefUseSystemTimezone = Services.prefs.getBoolPref(
+ "calendar.timezone.useSystemTimezone",
+ true
+ );
+ let prefTzid = Services.prefs.getStringPref("calendar.timezone.local", null);
+
+ let tzid;
+ if (prefUseSystemTimezone || prefTzid === null || prefTzid === "floating") {
+ // If we do not have a timezone preference set, we default to using the
+ // system time; we may also do this if the user has set their preferences
+ // accordingly
+ tzid = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } else {
+ tzid = prefTzid;
+ }
+
+ // Update default timezone and preference if necessary
+ if (!this.mDefaultTimezone || this.mDefaultTimezone.tzid != tzid) {
+ this.mDefaultTimezone = this.getTimezone(tzid);
+ cal.ASSERT(this.mDefaultTimezone, `Timezone not found: ${tzid}`);
+ Services.obs.notifyObservers(null, "defaultTimezoneChanged");
+
+ if (this.mDefaultTimezone.tzid != prefTzid) {
+ Services.prefs.setStringPref("calendar.timezone.local", this.mDefaultTimezone.tzid);
+ }
+ }
+ },
+
+ get defaultTimezone() {
+ // We expect this to be initialized when the service comes up and updated if
+ // the underlying default changes
+ return this.mDefaultTimezone;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ // Update the default timezone if the system timezone has changed; we
+ // expect the update function to decide if actually making the change is
+ // appropriate based on user prefs
+ if (aTopic == TIMEZONE_CHANGED_TOPIC) {
+ this._updateDefaultTimezone();
+ } else if (
+ aTopic == "nsPref:changed" &&
+ (aData == "calendar.timezone.useSystemTimezone" || aData == "calendar.timezone.local")
+ ) {
+ // We may get a bogus second update from the timezone pref if its change
+ // is a result of the system timezone changing, but it should settle, and
+ // trying to guard against it is full of corner cases
+ this._updateDefaultTimezone();
+ }
+ },
+};
diff --git a/comm/calendar/base/src/CalTodo.jsm b/comm/calendar/base/src/CalTodo.jsm
new file mode 100644
index 0000000000..50d2f42fd1
--- /dev/null
+++ b/comm/calendar/base/src/CalTodo.jsm
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from calItemBase.js */
+
+var EXPORTED_SYMBOLS = ["CalTodo"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+Services.scriptloader.loadSubScript("resource:///components/calItemBase.js");
+
+/**
+ * Constructor for `calITodo` objects.
+ *
+ * @class
+ * @implements {calITodo}
+ * @param {string} [icalString] - Optional iCal string for initializing existing todos.
+ */
+function CalTodo(icalString) {
+ this.initItemBase();
+
+ this.todoPromotedProps = {
+ DTSTART: true,
+ DTEND: true,
+ DUE: true,
+ COMPLETED: true,
+ __proto__: this.itemBasePromotedProps,
+ };
+
+ if (icalString) {
+ this.icalString = icalString;
+ }
+
+ // Set a default percentComplete if the icalString didn't already set it.
+ if (!this.percentComplete) {
+ this.percentComplete = 0;
+ }
+}
+
+var calTodoClassID = Components.ID("{7af51168-6abe-4a31-984d-6f8a3989212d}");
+var calTodoInterfaces = [Ci.calIItemBase, Ci.calITodo, Ci.calIInternalShallowCopy];
+CalTodo.prototype = {
+ __proto__: calItemBase.prototype,
+
+ classID: calTodoClassID,
+ QueryInterface: cal.generateQI(["calIItemBase", "calITodo", "calIInternalShallowCopy"]),
+ classInfo: cal.generateCI({
+ classID: calTodoClassID,
+ contractID: "@mozilla.org/calendar/todo;1",
+ classDescription: "Calendar Todo",
+ interfaces: calTodoInterfaces,
+ }),
+
+ cloneShallow(aNewParent) {
+ let cloned = new CalTodo();
+ this.cloneItemBaseInto(cloned, aNewParent);
+ return cloned;
+ },
+
+ createProxy(aRecurrenceId) {
+ cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true);
+
+ let proxy = new CalTodo();
+
+ // override proxy's DTSTART/DUE/RECURRENCE-ID
+ // before master is set (and item might get immutable):
+ let duration = this.duration;
+ if (duration) {
+ let dueDate = aRecurrenceId.clone();
+ dueDate.addDuration(duration);
+ proxy.dueDate = dueDate;
+ }
+ proxy.entryDate = aRecurrenceId;
+
+ proxy.initializeProxy(this, aRecurrenceId);
+ proxy.mDirty = false;
+
+ return proxy;
+ },
+
+ makeImmutable() {
+ this.makeItemBaseImmutable();
+ },
+
+ isTodo() {
+ return true;
+ },
+
+ get isCompleted() {
+ return this.completedDate != null || this.percentComplete == 100 || this.status == "COMPLETED";
+ },
+
+ set isCompleted(completed) {
+ if (completed) {
+ if (!this.completedDate) {
+ this.completedDate = cal.dtz.jsDateToDateTime(new Date());
+ }
+ this.status = "COMPLETED";
+ this.percentComplete = 100;
+ } else {
+ this.deleteProperty("COMPLETED");
+ this.deleteProperty("STATUS");
+ this.deleteProperty("PERCENT-COMPLETE");
+ }
+ },
+
+ get duration() {
+ let dur = this.getProperty("DURATION");
+ // pick up duration if available, otherwise calculate difference
+ // between start and enddate
+ if (dur) {
+ return cal.createDuration(dur);
+ }
+ if (!this.entryDate || !this.dueDate) {
+ return null;
+ }
+ return this.dueDate.subtractDate(this.entryDate);
+ },
+
+ set duration(value) {
+ this.setProperty("DURATION", value);
+ },
+
+ get recurrenceStartDate() {
+ // DTSTART is optional for VTODOs, so it's unclear if RRULE is allowed then,
+ // so fallback to DUE if no DTSTART is present:
+ return this.entryDate || this.dueDate;
+ },
+
+ icsEventPropMap: [
+ { cal: "DTSTART", ics: "startTime" },
+ { cal: "DUE", ics: "dueTime" },
+ { cal: "COMPLETED", ics: "completedTime" },
+ ],
+
+ set icalString(value) {
+ this.icalComponent = cal.icsService.parseICS(value);
+ },
+
+ get icalString() {
+ let calcomp = cal.icsService.createIcalComponent("VCALENDAR");
+ cal.item.setStaticProps(calcomp);
+ calcomp.addSubcomponent(this.icalComponent);
+ return calcomp.serializeToICS();
+ },
+
+ get icalComponent() {
+ let icalcomp = cal.icsService.createIcalComponent("VTODO");
+ this.fillIcalComponentFromBase(icalcomp);
+ this.mapPropsToICS(icalcomp, this.icsEventPropMap);
+
+ for (let [name, value] of this.properties) {
+ try {
+ // When deleting a property of an occurrence, the property is not actually deleted
+ // but instead set to null, so we need to prevent adding those properties.
+ let wasReset = this.mIsProxy && value === null;
+ if (!this.todoPromotedProps[name] && !wasReset) {
+ let icalprop = cal.icsService.createIcalProperty(name);
+ icalprop.value = value;
+ let propBucket = this.mPropertyParams[name];
+ if (propBucket) {
+ for (let paramName in propBucket) {
+ try {
+ icalprop.setParameter(paramName, propBucket[paramName]);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ // Illegal values should be ignored, but we could log them if
+ // the user has enabled logging.
+ cal.LOG(
+ "Warning: Invalid todo parameter value " +
+ paramName +
+ "=" +
+ propBucket[paramName]
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ icalcomp.addProperty(icalprop);
+ }
+ } catch (e) {
+ cal.ERROR("failed to set " + name + " to " + value + ": " + e + "\n");
+ }
+ }
+ return icalcomp;
+ },
+
+ todoPromotedProps: null,
+
+ set icalComponent(todo) {
+ this.modify();
+ if (todo.componentType != "VTODO") {
+ todo = todo.getFirstSubcomponent("VTODO");
+ if (!todo) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+
+ this.mDueDate = undefined;
+ this.setItemBaseFromICS(todo);
+ this.mapPropsFromICS(todo, this.icsEventPropMap);
+
+ this.importUnpromotedProperties(todo, this.todoPromotedProps);
+ // Importing didn't really change anything
+ this.mDirty = false;
+ },
+
+ isPropertyPromoted(name) {
+ // avoid strict undefined property warning
+ return this.todoPromotedProps[name] || false;
+ },
+
+ set entryDate(value) {
+ this.modify();
+
+ // We're about to change the start date of an item which probably
+ // could break the associated calIRecurrenceInfo. We're calling
+ // the appropriate method here to adjust the internal structure in
+ // order to free clients from worrying about such details.
+ if (this.parentItem == this) {
+ let rec = this.recurrenceInfo;
+ if (rec) {
+ rec.onStartDateChange(value, this.entryDate);
+ }
+ }
+
+ this.setProperty("DTSTART", value);
+ },
+
+ get entryDate() {
+ return this.getProperty("DTSTART");
+ },
+
+ mDueDate: undefined,
+ get dueDate() {
+ let dueDate = this.mDueDate;
+ if (dueDate === undefined) {
+ dueDate = this.getProperty("DUE");
+ if (!dueDate) {
+ let entryDate = this.entryDate;
+ let dur = this.getProperty("DURATION");
+ if (entryDate && dur) {
+ // If there is a duration set on the todo, calculate the right end time.
+ dueDate = entryDate.clone();
+ dueDate.addDuration(cal.createDuration(dur));
+ }
+ }
+ this.mDueDate = dueDate;
+ }
+ return dueDate;
+ },
+
+ set dueDate(value) {
+ this.deleteProperty("DURATION"); // setting dueDate once removes DURATION
+ this.setProperty("DUE", value);
+ this.mDueDate = value;
+ },
+};
+
+makeMemberAttrProperty(CalTodo, "COMPLETED", "completedDate");
+makeMemberAttrProperty(CalTodo, "PERCENT-COMPLETE", "percentComplete");
diff --git a/comm/calendar/base/src/CalTransactionManager.jsm b/comm/calendar/base/src/CalTransactionManager.jsm
new file mode 100644
index 0000000000..67f01733ec
--- /dev/null
+++ b/comm/calendar/base/src/CalTransactionManager.jsm
@@ -0,0 +1,372 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "CalTransactionManager",
+ "CalTransaction",
+ "CalBatchTransaction",
+ "CalAddTransaction",
+ "CalModifyTransaction",
+ "CalDeleteTransaction",
+];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const OP_ADD = Ci.calIOperationListener.ADD;
+const OP_MODIFY = Ci.calIOperationListener.MODIFY;
+const OP_DELETE = Ci.calIOperationListener.DELETE;
+
+let transactionManager = null;
+
+/**
+ * CalTransactionManager is used to track user initiated operations on calendar
+ * items. These transactions can be undone or repeated when appropriate.
+ *
+ * This implementation is used instead of nsITransactionManager because better
+ * support for async transactions and access to batch transactions is needed
+ * which nsITransactionManager does not provide.
+ */
+class CalTransactionManager {
+ /**
+ * Contains transactions executed by the transaction manager than can be
+ * undone.
+ *
+ * @type {CalTransaction}
+ */
+ undoStack = [];
+
+ /**
+ * Contains transactions that have been undone by the transaction manager and
+ * can be redone again later if desired.
+ *
+ * @type {CalTransaction}
+ */
+ redoStack = [];
+
+ /**
+ * Provides a singleton instance of the CalTransactionManager.
+ *
+ * @returns {CalTransactionManager}
+ */
+ static getInstance() {
+ if (!transactionManager) {
+ transactionManager = new CalTransactionManager();
+ }
+ return transactionManager;
+ }
+
+ /**
+ * @typedef {object} ExtResponse
+ * @property {number} responseMode One of the calIItipItem.autoResponse values.
+ */
+
+ /**
+ * @typedef {"add" | "modify" | "delete"} Action
+ */
+
+ /**
+ * Adds a CalTransaction to the internal stack. The transaction will be
+ * executed and its resulting Promise returned.
+ *
+ * @param {CalTransaction} trn - The CalTransaction to add to the stack and
+ * execute.
+ */
+ async commit(trn) {
+ this.undoStack.push(trn);
+ return trn.doTransaction();
+ }
+
+ /**
+ * Creates and pushes a new CalBatchTransaction onto the internal stack.
+ * The created transaction is returned and can be used to combine multiple
+ * transactions into one.
+ *
+ * @returns {CalBatchTrasaction}
+ */
+ beginBatch() {
+ let trn = new CalBatchTransaction();
+ this.undoStack.push(trn);
+ return trn;
+ }
+
+ /**
+ * peekUndoStack provides the top transaction on the undo stack (if any)
+ * without modifying the stack.
+ *
+ * @returns {CalTransaction?}
+ */
+ peekUndoStack() {
+ return this.undoStack.at(-1);
+ }
+
+ /**
+ * Undo the transaction at the top of the undo stack.
+ *
+ * @throws - NS_ERROR_FAILURE if the undo stack is empty.
+ */
+ async undo() {
+ if (!this.undoStack.length) {
+ throw new Components.Exception(
+ "CalTransactionManager: undo stack is empty!",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ let trn = this.undoStack.pop();
+ this.redoStack.push(trn);
+ return trn.undoTransaction();
+ }
+
+ /**
+ * Returns true if it is possible to undo the transaction at the top of the
+ * undo stack.
+ *
+ * @returns {boolean}
+ */
+ canUndo() {
+ let trn = this.peekUndoStack();
+ return Boolean(trn?.canWrite());
+ }
+
+ /**
+ * peekRedoStack provides the top transaction on the redo stack (if any)
+ * without modifying the stack.
+ *
+ * @returns {CalTransaction?}
+ */
+ peekRedoStack() {
+ return this.redoStack.at(-1);
+ }
+
+ /**
+ * Redo the transaction at the top of the redo stack.
+ *
+ * @throws - NS_ERROR_FAILURE if the redo stack is empty.
+ */
+ async redo() {
+ if (!this.redoStack.length) {
+ throw new Components.Exception(
+ "CalTransactionManager: redo stack is empty!",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ let trn = this.redoStack.pop();
+ this.undoStack.push(trn);
+ return trn.doTransaction();
+ }
+
+ /**
+ * Returns true if it is possible to redo the transaction at the top of the
+ * redo stack.
+ *
+ * @returns {boolean}
+ */
+ canRedo() {
+ let trn = this.peekRedoStack();
+ return Boolean(trn?.canWrite());
+ }
+}
+
+/**
+ * CalTransaction represents a single, atomic user operation on one or more
+ * calendar items.
+ */
+class CalTransaction {
+ /**
+ * Indicates whether the calendar of the transaction's target item(s) can be
+ * written to.
+ *
+ * @returns {boolean}
+ */
+ canWrite() {
+ return false;
+ }
+
+ /**
+ * Executes the transaction.
+ */
+ async doTransaction() {}
+
+ /**
+ * Executes the "undo" action of the transaction.
+ */
+ async undoTransaction() {}
+}
+
+/**
+ * CalBatchTransaction is used for batch transactions where multiple transactions
+ * treated as one is desired. For example; where the user selects and deletes
+ * more than one event.
+ */
+class CalBatchTransaction extends CalTransaction {
+ /**
+ * Stores the transactions that belong to the batch.
+ *
+ * @type {CalTransaction[]}
+ */
+ transactions = [];
+
+ /**
+ * Similar to the CalTransactionManager method except the transaction will be
+ * added to the batch.
+ */
+ async commit(trn) {
+ this.transactions.push(trn);
+ return trn.doTransaction();
+ }
+
+ canWrite() {
+ return Boolean(this.transactions.length && this.transactions.every(trn => trn.canWrite()));
+ }
+
+ async doTransaction() {
+ for (let trn of this.transactions) {
+ await trn.doTransaction();
+ }
+ }
+
+ async undoTransaction() {
+ for (let trn of this.transactions.slice().reverse()) {
+ await trn.undoTransaction();
+ }
+ }
+}
+
+/**
+ * CalBaseTransaction serves as the base for add/modify/delete operations.
+ */
+class CalBaseTransaction extends CalTransaction {
+ /**
+ * @type {calICalendar}
+ */
+ calendar = null;
+
+ /**
+ * @type {calIItemBase}
+ */
+ item = null;
+
+ /**
+ * @type {calIItemBase}
+ */
+ oldItem = null;
+
+ /**
+ * @type {calICalendar}
+ */
+ oldCalendar = null;
+
+ /**
+ * @type {ExtResponse}
+ */
+ extResponse = null;
+
+ /**
+ * @private
+ * @param {calIItemBase} item
+ * @param {calICalendar} calendar
+ * @param {calIItemBase?} oldItem
+ * @param {object?} extResponse
+ */
+ constructor(item, calendar, oldItem, extResponse) {
+ super();
+ this.item = item;
+ this.calendar = calendar;
+ this.oldItem = oldItem;
+ this.extResponse = extResponse;
+ }
+
+ _dispatch(opType, item, oldItem) {
+ cal.itip.checkAndSend(opType, item, oldItem, this.extResponse);
+ }
+
+ canWrite() {
+ if (itemWritable(this.item)) {
+ return this instanceof CalModifyTransaction ? itemWritable(this.oldItem) : true;
+ }
+ return false;
+ }
+}
+
+/**
+ * CalAddTransaction handles additions.
+ */
+class CalAddTransaction extends CalBaseTransaction {
+ async doTransaction() {
+ let item = await this.calendar.addItem(this.item);
+ this._dispatch(OP_ADD, item, this.oldItem);
+ this.item = item;
+ }
+
+ async undoTransaction() {
+ await this.calendar.deleteItem(this.item);
+ this._dispatch(OP_DELETE, this.item, this.item);
+ this.oldItem = this.item;
+ }
+}
+
+/**
+ * CalModifyTransaction handles modifications.
+ */
+class CalModifyTransaction extends CalBaseTransaction {
+ async doTransaction() {
+ let item;
+ if (this.item.calendar.id == this.oldItem.calendar.id) {
+ item = await this.calendar.modifyItem(
+ cal.itip.prepareSequence(this.item, this.oldItem),
+ this.oldItem
+ );
+ this._dispatch(OP_MODIFY, item, this.oldItem);
+ } else {
+ this.oldCalendar = this.oldItem.calendar;
+ item = await this.calendar.addItem(this.item);
+ this._dispatch(OP_ADD, item, this.oldItem);
+ await this.oldItem.calendar.deleteItem(this.oldItem);
+ this._dispatch(OP_DELETE, this.oldItem, this.oldItem);
+ }
+ this.item = item;
+ }
+
+ async undoTransaction() {
+ if (this.oldItem.calendar.id == this.item.calendar.id) {
+ await this.calendar.modifyItem(cal.itip.prepareSequence(this.oldItem, this.item), this.item);
+ this._dispatch(OP_MODIFY, this.oldItem, this.oldItem);
+ } else {
+ await this.calendar.deleteItem(this.item);
+ this._dispatch(OP_DELETE, this.item, this.item);
+ await this.oldCalendar.addItem(this.oldItem);
+ this._dispatch(OP_ADD, this.oldItem, this.item);
+ }
+ }
+}
+
+/**
+ * CalDeleteTransaction handles deletions.
+ */
+class CalDeleteTransaction extends CalBaseTransaction {
+ async doTransaction() {
+ await this.calendar.deleteItem(this.item);
+ this._dispatch(OP_DELETE, this.item, this.oldItem);
+ }
+
+ async undoTransaction() {
+ await this.calendar.addItem(this.item);
+ this._dispatch(OP_ADD, this.item, this.item);
+ }
+}
+
+/**
+ * Checks whether an item's calendar can be written to.
+ *
+ * @param {calIItemBase} item
+ */
+function itemWritable(item) {
+ return (
+ item &&
+ item.calendar &&
+ cal.acl.isCalendarWritable(item.calendar) &&
+ cal.acl.userCanAddItemsToCalendar(item.calendar)
+ );
+}
diff --git a/comm/calendar/base/src/CalWeekInfoService.jsm b/comm/calendar/base/src/CalWeekInfoService.jsm
new file mode 100644
index 0000000000..a94145f44e
--- /dev/null
+++ b/comm/calendar/base/src/CalWeekInfoService.jsm
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalWeekInfoService"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const SUNDAY = 0;
+const THURSDAY = 4;
+
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "startWeekday", "calendar.week.start", SUNDAY);
+
+function CalWeekInfoService() {
+ this.wrappedJSObject = this;
+}
+CalWeekInfoService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIWeekInfoService"]),
+ classID: Components.ID("{6877bbdd-f336-46f5-98ce-fe86d0285cc1}"),
+
+ // calIWeekInfoService:
+ getWeekTitle(aDateTime) {
+ /**
+ * This implementation is based on the ISO 8601 standard.
+ * ISO 8601 defines week one as the first week with at least 4
+ * days, and defines Monday as the first day of the week.
+ * Equivalently, the week one is the week with the first Thursday.
+ *
+ * This implementation uses the second definition, because it
+ * enables the user to set a different start-day of the week
+ * (Sunday instead of Monday is a common setting). If the first
+ * definition was used, all week-numbers could be off by one
+ * depending on the week start day. (For example, if weeks start
+ * on Sunday, a year that starts on Thursday has only 3 days
+ * [Thu-Sat] in that week, so it would be part of the last week of
+ * the previous year, but if weeks start on Monday, the year would
+ * have four days [Thu-Sun] in that week, so it would be counted
+ * as week 1.)
+ */
+
+ // The week number is the number of days since the start of week 1,
+ // divided by 7 and rounded up. Week 1 is the week containing the first
+ // Thursday of the year.
+ // Thus, the week number of any day is the same as the number of days
+ // between the Thursday of that week and the Thursday of week 1, divided
+ // by 7 and rounded up. (This takes care of days at end/start of a year
+ // which may be part of first/last week in the other year.)
+ // The Thursday of a week is the Thursday that follows the first day
+ // of the week.
+ // The week number of a day is the same as the week number of the first
+ // day of the week. (This takes care of days near the start of the year,
+ // which may be part of the week counted in the previous year.) So we
+ // need the startWeekday.
+
+ // The number of days since the start of the week.
+ // Notice that the result of the subtraction might be negative.
+ // We correct for that by adding 7, and then using the remainder operator.
+ let sinceStartOfWeek = (aDateTime.weekday - lazy.startWeekday + 7) % 7;
+
+ // The number of days to Thursday is the difference between Thursday
+ // and the start-day of the week (again corrected for negative values).
+ let startToThursday = (THURSDAY - lazy.startWeekday + 7) % 7;
+
+ // The yearday number of the Thursday this week.
+ let thisWeeksThursday = aDateTime.yearday - sinceStartOfWeek + startToThursday;
+
+ if (thisWeeksThursday < 1) {
+ // For the first few days of the year, we still are in week 52 or 53.
+ let lastYearDate = aDateTime.clone();
+ lastYearDate.year -= 1;
+ thisWeeksThursday += lastYearDate.endOfYear.yearday;
+ } else if (thisWeeksThursday > aDateTime.endOfYear.yearday) {
+ // For the last few days of the year, we already are in week 1.
+ thisWeeksThursday -= aDateTime.endOfYear.yearday;
+ }
+
+ let weekNumber = Math.ceil(thisWeeksThursday / 7);
+ return weekNumber;
+ },
+
+ /**
+ * gets the first day of a week of a passed day under consideration
+ * of the preference setting "calendar.week.start"
+ *
+ * @param aDate a date time object
+ * @returns a dateTime-object denoting the first day of the week
+ */
+ getStartOfWeek(aDate) {
+ let date = aDate.clone();
+ date.isDate = true;
+ let offset = lazy.startWeekday - aDate.weekday;
+ date.day += offset;
+ if (offset > 0) {
+ date.day -= 7;
+ }
+ return date;
+ },
+
+ /**
+ * gets the last day of a week of a passed day under consideration
+ * of the preference setting "calendar.week.start"
+ *
+ * @param aDate a date time object
+ * @returns a dateTime-object denoting the last day of the week
+ */
+ getEndOfWeek(aDate) {
+ let date = this.getStartOfWeek(aDate);
+ date.day += 6;
+ return date;
+ },
+};
diff --git a/comm/calendar/base/src/TimezoneDatabase.cpp b/comm/calendar/base/src/TimezoneDatabase.cpp
new file mode 100644
index 0000000000..d93f78e1f4
--- /dev/null
+++ b/comm/calendar/base/src/TimezoneDatabase.cpp
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsPrintfCString.h"
+#include "nsString.h"
+#include "unicode/strenum.h"
+#include "unicode/timezone.h"
+#include "unicode/ucal.h"
+#include "unicode/utypes.h"
+#include "unicode/vtzone.h"
+
+#include "TimezoneDatabase.h"
+
+NS_IMPL_ISUPPORTS(TimezoneDatabase, calITimezoneDatabase)
+
+NS_IMETHODIMP
+TimezoneDatabase::GetVersion(nsACString& aVersion) {
+ UErrorCode err = U_ZERO_ERROR;
+ const char* version = icu::VTimeZone::getTZDataVersion(err);
+ if (U_FAILURE(err)) {
+ NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get());
+ return NS_ERROR_FAILURE;
+ }
+
+ aVersion.Assign(version);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+TimezoneDatabase::GetCanonicalTimezoneIds(nsTArray<nsCString>& aTimezoneIds) {
+ aTimezoneIds.Clear();
+
+ UErrorCode err = U_ZERO_ERROR;
+
+ // Because this list of IDs is not intended to be restrictive, we only request
+ // the canonical IDs to avoid providing lots of redundant options to users
+ icu::StringEnumeration* icuEnum = icu::VTimeZone::createTimeZoneIDEnumeration(
+ UCAL_ZONE_TYPE_CANONICAL, nullptr, nullptr, err);
+ if (U_FAILURE(err)) {
+ NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get());
+ return NS_ERROR_FAILURE;
+ }
+
+ const char* value;
+ err = U_ZERO_ERROR;
+ while ((value = icuEnum->next(nullptr, err)) != nullptr && U_SUCCESS(err)) {
+ nsCString tzid(value);
+ aTimezoneIds.AppendElement(tzid);
+ }
+
+ if (U_FAILURE(err)) {
+ // If we encountered any error during enumeration of the timezones, we want
+ // to return an empty list
+ aTimezoneIds.Clear();
+
+ NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get());
+ return NS_ERROR_FAILURE;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+TimezoneDatabase::GetTimezoneDefinition(const nsACString& tzid,
+ nsACString& _retval) {
+ _retval.Truncate();
+
+ NS_ConvertUTF8toUTF16 convertedTzid(tzid);
+
+ // It seems Windows can potentially build `convertedTzid` with wchar_t
+ // underlying, which makes the UnicodeString ctor ambiguous; be explicit here
+ const char16_t* convertedTzidPtr = convertedTzid.get();
+
+ icu::UnicodeString icuTzid(convertedTzidPtr,
+ static_cast<int>(convertedTzid.Length()));
+
+ auto* icuTimezone = icu::VTimeZone::createVTimeZoneByID(icuTzid);
+ if (icuTimezone == nullptr) {
+ return NS_OK;
+ }
+
+ // Work around https://unicode-org.atlassian.net/browse/ICU-22175
+ // This workaround is overly complex because there's no simple, reliable way
+ // to determine if a VTimeZone is Etc/Unknown; getID() doesn't work because
+ // the ctor doesn't set the ID field, and hasSameRules() against Etc/Unknown
+ // will return true if icuTimezone is GMT
+ if (icuTimezone->hasSameRules(icu::TimeZone::getUnknown()) &&
+ !tzid.Equals("Etc/Unknown")) {
+ icu::UnicodeString actualTzid;
+ icu::TimeZone::createTimeZone(icuTzid)->getID(actualTzid);
+
+ if (actualTzid == UNICODE_STRING("Etc/Unknown", 11)) {
+ return NS_OK;
+ }
+ }
+
+ // Extract the VTIMEZONE definition from the timezone object
+ icu::UnicodeString vtimezoneDef;
+ UErrorCode err = U_ZERO_ERROR;
+ icuTimezone->write(vtimezoneDef, err);
+ if (U_FAILURE(err)) {
+ NS_WARNING(nsPrintfCString("ICU error: %s", u_errorName(err)).get());
+
+ return NS_ERROR_FAILURE;
+ }
+
+ NS_ConvertUTF16toUTF8 convertedDef(vtimezoneDef.getTerminatedBuffer());
+
+ _retval.Assign(convertedDef);
+
+ return NS_OK;
+}
diff --git a/comm/calendar/base/src/TimezoneDatabase.h b/comm/calendar/base/src/TimezoneDatabase.h
new file mode 100644
index 0000000000..df3f782309
--- /dev/null
+++ b/comm/calendar/base/src/TimezoneDatabase.h
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+#ifndef mozilla_calTimezoneDatabase_h__
+#define mozilla_calTimezoneDatabase_h__
+
+#include "calITimezoneDatabase.h"
+
+class TimezoneDatabase final : public calITimezoneDatabase {
+ NS_DECL_ISUPPORTS
+ NS_DECL_CALITIMEZONEDATABASE
+
+ public:
+ TimezoneDatabase() = default;
+
+ private:
+ ~TimezoneDatabase() = default;
+};
+
+#endif
diff --git a/comm/calendar/base/src/calApplicationUtils.js b/comm/calendar/base/src/calApplicationUtils.js
new file mode 100644
index 0000000000..47668a099f
--- /dev/null
+++ b/comm/calendar/base/src/calApplicationUtils.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported launchBrowser */
+
+/**
+ * Launch the given url (string) in the external browser. If an event is passed,
+ * then this is only done on left click and the event propagation is stopped.
+ *
+ * @param url The URL to open, as a string
+ * @param event (optional) The event that caused the URL to open
+ */
+function launchBrowser(url, event) {
+ // Bail out if there is no url set, or an event was passed without left-click
+ if (!url || (event && event.button != 0)) {
+ return;
+ }
+
+ // 0. Prevent people from trying to launch URLs such as javascript:foo();
+ // by only allowing URLs starting with http or https or mid.
+ // XXX: We likely will want to do this using nsIURLs in the future to
+ // prevent sneaky nasty escaping issues, but this is fine for now.
+ if (!/^https?:/i.test(url) && !/^mid:/i.test(url)) {
+ console.error(
+ "launchBrowser: Invalid URL provided: " + url + " Only http(s):// and mid:// URLs are valid."
+ );
+ return;
+ }
+
+ if (/^mid:/i.test(url)) {
+ let { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm");
+ MailUtils.openMessageByMessageId(url.slice(4));
+ return;
+ }
+
+ Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .loadURI(Services.io.newURI(url));
+
+ // Make sure that any default click handlers don't do anything, we have taken
+ // care of all processing
+ if (event) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+}
diff --git a/comm/calendar/base/src/calCachedCalendar.js b/comm/calendar/base/src/calCachedCalendar.js
new file mode 100644
index 0000000000..3dd2d872a4
--- /dev/null
+++ b/comm/calendar/base/src/calCachedCalendar.js
@@ -0,0 +1,957 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["calCachedCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var calICalendar = Ci.calICalendar;
+var cICL = Ci.calIChangeLog;
+var cIOL = Ci.calIOperationListener;
+
+var gNoOpListener = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ onGetResult(calendar, status, itemType, detail, items) {},
+
+ onOperationComplete(calendar, status, opType, id, detail) {},
+};
+
+/**
+ * Returns true if the exception passed is one that should cause the cache
+ * layer to retry the operation. This is usually a network error or other
+ * temporary error.
+ *
+ * @param result The result code to check.
+ * @returns True, if the result code means server unavailability.
+ */
+function isUnavailableCode(result) {
+ // Stolen from nserror.h
+ const NS_ERROR_MODULE_NETWORK = 6;
+ function NS_ERROR_GET_MODULE(code) {
+ return ((code >> 16) - 0x45) & 0x1fff;
+ }
+
+ if (NS_ERROR_GET_MODULE(result) == NS_ERROR_MODULE_NETWORK && !Components.isSuccessCode(result)) {
+ // This is a network error, which most likely means we should
+ // retry it some time.
+ return true;
+ }
+
+ // Other potential errors we want to retry with
+ switch (result) {
+ case Cr.NS_ERROR_NOT_AVAILABLE:
+ return true;
+ default:
+ return false;
+ }
+}
+
+function calCachedCalendarObserverHelper(home, isCachedObserver) {
+ this.home = home;
+ this.isCachedObserver = isCachedObserver;
+}
+calCachedCalendarObserverHelper.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ isCachedObserver: false,
+
+ onStartBatch() {
+ this.home.mObservers.notify("onStartBatch", [this.home]);
+ },
+
+ onEndBatch() {
+ this.home.mObservers.notify("onEndBatch", [this.home]);
+ },
+
+ async onLoad(calendar) {
+ if (this.isCachedObserver) {
+ this.home.mObservers.notify("onLoad", [this.home]);
+ } else {
+ // start sync action after uncached calendar has been loaded.
+ // xxx todo, think about:
+ // although onAddItem et al have been called, we need to fire
+ // an additional onLoad completing the refresh call (->composite)
+ let home = this.home;
+ await home.synchronize();
+ home.mObservers.notify("onLoad", [home]);
+ }
+ },
+
+ onAddItem(aItem) {
+ if (this.isCachedObserver) {
+ this.home.mObservers.notify("onAddItem", arguments);
+ }
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ if (this.isCachedObserver) {
+ this.home.mObservers.notify("onModifyItem", arguments);
+ }
+ },
+
+ onDeleteItem(aItem) {
+ if (this.isCachedObserver) {
+ this.home.mObservers.notify("onDeleteItem", arguments);
+ }
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.home.mObservers.notify("onError", arguments);
+ },
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ if (!this.isCachedObserver) {
+ this.home.mObservers.notify("onPropertyChanged", [this.home, aName, aValue, aOldValue]);
+ }
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ if (!this.isCachedObserver) {
+ this.home.mObservers.notify("onPropertyDeleting", [this.home, aName]);
+ }
+ },
+};
+
+function calCachedCalendar(uncachedCalendar) {
+ this.wrappedJSObject = this;
+ this.mSyncQueue = [];
+ this.mObservers = new cal.data.ObserverSet(Ci.calIObserver);
+ uncachedCalendar.superCalendar = this;
+ uncachedCalendar.addObserver(new calCachedCalendarObserverHelper(this, false));
+ this.mUncachedCalendar = uncachedCalendar;
+ this.setupCachedCalendar();
+ if (this.supportsChangeLog) {
+ uncachedCalendar.offlineStorage = this.mCachedCalendar;
+ }
+ this.offlineCachedItems = {};
+ this.offlineCachedItemFlags = {};
+}
+calCachedCalendar.prototype = {
+ /* eslint-disable mozilla/use-chromeutils-generateqi */
+ QueryInterface(aIID) {
+ if (aIID.equals(Ci.calISchedulingSupport) && this.mUncachedCalendar.QueryInterface(aIID)) {
+ // check whether uncached calendar supports it:
+ return this;
+ } else if (aIID.equals(Ci.calICalendar) || aIID.equals(Ci.nsISupports)) {
+ return this;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ /* eslint-enable mozilla/use-chromeutils-generateqi */
+
+ mCachedCalendar: null,
+ mCachedObserver: null,
+ mUncachedCalendar: null,
+ mObservers: null,
+ mSuperCalendar: null,
+ offlineCachedItems: null,
+ offlineCachedItemFlags: null,
+
+ onCalendarUnregistering() {
+ if (this.mCachedCalendar) {
+ let self = this;
+ this.mCachedCalendar.removeObserver(this.mCachedObserver);
+ // TODO put changes into a different calendar and delete
+ // afterwards.
+
+ let listener = {
+ onDeleteCalendar(aCalendar, aStatus, aDetail) {
+ self.mCachedCalendar = null;
+ },
+ };
+
+ this.mCachedCalendar
+ .QueryInterface(Ci.calICalendarProvider)
+ .deleteCalendar(this.mCachedCalendar, listener);
+ }
+ },
+
+ setupCachedCalendar() {
+ try {
+ if (this.mCachedCalendar) {
+ // this is actually a resetupCachedCalendar:
+ // Although this doesn't really follow the spec, we know the
+ // storage calendar's deleteCalendar method is synchronous.
+ // TODO put changes into a different calendar and delete
+ // afterwards.
+ this.mCachedCalendar
+ .QueryInterface(Ci.calICalendarProvider)
+ .deleteCalendar(this.mCachedCalendar, null);
+ if (this.supportsChangeLog) {
+ // start with full sync:
+ this.mUncachedCalendar.resetLog();
+ }
+ } else {
+ let calType = Services.prefs.getStringPref("calendar.cache.type", "storage");
+ // While technically, the above deleteCalendar should delete the
+ // whole calendar, this is nothing more than deleting all events
+ // todos and properties. Therefore the initialization can be
+ // skipped.
+ let cachedCalendar = Cc["@mozilla.org/calendar/calendar;1?type=" + calType].createInstance(
+ Ci.calICalendar
+ );
+ switch (calType) {
+ case "memory": {
+ if (this.supportsChangeLog) {
+ // start with full sync:
+ this.mUncachedCalendar.resetLog();
+ }
+ break;
+ }
+ case "storage": {
+ let file = cal.provider.getCalendarDirectory();
+ file.append("cache.sqlite");
+ cachedCalendar.uri = Services.io.newFileURI(file);
+ cachedCalendar.id = this.id;
+ break;
+ }
+ default: {
+ throw new Error("unsupported cache calendar type: " + calType);
+ }
+ }
+ cachedCalendar.transientProperties = true;
+ // Forward the disabled property to the storage calendar so that it
+ // stops interacting with the file system. Other properties have no
+ // useful effect on the storage calendar, so don't forward them.
+ cachedCalendar.setProperty("disabled", this.getProperty("disabled"));
+ cachedCalendar.setProperty("relaxedMode", true);
+ cachedCalendar.superCalendar = this;
+ if (!this.mCachedObserver) {
+ this.mCachedObserver = new calCachedCalendarObserverHelper(this, true);
+ }
+ cachedCalendar.addObserver(this.mCachedObserver);
+ this.mCachedCalendar = cachedCalendar;
+ }
+ } catch (exc) {
+ console.error(exc);
+ }
+ },
+
+ async getOfflineAddedItems() {
+ this.offlineCachedItems = {};
+ for await (let items of cal.iterate.streamValues(
+ this.mCachedCalendar.getItems(
+ calICalendar.ITEM_FILTER_ALL_ITEMS | calICalendar.ITEM_FILTER_OFFLINE_CREATED,
+ 0,
+ null,
+ null
+ )
+ )) {
+ for (let item of items) {
+ this.offlineCachedItems[item.hashId] = item;
+ this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ }
+ }
+ },
+
+ async getOfflineModifiedItems() {
+ for await (let items of cal.iterate.streamValues(
+ this.mCachedCalendar.getItems(
+ calICalendar.ITEM_FILTER_OFFLINE_MODIFIED | calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ null,
+ null
+ )
+ )) {
+ for (let item of items) {
+ this.offlineCachedItems[item.hashId] = item;
+ this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+ }
+ },
+
+ async getOfflineDeletedItems() {
+ for await (let items of cal.iterate.streamValues(
+ this.mCachedCalendar.getItems(
+ calICalendar.ITEM_FILTER_OFFLINE_DELETED | calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ null,
+ null
+ )
+ )) {
+ for (let item of items) {
+ this.offlineCachedItems[item.hashId] = item;
+ this.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ }
+ }
+ },
+
+ mPendingSync: null,
+ async synchronize() {
+ if (!this.mPendingSync) {
+ this.mPendingSync = this._doSynchronize().catch(console.error);
+ }
+ return this.mPendingSync;
+ },
+ async _doSynchronize() {
+ let clearPending = () => {
+ this.mPendingSync = null;
+ };
+
+ if (this.getProperty("disabled")) {
+ clearPending();
+ return;
+ }
+
+ if (this.offline) {
+ clearPending();
+ return;
+ }
+
+ if (this.supportsChangeLog) {
+ await new Promise((resolve, reject) => {
+ let spec = this.uri.spec;
+ cal.LOG("[calCachedCalendar] Doing changelog based sync for calendar " + spec);
+ let opListener = {
+ onResult(operation, result) {
+ if (!operation || !operation.isPending) {
+ let status = operation ? operation.status : Cr.NS_OK;
+ clearPending();
+ if (!Components.isSuccessCode(status)) {
+ reject(
+ "[calCachedCalendar] replay action failed: " +
+ (operation && operation.id ? operation.id : "<unknown>") +
+ ", uri=" +
+ spec +
+ ", result=" +
+ result +
+ ", operation=" +
+ operation
+ );
+ return;
+ }
+ cal.LOG("[calCachedCalendar] replayChangesOn finished.");
+ resolve();
+ }
+ },
+ };
+ this.mUncachedCalendar.replayChangesOn(opListener);
+ });
+ return;
+ }
+
+ cal.LOG("[calCachedCalendar] Doing full sync for calendar " + this.uri.spec);
+
+ await this.getOfflineAddedItems();
+ await this.getOfflineModifiedItems();
+ await this.getOfflineDeletedItems();
+
+ // TODO instead of deleting the calendar and creating a new
+ // one, maybe we want to do a "real" sync between the
+ // existing local calendar and the remote calendar.
+ this.setupCachedCalendar();
+
+ let modifiedTimes = {};
+ try {
+ for await (let items of cal.iterate.streamValues(
+ this.mUncachedCalendar.getItems(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null)
+ )) {
+ for (let item of items) {
+ // Adding items recd from the Memory Calendar
+ // These may be different than what the cache has
+ modifiedTimes[item.id] = item.lastModifiedTime;
+ this.mCachedCalendar.addItem(item);
+ }
+ }
+ } catch (e) {
+ await this.playbackOfflineItems();
+ this.mCachedObserver.onLoad(this.mCachedCalendar);
+ clearPending();
+ throw e; // Do not swallow this error.
+ }
+
+ await new Promise((resolve, reject) => {
+ cal.iterate.forEach(
+ this.offlineCachedItems,
+ item => {
+ switch (this.offlineCachedItemFlags[item.hashId]) {
+ case cICL.OFFLINE_FLAG_CREATED_RECORD:
+ // Created items are not present on the server, so its safe to adopt them
+ this.adoptOfflineItem(item.clone());
+ break;
+ case cICL.OFFLINE_FLAG_MODIFIED_RECORD:
+ // Two Cases Here:
+ if (item.id in modifiedTimes) {
+ // The item is still on the server, we just retrieved it in the listener above.
+ if (item.lastModifiedTime.compare(modifiedTimes[item.id]) < 0) {
+ // The item on the server has been modified, ask to overwrite
+ cal.WARN(
+ "[calCachedCalendar] Item '" +
+ item.title +
+ "' at the server seems to be modified recently."
+ );
+ this.promptOverwrite("modify", item, null);
+ } else {
+ // Our item is newer, just modify the item
+ this.modifyOfflineItem(item, null);
+ }
+ } else {
+ // The item has been deleted from the server, ask if it should be added again
+ cal.WARN(
+ "[calCachedCalendar] Item '" + item.title + "' has been deleted from the server"
+ );
+ if (cal.provider.promptOverwrite("modify", item, null)) {
+ this.adoptOfflineItem(item.clone());
+ }
+ }
+ break;
+ case cICL.OFFLINE_FLAG_DELETED_RECORD:
+ if (item.id in modifiedTimes) {
+ // The item seems to exist on the server...
+ if (item.lastModifiedTime.compare(modifiedTimes[item.id]) < 0) {
+ // ...and has been modified on the server. Ask to overwrite
+ cal.WARN(
+ "[calCachedCalendar] Item '" +
+ item.title +
+ "' at the server seems to be modified recently."
+ );
+ this.promptOverwrite("delete", item, null);
+ } else {
+ // ...and has not been modified. Delete it now.
+ this.deleteOfflineItem(item);
+ }
+ } else {
+ // Item has already been deleted from the server, no need to change anything.
+ }
+ break;
+ }
+ },
+ async () => {
+ this.offlineCachedItems = {};
+ this.offlineCachedItemFlags = {};
+ await this.playbackOfflineItems();
+ clearPending();
+ resolve();
+ }
+ );
+ });
+ },
+
+ onOfflineStatusChanged(aNewState) {
+ if (aNewState) {
+ // Going offline: (XXX get items before going offline?) => we may ask the user to stay online a bit longer
+ } else if (!this.getProperty("disabled") && this.getProperty("refreshInterval") != "0") {
+ // Going online (start replaying changes to the remote calendar).
+ // Don't do this if the calendar is disabled or set to manual updates only.
+ this.refresh();
+ }
+ },
+
+ // aOldItem is already in the cache
+ async promptOverwrite(aMethod, aItem, aOldItem) {
+ let overwrite = cal.provider.promptOverwrite(aMethod, aItem);
+ if (overwrite) {
+ if (aMethod == "modify") {
+ await this.modifyOfflineItem(aItem, aOldItem);
+ } else {
+ await this.deleteOfflineItem(aItem);
+ }
+ }
+ },
+
+ /*
+ * Asynchronously performs playback operations of items added, modified, or deleted offline
+ *
+ * @param aPlaybackType (optional) The starting operation type. This function will be
+ * called recursively through playback operations in the order of
+ * add, modify, delete. By default playback will start with the add
+ * operation. Valid values for this parameter are defined as
+ * OFFLINE_FLAG_XXX constants in the calIChangeLog interface.
+ */
+ async playbackOfflineItems(aPlaybackType) {
+ let self = this;
+ let storage = this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage);
+
+ let itemQueue = [];
+ let debugOp;
+ let nextCallback;
+ let uncachedOp;
+ let filter;
+
+ aPlaybackType = aPlaybackType || cICL.OFFLINE_FLAG_CREATED_RECORD;
+ switch (aPlaybackType) {
+ case cICL.OFFLINE_FLAG_CREATED_RECORD:
+ debugOp = "add";
+ nextCallback = this.playbackOfflineItems.bind(this, cICL.OFFLINE_FLAG_MODIFIED_RECORD);
+ uncachedOp = item => this.mUncachedCalendar.addItem(item);
+ filter = calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ break;
+ case cICL.OFFLINE_FLAG_MODIFIED_RECORD:
+ debugOp = "modify";
+ nextCallback = this.playbackOfflineItems.bind(this, cICL.OFFLINE_FLAG_DELETED_RECORD);
+ uncachedOp = item => this.mUncachedCalendar.modifyItem(item, item);
+ filter = calICalendar.ITEM_FILTER_OFFLINE_MODIFIED;
+ break;
+ case cICL.OFFLINE_FLAG_DELETED_RECORD:
+ debugOp = "delete";
+ uncachedOp = item => this.mUncachedCalendar.deleteItem(item);
+ filter = calICalendar.ITEM_FILTER_OFFLINE_DELETED;
+ break;
+ default:
+ cal.ERROR("[calCachedCalendar] Invalid playback type: " + aPlaybackType);
+ return;
+ }
+
+ async function popItemQueue() {
+ if (!itemQueue || itemQueue.length == 0) {
+ // no items left in the queue, move on to the next operation
+ if (nextCallback) {
+ await nextCallback();
+ }
+ } else {
+ // perform operation on the next offline item in the queue
+ let item = itemQueue.pop();
+ let error = null;
+ try {
+ await uncachedOp(item);
+ } catch (e) {
+ error = e;
+ cal.ERROR(
+ "[calCachedCalendar] Could not perform playback operation " +
+ debugOp +
+ " for item " +
+ (item.title || " (none) ") +
+ ": " +
+ e
+ );
+ }
+ if (!error) {
+ if (aPlaybackType == cICL.OFFLINE_FLAG_DELETED_RECORD) {
+ self.mCachedCalendar.deleteItem(item);
+ } else {
+ storage.resetItemOfflineFlag(item);
+ }
+ } else {
+ // If the playback action could not be performed, then there
+ // is no need for further action. The item still has the
+ // offline flag, so it will be taken care of next time.
+ cal.WARN(
+ "[calCachedCalendar] Unable to perform playback action " +
+ debugOp +
+ " to the server, will try again next time (" +
+ item.id +
+ "," +
+ error +
+ ")"
+ );
+ }
+
+ // move on to the next item in the queue
+ await popItemQueue();
+ }
+ }
+
+ itemQueue = itemQueue.concat(
+ await this.mCachedCalendar.getItemsAsArray(
+ calICalendar.ITEM_FILTER_ALL_ITEMS | filter,
+ 0,
+ null,
+ null
+ )
+ );
+
+ if (this.offline) {
+ cal.LOG("[calCachedCalendar] back to offline mode, reconciliation aborted");
+ } else {
+ cal.LOG(
+ "[calCachedCalendar] Performing playback operation " +
+ debugOp +
+ " on " +
+ itemQueue.length +
+ " items to " +
+ self.name
+ );
+ // start the first operation
+ await popItemQueue();
+ }
+ },
+
+ get superCalendar() {
+ return (this.mSuperCalendar && this.mSuperCalendar.superCalendar) || this;
+ },
+ set superCalendar(val) {
+ this.mSuperCalendar = val;
+ },
+
+ get offline() {
+ return Services.io.offline;
+ },
+ get supportsChangeLog() {
+ return cal.wrapInstance(this.mUncachedCalendar, Ci.calIChangeLog) != null;
+ },
+
+ get canRefresh() {
+ // enable triggering sync using the reload button
+ return true;
+ },
+
+ get supportsScheduling() {
+ return this.mUncachedCalendar.supportsScheduling;
+ },
+
+ getSchedulingSupport() {
+ return this.mUncachedCalendar.getSchedulingSupport();
+ },
+
+ getProperty(aName) {
+ switch (aName) {
+ case "cache.enabled":
+ if (this.mUncachedCalendar.getProperty("cache.always")) {
+ return true;
+ }
+ break;
+ }
+
+ return this.mUncachedCalendar.getProperty(aName);
+ },
+ setProperty(aName, aValue) {
+ if (aName == "disabled") {
+ // Forward the disabled property to the storage calendar so that it
+ // stops interacting with the file system. Other properties have no
+ // useful effect on the storage calendar, so don't forward them.
+ this.mCachedCalendar.setProperty(aName, aValue);
+ }
+ this.mUncachedCalendar.setProperty(aName, aValue);
+ },
+ async refresh() {
+ if (this.offline) {
+ this.downstreamRefresh();
+ } else if (this.supportsChangeLog) {
+ /* we first ensure that any remaining offline items are reconciled with the calendar server */
+ await this.playbackOfflineItems();
+ await this.downstreamRefresh();
+ } else {
+ this.downstreamRefresh();
+ }
+ },
+ async downstreamRefresh() {
+ if (this.mUncachedCalendar.canRefresh && !this.offline) {
+ this.mUncachedCalendar.refresh(); // will trigger synchronize once the calendar is loaded
+ return;
+ }
+ await this.synchronize();
+ // fire completing onLoad for this refresh call
+ this.mCachedObserver.onLoad(this.mCachedCalendar);
+ },
+
+ addObserver(aObserver) {
+ this.mObservers.add(aObserver);
+ },
+ removeObserver(aObserver) {
+ this.mObservers.delete(aObserver);
+ },
+
+ async addItem(item) {
+ return this.adoptItem(item.clone());
+ },
+
+ async adoptItem(item) {
+ return new Promise((resolve, reject) => {
+ this.doAdoptItem(item, (calendar, status, opType, id, detail) => {
+ if (!Components.isSuccessCode(status)) {
+ return reject(new Components.Exception(detail, status));
+ }
+ return resolve(detail);
+ });
+ });
+ },
+
+ /**
+ * The function form of calIOperationListener.onOperationComplete used where
+ * the whole interface is not needed.
+ *
+ * @callback OnOperationCompleteHandler
+ *
+ * @param {calICalendar} calendar
+ * @param {number} status
+ * @param {number} operationType
+ * @param {string} id
+ * @param {calIItem|Error} detail
+ */
+
+ /**
+ * Keeps track of pending callbacks injected into the uncached calendar during
+ * adopt or modify operations. This is done to ensure we remove the correct
+ * callback when multiple operations occur at once.
+ *
+ * @type {OnOperationComplateHandler[]}
+ */
+ _injectedCallbacks: [],
+
+ /**
+ * Executes the actual addition of the item using either the cached or uncached
+ * calendar depending on offline state. A separate method is used here to
+ * preserve the order of the "onAddItem" event.
+ *
+ * @param {calIItem} item
+ * @param {OnOperationCompleteHandler} handler
+ */
+ doAdoptItem(item, listener) {
+ // Forwarding add/modify/delete to the cached calendar using the calIObserver
+ // callbacks would be advantageous, because the uncached provider could implement
+ // a true push mechanism firing without being triggered from within the program.
+ // But this would mean the uncached provider fires on the passed
+ // calIOperationListener, e.g. *before* it fires on calIObservers
+ // (because that order is undefined). Firing onOperationComplete before onAddItem et al
+ // would result in this facade firing onOperationComplete even though the modification
+ // hasn't yet been performed on the cached calendar (which happens in onAddItem et al).
+ // Result is that we currently stick to firing onOperationComplete if the cached calendar
+ // has performed the modification, see below:
+
+ let onSuccess = item => listener(item.calendar, Cr.NS_OK, cIOL.ADD, item.id, item);
+ let onError = e => listener(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e);
+
+ if (this.offline) {
+ // If we are offline, don't even try to add the item
+ this.adoptOfflineItem(item).then(onSuccess, onError);
+ } else {
+ // Otherwise ask the provider to add the item now.
+
+ // Expected to be called in the context of the uncached calendar's adoptItem()
+ // so this adoptItem() call returns first. This is a needed hack to keep the
+ // cached calendar's "onAddItem" event firing before the endBatch() call of
+ // the uncached calendar.
+ let adoptItemCallback = async (calendar, status, opType, id, detail) => {
+ if (isUnavailableCode(status)) {
+ // The item couldn't be added to the (remote) location,
+ // this is like being offline. Add the item to the cached
+ // calendar instead.
+ cal.LOG(
+ "[calCachedCalendar] Calendar " + calendar.name + " is unavailable, adding item offline"
+ );
+ await this.adoptOfflineItem(item).then(onSuccess, onError);
+ } else if (Components.isSuccessCode(status)) {
+ // On success, add the item to the cache.
+ await this.mCachedCalendar.addItem(detail).then(onSuccess, onError);
+ } else {
+ // Either an error occurred or this is a successful add
+ // to a cached calendar. Forward the call to the listener
+ listener(this, status, opType, id, detail);
+ }
+ this.mUncachedCalendar.wrappedJSObject._cachedAdoptItemCallback = null;
+ this._injectedCallbacks = this._injectedCallbacks.filter(cb => cb != adoptItemCallback);
+ };
+
+ // Store the callback so we can remove the correct one later.
+ this._injectedCallbacks.push(adoptItemCallback);
+
+ this.mUncachedCalendar.wrappedJSObject._cachedAdoptItemCallback = adoptItemCallback;
+ this.mUncachedCalendar.adoptItem(item).catch(e => {
+ adoptItemCallback(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e);
+ });
+ }
+ },
+
+ /**
+ * Adds an item to the cached (storage) calendar.
+ *
+ * @param {calIItem} item
+ * @returns {calIItem}
+ */
+ async adoptOfflineItem(item) {
+ let adoptedItem = await this.mCachedCalendar.adoptItem(item);
+ await this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage).addOfflineItem(adoptedItem);
+ return adoptedItem;
+ },
+
+ async modifyItem(newItem, oldItem) {
+ return new Promise((resolve, reject) => {
+ this.doModifyItem(newItem, oldItem, (calendar, status, opType, id, detail) => {
+ if (!Components.isSuccessCode(status)) {
+ return reject(new Components.Exception(detail, status));
+ }
+ return resolve(detail);
+ });
+ });
+ },
+
+ /**
+ * Executes the actual modification of the item using either the cached or
+ * uncached calendar depending on offline state. A separate method is used here
+ * to preserve the order of the "onModifyItem" event.
+ *
+ * @param {calIItem} newItem
+ * @param {calIItem} oldItem
+ * @param {OnOperationCompleteHandler} handler
+ */
+ doModifyItem(newItem, oldItem, listener) {
+ let onSuccess = item => listener(item.calendar, Cr.NS_OK, cIOL.MODIFY, item.id, item);
+ let onError = e => listener(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e);
+
+ // Forwarding add/modify/delete to the cached calendar using the calIObserver
+ // callbacks would be advantageous, because the uncached provider could implement
+ // a true push mechanism firing without being triggered from within the program.
+ // But this would mean the uncached provider fires on the passed
+ // calIOperationListener, e.g. *before* it fires on calIObservers
+ // (because that order is undefined). Firing onOperationComplete before onAddItem et al
+ // would result in this facade firing onOperationComplete even though the modification
+ // hasn't yet been performed on the cached calendar (which happens in onAddItem et al).
+ // Result is that we currently stick to firing onOperationComplete if the cached calendar
+ // has performed the modification, see below: */
+
+ // Expected to be called in the context of the uncached calendar's modifyItem()
+ // so this modifyItem() call returns first. This is a needed hack to keep the
+ // cached calendar's "onModifyItem" event firing before the endBatch() call of
+ // the uncached calendar.
+ let modifyItemCallback = async (calendar, status, opType, id, detail) => {
+ // Returned Promise only available through wrappedJSObject.
+ if (isUnavailableCode(status)) {
+ // The item couldn't be modified at the (remote) location,
+ // this is like being offline. Add the item to the cache
+ // instead.
+ cal.LOG(
+ "[calCachedCalendar] Calendar " +
+ calendar.name +
+ " is unavailable, modifying item offline"
+ );
+ await this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError);
+ } else if (Components.isSuccessCode(status)) {
+ // On success, modify the item in the cache
+ await this.mCachedCalendar.modifyItem(detail, oldItem).then(onSuccess, onError);
+ } else {
+ // This happens on error, forward the error through the listener
+ listener(this, status, opType, id, detail);
+ }
+ this._injectedCallbacks = this._injectedCallbacks.filter(cb => cb != modifyItemCallback);
+ };
+
+ // First of all, we should find out if the item to modify is
+ // already an offline item or not.
+ if (this.offline) {
+ // If we are offline, don't even try to modify the item
+ this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError);
+ } else {
+ // Otherwise, get the item flags and further process the item.
+ this.mCachedCalendar.getItemOfflineFlag(oldItem).then(offline_flag => {
+ if (
+ offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD
+ ) {
+ // The item is already offline, just modify it in the cache
+ this.modifyOfflineItem(newItem, oldItem).then(onSuccess, onError);
+ } else {
+ // Not an offline item, attempt to modify using provider
+
+ // This is a needed hack to keep the cached calendar's "onModifyItem" event
+ // firing before the endBatch() call of the uncached calendar. It is called
+ // in mUncachedCalendar's modifyItem() method.
+ this.mUncachedCalendar.wrappedJSObject._cachedModifyItemCallback = modifyItemCallback;
+
+ // Store the callback so we can remove the correct one later.
+ this._injectedCallbacks.push(modifyItemCallback);
+
+ this.mUncachedCalendar.modifyItem(newItem, oldItem).catch(e => {
+ modifyItemCallback(null, e.result || Cr.NS_ERROR_FAILURE, null, null, e);
+ });
+ }
+ });
+ }
+ },
+
+ /**
+ * Modifies an item in the cached calendar.
+ *
+ * @param {calIItem} newItem
+ * @param {calIItem} oldItem
+ * @returns {calIItem}
+ */
+ async modifyOfflineItem(newItem, oldItem) {
+ let modifiedItem = await this.mCachedCalendar.modifyItem(newItem, oldItem);
+ await this.mCachedCalendar
+ .QueryInterface(Ci.calIOfflineStorage)
+ .modifyOfflineItem(modifiedItem);
+ return modifiedItem;
+ },
+
+ async deleteItem(item) {
+ // First of all, we should find out if the item to delete is
+ // already an offline item or not.
+ if (this.offline) {
+ // If we are offline, don't even try to delete the item
+ await this.deleteOfflineItem(item);
+ } else {
+ // Otherwise, get the item flags, the listener will further
+ // process the item.
+ let offline_flag = await this.mCachedCalendar.getItemOfflineFlag(item);
+ if (
+ offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD
+ ) {
+ // The item is already offline, just mark it deleted it in
+ // the cache
+ await this.deleteOfflineItem(item);
+ } else {
+ try {
+ // Not an offline item, attempt to delete using provider
+ await this.mUncachedCalendar.deleteItem(item);
+
+ // On success, delete the item from the cache
+ await this.mCachedCalendar.deleteItem(item);
+
+ // Also, remove any meta data associated with the item
+ try {
+ this.mCachedCalendar.QueryInterface(Ci.calISyncWriteCalendar).deleteMetaData(item.id);
+ } catch (e) {
+ cal.LOG("[calCachedCalendar] Offline storage doesn't support metadata");
+ }
+ } catch (e) {
+ if (isUnavailableCode(e.result)) {
+ // The item couldn't be deleted at the (remote) location,
+ // this is like being offline. Mark the item deleted in the
+ // cache instead.
+ cal.LOG(
+ "[calCachedCalendar] Calendar " +
+ item.calendar.name +
+ " is unavailable, deleting item offline"
+ );
+ await this.deleteOfflineItem(item);
+ }
+ }
+ }
+ }
+ },
+
+ async deleteOfflineItem(item) {
+ /* We do not delete the item from the cache, as we will need it when reconciling the cache content and the server content. */
+ return this.mCachedCalendar.QueryInterface(Ci.calIOfflineStorage).deleteOfflineItem(item);
+ },
+};
+(function () {
+ function defineForwards(proto, targetName, functions, getters, gettersAndSetters) {
+ function defineForwardGetter(attr) {
+ proto.__defineGetter__(attr, function () {
+ return this[targetName][attr];
+ });
+ }
+ function defineForwardGetterAndSetter(attr) {
+ defineForwardGetter(attr);
+ proto.__defineSetter__(attr, function (value) {
+ return (this[targetName][attr] = value);
+ });
+ }
+ function defineForwardFunction(funcName) {
+ proto[funcName] = function (...args) {
+ let obj = this[targetName];
+ return obj[funcName](...args);
+ };
+ }
+ functions.forEach(defineForwardFunction);
+ getters.forEach(defineForwardGetter);
+ gettersAndSetters.forEach(defineForwardGetterAndSetter);
+ }
+
+ defineForwards(
+ calCachedCalendar.prototype,
+ "mUncachedCalendar",
+ ["deleteProperty", "isInvitation", "getInvitedAttendee", "canNotify"],
+ ["providerID", "type", "aclManager", "aclEntry"],
+ ["id", "name", "uri", "readOnly"]
+ );
+ defineForwards(
+ calCachedCalendar.prototype,
+ "mCachedCalendar",
+ ["getItem", "getItems", "getItemsAsArray", "startBatch", "endBatch"],
+ [],
+ []
+ );
+})();
diff --git a/comm/calendar/base/src/calICSService-worker.js b/comm/calendar/base/src/calICSService-worker.js
new file mode 100644
index 0000000000..83d8c2c088
--- /dev/null
+++ b/comm/calendar/base/src/calICSService-worker.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * ChromeWorker for parseICSAsync method in CalICSService.jsm
+ */
+
+/* eslint-env worker */
+/* import-globals-from ../modules/Ical.jsm */
+
+// eslint-disable-next-line no-unused-vars
+importScripts("resource:///modules/calendar/Ical.jsm");
+
+ICAL.design.strict = false;
+
+self.onmessage = function (event) {
+ let comp = ICAL.parse(event.data);
+ postMessage(comp);
+ self.close();
+};
diff --git a/comm/calendar/base/src/calInternalInterfaces.idl b/comm/calendar/base/src/calInternalInterfaces.idl
new file mode 100644
index 0000000000..339b935448
--- /dev/null
+++ b/comm/calendar/base/src/calInternalInterfaces.idl
@@ -0,0 +1,29 @@
+/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Don't use these if you're not the calendar glue code! **/
+
+#include "nsISupports.idl"
+
+interface calIItemBase;
+interface calIDateTime;
+
+[scriptable, uuid(1903648f-a0ee-4ae1-84b0-d8e8d0b10506)]
+interface calIInternalShallowCopy : nsISupports
+{
+ /**
+ * create a proxy for this item; the returned item
+ * proxy will have parentItem set to this instance.
+ *
+ * @param aRecurrenceId RECURRENCE-ID of the proxy to be created
+ */
+ calIItemBase createProxy(in calIDateTime aRecurrenceId);
+
+ // used by recurrenceInfo when cloning proxy objects to
+ // avoid an infinite loop. aNewParent is optional, and is
+ // used to set the parent of the new item; it should be null
+ // if no new parent is passed in.
+ calIItemBase cloneShallow(in calIItemBase aNewParent);
+};
diff --git a/comm/calendar/base/src/calItemBase.js b/comm/calendar/base/src/calItemBase.js
new file mode 100644
index 0000000000..d1cd1599e5
--- /dev/null
+++ b/comm/calendar/base/src/calItemBase.js
@@ -0,0 +1,1198 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported makeMemberAttr, makeMemberAttrProperty */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm");
+var { CalRelation } = ChromeUtils.import("resource:///modules/CalRelation.jsm");
+var { CalAttachment } = ChromeUtils.import("resource:///modules/CalAttachment.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gParserUtils",
+ "@mozilla.org/parserutils;1",
+ "nsIParserUtils"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gTextToHtmlConverter",
+ "@mozilla.org/txttohtmlconv;1",
+ "mozITXTToHTMLConv"
+);
+
+/**
+ * calItemBase prototype definition
+ *
+ * @implements calIItemBase
+ * @class
+ */
+function calItemBase() {
+ cal.ASSERT(false, "Inheriting objects call initItemBase()!");
+}
+
+calItemBase.prototype = {
+ mProperties: null,
+ mPropertyParams: null,
+
+ mIsProxy: false,
+ mHashId: null,
+ mImmutable: false,
+ mDirty: false,
+ mCalendar: null,
+ mParentItem: null,
+ mRecurrenceInfo: null,
+ mOrganizer: null,
+
+ mAlarms: null,
+ mAlarmLastAck: null,
+
+ mAttendees: null,
+ mAttachments: null,
+ mRelations: null,
+ mCategories: null,
+
+ mACLEntry: null,
+
+ /**
+ * Initialize the base item's attributes. Can be called from inheriting
+ * objects in their constructor.
+ */
+ initItemBase() {
+ this.wrappedJSObject = this;
+ this.mProperties = new Map();
+ this.mPropertyParams = {};
+ this.setProperty("CREATED", cal.dtz.jsDateToDateTime(new Date()));
+ },
+
+ /**
+ * @see nsISupports
+ */
+ QueryInterface: ChromeUtils.generateQI(["calIItemBase"]),
+
+ /**
+ * @see calIItemBase
+ */
+ get aclEntry() {
+ let aclEntry = this.mACLEntry;
+ let aclManager = this.calendar && this.calendar.superCalendar.aclManager;
+
+ if (!aclEntry && aclManager) {
+ this.mACLEntry = aclManager.getItemEntry(this);
+ aclEntry = this.mACLEntry;
+ }
+
+ if (!aclEntry && this.parentItem != this) {
+ // No ACL entry on this item, check the parent
+ aclEntry = this.parentItem.aclEntry;
+ }
+
+ return aclEntry;
+ },
+
+ // readonly attribute AUTF8String hashId;
+ get hashId() {
+ if (this.mHashId === null) {
+ let rid = this.recurrenceId;
+ let calendar = this.calendar;
+ // some unused delim character:
+ this.mHashId = [
+ encodeURIComponent(this.id),
+ rid ? rid.getInTimezone(cal.dtz.UTC).icalString : "",
+ calendar ? encodeURIComponent(calendar.id) : "",
+ ].join("#");
+ }
+ return this.mHashId;
+ },
+
+ // attribute AUTF8String id;
+ get id() {
+ return this.getProperty("UID");
+ },
+ set id(uid) {
+ this.mHashId = null; // recompute hashId
+ this.setProperty("UID", uid);
+ if (this.mRecurrenceInfo) {
+ this.mRecurrenceInfo.onIdChange(uid);
+ }
+ },
+
+ // attribute calIDateTime recurrenceId;
+ get recurrenceId() {
+ return this.getProperty("RECURRENCE-ID");
+ },
+ set recurrenceId(rid) {
+ this.mHashId = null; // recompute hashId
+ this.setProperty("RECURRENCE-ID", rid);
+ },
+
+ // attribute calIRecurrenceInfo recurrenceInfo;
+ get recurrenceInfo() {
+ return this.mRecurrenceInfo;
+ },
+ set recurrenceInfo(value) {
+ this.modify();
+ this.mRecurrenceInfo = cal.unwrapInstance(value);
+ },
+
+ // attribute calIItemBase parentItem;
+ get parentItem() {
+ return this.mParentItem || this;
+ },
+ set parentItem(value) {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ this.mParentItem = cal.unwrapInstance(value);
+ },
+
+ /**
+ * Initializes the base item to be an item proxy. Used by inheriting
+ * objects createProxy() method.
+ *
+ * XXXdbo Explain proxy a bit better, either here or in
+ * calIInternalShallowCopy.
+ *
+ * @see calIInternalShallowCopy
+ * @param aParentItem The parent item to initialize the proxy on.
+ * @param aRecurrenceId The recurrence id to initialize the proxy for.
+ */
+ initializeProxy(aParentItem, aRecurrenceId) {
+ this.mIsProxy = true;
+
+ aParentItem = cal.unwrapInstance(aParentItem);
+ this.mParentItem = aParentItem;
+ this.mCalendar = aParentItem.mCalendar;
+ this.recurrenceId = aRecurrenceId;
+
+ // Make sure organizer is unset, as the getter checks for this.
+ this.mOrganizer = undefined;
+
+ this.mImmutable = aParentItem.mImmutable;
+ },
+
+ // readonly attribute boolean isMutable;
+ get isMutable() {
+ return !this.mImmutable;
+ },
+
+ /**
+ * This function should be called by all members that modify the item. It
+ * checks if the item is immutable and throws accordingly, and sets the
+ * mDirty property.
+ */
+ modify() {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ this.mDirty = true;
+ },
+
+ /**
+ * Makes sure the item is not dirty. If the item is dirty, properties like
+ * LAST-MODIFIED and DTSTAMP are set to now.
+ */
+ ensureNotDirty() {
+ if (this.mDirty) {
+ let now = cal.dtz.jsDateToDateTime(new Date());
+ this.setProperty("LAST-MODIFIED", now);
+ this.setProperty("DTSTAMP", now);
+ this.mDirty = false;
+ }
+ },
+
+ /**
+ * Makes all properties of the base item immutable. Can be called by
+ * inheriting objects' makeImmutable method.
+ */
+ makeItemBaseImmutable() {
+ if (this.mImmutable) {
+ return;
+ }
+
+ // make all our components immutable
+ if (this.mRecurrenceInfo) {
+ this.mRecurrenceInfo.makeImmutable();
+ }
+
+ if (this.mOrganizer) {
+ this.mOrganizer.makeImmutable();
+ }
+ if (this.mAttendees) {
+ for (let att of this.mAttendees) {
+ att.makeImmutable();
+ }
+ }
+
+ for (let propValue of this.mProperties.values()) {
+ if (propValue?.isMutable) {
+ propValue.makeImmutable();
+ }
+ }
+
+ if (this.mAlarms) {
+ for (let alarm of this.mAlarms) {
+ alarm.makeImmutable();
+ }
+ }
+
+ if (this.mAlarmLastAck) {
+ this.mAlarmLastAck.makeImmutable();
+ }
+
+ this.ensureNotDirty();
+ this.mImmutable = true;
+ },
+
+ // boolean hasSameIds(in calIItemBase aItem);
+ hasSameIds(that) {
+ return (
+ that &&
+ this.id == that.id &&
+ (this.recurrenceId == that.recurrenceId || // both null
+ (this.recurrenceId &&
+ that.recurrenceId &&
+ this.recurrenceId.compare(that.recurrenceId) == 0))
+ );
+ },
+
+ /**
+ * Overridden by CalEvent to indicate the item is an event.
+ */
+ isEvent() {
+ return false;
+ },
+
+ /**
+ * Overridden by CalTodo to indicate the item is a todo.
+ */
+ isTodo() {
+ return false;
+ },
+
+ // calIItemBase clone();
+ clone() {
+ return this.cloneShallow(this.mParentItem);
+ },
+
+ /**
+ * Clones the base item's properties into the passed object, potentially
+ * setting a new parent item.
+ *
+ * @param m The item to clone this item into
+ * @param aNewParent (optional) The new parent item to set on m.
+ */
+ cloneItemBaseInto(cloned, aNewParent) {
+ cloned.mImmutable = false;
+ cloned.mACLEntry = this.mACLEntry;
+ cloned.mIsProxy = this.mIsProxy;
+ cloned.mParentItem = cal.unwrapInstance(aNewParent) || this.mParentItem;
+ cloned.mHashId = this.mHashId;
+ cloned.mCalendar = this.mCalendar;
+ if (this.mRecurrenceInfo) {
+ cloned.mRecurrenceInfo = cal.unwrapInstance(this.mRecurrenceInfo.clone());
+ cloned.mRecurrenceInfo.item = cloned;
+ }
+
+ let org = this.organizer;
+ if (org) {
+ org = org.clone();
+ }
+ cloned.mOrganizer = org;
+
+ cloned.mAttendees = [];
+ for (let att of this.getAttendees()) {
+ cloned.mAttendees.push(att.clone());
+ }
+
+ cloned.mProperties = new Map();
+ for (let [name, value] of this.mProperties.entries()) {
+ if (value instanceof CalDateTime || value instanceof Ci.calIDateTime) {
+ value = value.clone();
+ }
+
+ cloned.mProperties.set(name, value);
+
+ let propBucket = this.mPropertyParams[name];
+ if (propBucket) {
+ let newBucket = {};
+ for (let param in propBucket) {
+ newBucket[param] = propBucket[param];
+ }
+ cloned.mPropertyParams[name] = newBucket;
+ }
+ }
+
+ cloned.mAttachments = [];
+ for (let att of this.getAttachments()) {
+ cloned.mAttachments.push(att.clone());
+ }
+
+ cloned.mRelations = [];
+ for (let rel of this.getRelations()) {
+ cloned.mRelations.push(rel.clone());
+ }
+
+ cloned.mCategories = this.getCategories();
+
+ cloned.mAlarms = [];
+ for (let alarm of this.getAlarms()) {
+ // Clone alarms into new item, assume the alarms from the old item
+ // are valid and don't need validation.
+ cloned.mAlarms.push(alarm.clone());
+ }
+
+ let alarmLastAck = this.alarmLastAck;
+ if (alarmLastAck) {
+ alarmLastAck = alarmLastAck.clone();
+ }
+ cloned.mAlarmLastAck = alarmLastAck;
+
+ cloned.mDirty = this.mDirty;
+
+ return cloned;
+ },
+
+ // attribute calIDateTime alarmLastAck;
+ get alarmLastAck() {
+ return this.mAlarmLastAck;
+ },
+ set alarmLastAck(aValue) {
+ this.modify();
+ if (aValue && !aValue.timezone.isUTC) {
+ aValue = aValue.getInTimezone(cal.dtz.UTC);
+ }
+ this.mAlarmLastAck = aValue;
+ },
+
+ // readonly attribute calIDateTime lastModifiedTime;
+ get lastModifiedTime() {
+ this.ensureNotDirty();
+ return this.getProperty("LAST-MODIFIED");
+ },
+
+ // readonly attribute calIDateTime stampTime;
+ get stampTime() {
+ this.ensureNotDirty();
+ return this.getProperty("DTSTAMP");
+ },
+
+ // attribute AUTF8string descriptionText;
+ get descriptionText() {
+ return this.getProperty("DESCRIPTION");
+ },
+
+ set descriptionText(text) {
+ this.setProperty("DESCRIPTION", text);
+ if (text) {
+ this.setPropertyParameter("DESCRIPTION", "ALTREP", null);
+ } // else: property parameter deleted by setProperty(..., null)
+ },
+
+ // attribute AUTF8string descriptionHTML;
+ get descriptionHTML() {
+ let altrep = this.getPropertyParameter("DESCRIPTION", "ALTREP");
+ if (altrep?.startsWith("data:text/html,")) {
+ try {
+ return decodeURIComponent(altrep.slice("data:text/html,".length));
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ // Fallback: Upconvert the plaintext
+ let description = this.getProperty("DESCRIPTION");
+ if (!description) {
+ return null;
+ }
+ let mode = Ci.mozITXTToHTMLConv.kStructPhrase | Ci.mozITXTToHTMLConv.kURLs;
+ description = gTextToHtmlConverter.scanTXT(description, mode);
+ return description.replace(/\r?\n/g, "<br>");
+ },
+
+ set descriptionHTML(html) {
+ if (html) {
+ // We need to output a plaintext version of the description, even if we're
+ // using the ALTREP parameter. We use the "preformatted" option in case
+ // the HTML contains a <pre/> tag with newlines.
+ let mode =
+ Ci.nsIDocumentEncoder.OutputDropInvisibleBreak |
+ Ci.nsIDocumentEncoder.OutputLFLineBreak |
+ Ci.nsIDocumentEncoder.OutputPreformatted;
+ let text = gParserUtils.convertToPlainText(html, mode, 0);
+
+ this.setProperty("DESCRIPTION", text);
+
+ // If the text is non-empty, create a standard ALTREP representation of
+ // the description as HTML.
+ // N.B. There's logic in nsMsgCompose for determining if HTML is
+ // convertible to plaintext without losing formatting. We could test if we
+ // could leave this part off if we generalized that logic.
+ if (text) {
+ this.setPropertyParameter(
+ "DESCRIPTION",
+ "ALTREP",
+ "data:text/html," + encodeURIComponent(html)
+ );
+ }
+ } else {
+ this.deleteProperty("DESCRIPTION");
+ }
+ },
+
+ // Each inner array has two elements: a string and a nsIVariant.
+ // readonly attribute Array<Array<jsval> > properties;
+ get properties() {
+ let properties = this.mProperties;
+ if (this.mIsProxy) {
+ let parentProperties = this.mParentItem.wrappedJSObject.mProperties;
+ let thisProperties = this.mProperties;
+ properties = new Map(
+ (function* () {
+ yield* parentProperties;
+ yield* thisProperties;
+ })()
+ );
+ }
+
+ return [...properties.entries()];
+ },
+
+ // nsIVariant getProperty(in AString name);
+ getProperty(aName) {
+ let name = aName.toUpperCase();
+ if (this.mProperties.has(name)) {
+ return this.mProperties.get(name);
+ }
+ return this.mIsProxy ? this.mParentItem.getProperty(name) : null;
+ },
+
+ // boolean hasProperty(in AString name);
+ hasProperty(aName) {
+ return this.getProperty(aName) != null;
+ },
+
+ // void setProperty(in AString name, in nsIVariant value);
+ setProperty(aName, aValue) {
+ this.modify();
+ aName = aName.toUpperCase();
+ if (aValue || !isNaN(parseInt(aValue, 10))) {
+ this.mProperties.set(aName, aValue);
+ if (!(aName in this.mPropertyParams)) {
+ this.mPropertyParams[aName] = {};
+ }
+ } else {
+ this.deleteProperty(aName);
+ }
+ if (aName == "LAST-MODIFIED") {
+ // setting LAST-MODIFIED cleans/undirties the item, we use this for preserving DTSTAMP
+ this.mDirty = false;
+ }
+ },
+
+ // void deleteProperty(in AString name);
+ deleteProperty(aName) {
+ this.modify();
+ aName = aName.toUpperCase();
+ if (this.mIsProxy) {
+ // deleting a proxy's property will mark the bag's item as null, so we could
+ // distinguish it when enumerating/getting properties from the undefined ones.
+ this.mProperties.set(aName, null);
+ } else {
+ this.mProperties.delete(aName);
+ }
+ delete this.mPropertyParams[aName];
+ },
+
+ // AString getPropertyParameter(in AString aPropertyName,
+ // in AString aParameterName);
+ getPropertyParameter(aPropName, aParamName) {
+ let propName = aPropName.toUpperCase();
+ let paramName = aParamName.toUpperCase();
+ if (propName in this.mPropertyParams) {
+ if (paramName in this.mPropertyParams[propName]) {
+ // If the property is not in mPropertyParams, then this just means
+ // there are no properties set.
+ return this.mPropertyParams[propName][paramName];
+ }
+ return null;
+ }
+ return this.mIsProxy ? this.mParentItem.getPropertyParameter(propName, paramName) : null;
+ },
+
+ // boolean hasPropertyParameter(in AString aPropertyName,
+ // in AString aParameterName);
+ hasPropertyParameter(aPropName, aParamName) {
+ return this.getPropertyParameter(aPropName, aParamName) != null;
+ },
+
+ // void setPropertyParameter(in AString aPropertyName,
+ // in AString aParameterName,
+ // in AUTF8String aParameterValue);
+ setPropertyParameter(aPropName, aParamName, aParamValue) {
+ let propName = aPropName.toUpperCase();
+ let paramName = aParamName.toUpperCase();
+ this.modify();
+ if (!(propName in this.mPropertyParams)) {
+ if (this.hasProperty(propName)) {
+ this.mPropertyParams[propName] = {};
+ } else {
+ throw new Error("Property " + aPropName + " not set");
+ }
+ }
+ if (aParamValue || !isNaN(parseInt(aParamValue, 10))) {
+ this.mPropertyParams[propName][paramName] = aParamValue;
+ } else {
+ delete this.mPropertyParams[propName][paramName];
+ }
+ return aParamValue;
+ },
+
+ // Array<AString> getParameterNames(in AString aPropertyName);
+ getParameterNames(aPropName) {
+ let propName = aPropName.toUpperCase();
+ if (!(propName in this.mPropertyParams)) {
+ if (this.mIsProxy) {
+ return this.mParentItem.getParameterNames(aPropName);
+ }
+ throw new Error("Property " + aPropName + " not set");
+ }
+ return Object.keys(this.mPropertyParams[propName]);
+ },
+
+ // Array<calIAttendee> getAttendees();
+ getAttendees() {
+ if (!this.mAttendees && this.mIsProxy) {
+ this.mAttendees = this.mParentItem.getAttendees();
+ }
+ if (this.mAttendees) {
+ return Array.from(this.mAttendees); // clone
+ }
+ return [];
+ },
+
+ // calIAttendee getAttendeeById(in AUTF8String id);
+ getAttendeeById(id) {
+ let attendees = this.getAttendees();
+ let lowerCaseId = id.toLowerCase();
+ for (let attendee of attendees) {
+ // This match must be case insensitive to deal with differing
+ // cases of things like MAILTO:
+ if (attendee.id.toLowerCase() == lowerCaseId) {
+ return attendee;
+ }
+ }
+ return null;
+ },
+
+ // void removeAttendee(in calIAttendee attendee);
+ removeAttendee(attendee) {
+ this.modify();
+ let found = false,
+ newAttendees = [];
+ let attendees = this.getAttendees();
+ let attIdLowerCase = attendee.id.toLowerCase();
+
+ for (let i = 0; i < attendees.length; i++) {
+ if (attendees[i].id.toLowerCase() == attIdLowerCase) {
+ found = true;
+ } else {
+ newAttendees.push(attendees[i]);
+ }
+ }
+ if (found) {
+ this.mAttendees = newAttendees;
+ }
+ },
+
+ // void removeAllAttendees();
+ removeAllAttendees() {
+ this.modify();
+ this.mAttendees = [];
+ },
+
+ // void addAttendee(in calIAttendee attendee);
+ addAttendee(attendee) {
+ if (!attendee.id) {
+ cal.LOG("Tried to add invalid attended");
+ return;
+ }
+ // the duplicate check is migration code for bug 1204255
+ let exists = this.getAttendeeById(attendee.id);
+ if (exists) {
+ cal.LOG(
+ "Ignoring attendee duplicate for item " + this.id + " (" + this.title + "): " + exists.id
+ );
+ if (
+ exists.participationStatus == "NEEDS-ACTION" ||
+ attendee.participationStatus == "DECLINED"
+ ) {
+ this.removeAttendee(exists);
+ } else {
+ attendee = null;
+ }
+ }
+ if (attendee) {
+ if (attendee.commonName) {
+ // migration code for bug 1209399 to remove leading/training double quotes in
+ let commonName = attendee.commonName.replace(/^["]*([^"]*)["]*$/, "$1");
+ if (commonName.length == 0) {
+ commonName = null;
+ }
+ if (commonName != attendee.commonName) {
+ if (attendee.isMutable) {
+ attendee.commonName = commonName;
+ } else {
+ cal.LOG(
+ "Failed to cleanup malformed commonName for immutable attendee " +
+ attendee.toString() +
+ "\n" +
+ cal.STACK(20)
+ );
+ }
+ }
+ }
+ this.modify();
+ this.mAttendees = this.getAttendees();
+ this.mAttendees.push(attendee);
+ }
+ },
+
+ // Array<calIAttachment> getAttachments();
+ getAttachments() {
+ if (!this.mAttachments && this.mIsProxy) {
+ this.mAttachments = this.mParentItem.getAttachments();
+ }
+ if (this.mAttachments) {
+ return this.mAttachments.concat([]); // clone
+ }
+ return [];
+ },
+
+ // void removeAttachment(in calIAttachment attachment);
+ removeAttachment(aAttachment) {
+ this.modify();
+ for (let attIndex in this.mAttachments) {
+ if (cal.data.compareObjects(this.mAttachments[attIndex], aAttachment, Ci.calIAttachment)) {
+ this.modify();
+ this.mAttachments.splice(attIndex, 1);
+ break;
+ }
+ }
+ },
+
+ // void addAttachment(in calIAttachment attachment);
+ addAttachment(attachment) {
+ this.modify();
+ this.mAttachments = this.getAttachments();
+ if (!this.mAttachments.some(x => x.hashId == attachment.hashId)) {
+ this.mAttachments.push(attachment);
+ }
+ },
+
+ // void removeAllAttachments();
+ removeAllAttachments() {
+ this.modify();
+ this.mAttachments = [];
+ },
+
+ // Array<calIRelation> getRelations();
+ getRelations() {
+ if (!this.mRelations && this.mIsProxy) {
+ this.mRelations = this.mParentItem.getRelations();
+ }
+ if (this.mRelations) {
+ return this.mRelations.concat([]);
+ }
+ return [];
+ },
+
+ // void removeRelation(in calIRelation relation);
+ removeRelation(aRelation) {
+ this.modify();
+ for (let attIndex in this.mRelations) {
+ // Could we have the same item as parent and as child ?
+ if (
+ this.mRelations[attIndex].relId == aRelation.relId &&
+ this.mRelations[attIndex].relType == aRelation.relType
+ ) {
+ this.modify();
+ this.mRelations.splice(attIndex, 1);
+ break;
+ }
+ }
+ },
+
+ // void addRelation(in calIRelation relation);
+ addRelation(aRelation) {
+ this.modify();
+ this.mRelations = this.getRelations();
+ this.mRelations.push(aRelation);
+ // XXX ensure that the relation isn't already there?
+ },
+
+ // void removeAllRelations();
+ removeAllRelations() {
+ this.modify();
+ this.mRelations = [];
+ },
+
+ // attribute calICalendar calendar;
+ get calendar() {
+ if (!this.mCalendar && this.parentItem != this) {
+ return this.parentItem.calendar;
+ }
+ return this.mCalendar;
+ },
+ set calendar(calendar) {
+ if (this.mImmutable) {
+ throw Components.Exception("", Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ }
+ this.mHashId = null; // recompute hashId
+ this.mCalendar = calendar;
+ },
+
+ // attribute calIAttendee organizer;
+ get organizer() {
+ if (this.mIsProxy && this.mOrganizer === undefined) {
+ return this.mParentItem.organizer;
+ }
+ return this.mOrganizer;
+ },
+ set organizer(organizer) {
+ this.modify();
+ this.mOrganizer = organizer;
+ },
+
+ // Array<AString> getCategories();
+ getCategories() {
+ if (!this.mCategories && this.mIsProxy) {
+ this.mCategories = this.mParentItem.getCategories();
+ }
+ if (this.mCategories) {
+ return this.mCategories.concat([]); // clone
+ }
+ return [];
+ },
+
+ // void setCategories(in Array<AString> aCategories);
+ setCategories(aCategories) {
+ this.modify();
+ this.mCategories = aCategories.concat([]);
+ },
+
+ // attribute AUTF8String icalString;
+ get icalString() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set icalString(str) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /**
+ * The map of promoted properties is a list of those properties that are
+ * represented directly by getters/setters.
+ * All of these property names must be in upper case isPropertyPromoted to
+ * function correctly. The has/get/set/deleteProperty interfaces
+ * are case-insensitive, but these are not.
+ */
+ itemBasePromotedProps: {
+ CREATED: true,
+ UID: true,
+ "LAST-MODIFIED": true,
+ SUMMARY: true,
+ PRIORITY: true,
+ STATUS: true,
+ DTSTAMP: true,
+ RRULE: true,
+ EXDATE: true,
+ RDATE: true,
+ ATTENDEE: true,
+ ATTACH: true,
+ CATEGORIES: true,
+ ORGANIZER: true,
+ "RECURRENCE-ID": true,
+ "X-MOZ-LASTACK": true,
+ "RELATED-TO": true,
+ },
+
+ /**
+ * A map of properties that need translation between the ical component
+ * property and their ICS counterpart.
+ */
+ icsBasePropMap: [
+ { cal: "CREATED", ics: "createdTime" },
+ { cal: "LAST-MODIFIED", ics: "lastModified" },
+ { cal: "DTSTAMP", ics: "stampTime" },
+ { cal: "UID", ics: "uid" },
+ { cal: "SUMMARY", ics: "summary" },
+ { cal: "PRIORITY", ics: "priority" },
+ { cal: "STATUS", ics: "status" },
+ { cal: "RECURRENCE-ID", ics: "recurrenceId" },
+ ],
+
+ /**
+ * Walks through the propmap and sets all properties on this item from the
+ * given icalcomp.
+ *
+ * @param icalcomp The calIIcalComponent to read from.
+ * @param propmap The property map to walk through.
+ */
+ mapPropsFromICS(icalcomp, propmap) {
+ for (let i = 0; i < propmap.length; i++) {
+ let prop = propmap[i];
+ let val = icalcomp[prop.ics];
+ if (val != null && val != Ci.calIIcalComponent.INVALID_VALUE) {
+ this.setProperty(prop.cal, val);
+ }
+ }
+ },
+
+ /**
+ * Walks through the propmap and sets all properties on the given icalcomp
+ * from the properties set on this item.
+ * given icalcomp.
+ *
+ * @param icalcomp The calIIcalComponent to write to.
+ * @param propmap The property map to walk through.
+ */
+ mapPropsToICS(icalcomp, propmap) {
+ for (let i = 0; i < propmap.length; i++) {
+ let prop = propmap[i];
+ let val = this.getProperty(prop.cal);
+ if (val != null && val != Ci.calIIcalComponent.INVALID_VALUE) {
+ icalcomp[prop.ics] = val;
+ }
+ }
+ },
+
+ /**
+ * Reads an ical component and sets up the base item's properties to match
+ * it.
+ *
+ * @param icalcomp The ical component to read.
+ */
+ setItemBaseFromICS(icalcomp) {
+ this.modify();
+
+ // re-initializing from scratch -- no light proxy anymore:
+ this.mIsProxy = false;
+ this.mProperties = new Map();
+ this.mPropertyParams = {};
+
+ this.mapPropsFromICS(icalcomp, this.icsBasePropMap);
+
+ this.mAttendees = []; // don't inherit anything from parent
+ for (let attprop of cal.iterate.icalProperty(icalcomp, "ATTENDEE")) {
+ let att = new CalAttendee();
+ att.icalProperty = attprop;
+ this.addAttendee(att);
+ }
+
+ this.mAttachments = []; // don't inherit anything from parent
+ for (let attprop of cal.iterate.icalProperty(icalcomp, "ATTACH")) {
+ let att = new CalAttachment();
+ att.icalProperty = attprop;
+ this.addAttachment(att);
+ }
+
+ this.mRelations = []; // don't inherit anything from parent
+ for (let relprop of cal.iterate.icalProperty(icalcomp, "RELATED-TO")) {
+ let rel = new CalRelation();
+ rel.icalProperty = relprop;
+ this.addRelation(rel);
+ }
+
+ let org = null;
+ let orgprop = icalcomp.getFirstProperty("ORGANIZER");
+ if (orgprop) {
+ org = new CalAttendee();
+ org.icalProperty = orgprop;
+ org.isOrganizer = true;
+ }
+ this.mOrganizer = org;
+
+ this.mCategories = [];
+ for (let catprop of cal.iterate.icalProperty(icalcomp, "CATEGORIES")) {
+ this.mCategories.push(catprop.value);
+ }
+
+ // find recurrence properties
+ let rec = null;
+ if (!this.recurrenceId) {
+ for (let recprop of cal.iterate.icalProperty(icalcomp)) {
+ let ritem = null;
+ switch (recprop.propertyName) {
+ case "RRULE":
+ case "EXRULE":
+ ritem = cal.createRecurrenceRule();
+ break;
+ case "RDATE":
+ case "EXDATE":
+ ritem = cal.createRecurrenceDate();
+ break;
+ default:
+ continue;
+ }
+ ritem.icalProperty = recprop;
+
+ if (!rec) {
+ rec = new CalRecurrenceInfo(this);
+ }
+ rec.appendRecurrenceItem(ritem);
+ }
+ }
+ this.mRecurrenceInfo = rec;
+
+ this.mAlarms = []; // don't inherit anything from parent
+ for (let alarmComp of cal.iterate.icalSubcomponent(icalcomp, "VALARM")) {
+ let alarm = new CalAlarm();
+ try {
+ alarm.icalComponent = alarmComp;
+ this.addAlarm(alarm, true);
+ } catch (e) {
+ cal.ERROR(
+ "Invalid alarm for item: " +
+ this.id +
+ " (" +
+ alarmComp.serializeToICS() +
+ ")" +
+ " exception: " +
+ e
+ );
+ }
+ }
+
+ let lastAck = icalcomp.getFirstProperty("X-MOZ-LASTACK");
+ this.mAlarmLastAck = null;
+ if (lastAck) {
+ this.mAlarmLastAck = cal.createDateTime(lastAck.value);
+ }
+
+ this.mDirty = false;
+ },
+
+ /**
+ * Import all properties not in the promoted map into this item's extended
+ * properties bag.
+ *
+ * @param icalcomp The ical component to read.
+ * @param promoted The map of promoted properties.
+ */
+ importUnpromotedProperties(icalcomp, promoted) {
+ for (let prop of cal.iterate.icalProperty(icalcomp)) {
+ let propName = prop.propertyName;
+ if (!promoted[propName]) {
+ this.setProperty(propName, prop.value);
+ for (let [paramName, paramValue] of cal.iterate.icalParameter(prop)) {
+ if (!(propName in this.mPropertyParams)) {
+ this.mPropertyParams[propName] = {};
+ }
+ this.mPropertyParams[propName][paramName] = paramValue;
+ }
+ }
+ }
+ },
+
+ // boolean isPropertyPromoted(in AString name);
+ isPropertyPromoted(name) {
+ return this.itemBasePromotedProps[name.toUpperCase()];
+ },
+
+ // attribute calIIcalComponent icalComponent;
+ get icalComponent() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set icalComponent(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ // attribute PRUint32 generation;
+ get generation() {
+ let gen = this.getProperty("X-MOZ-GENERATION");
+ return gen ? parseInt(gen, 10) : 0;
+ },
+ set generation(aValue) {
+ this.setProperty("X-MOZ-GENERATION", String(aValue));
+ },
+
+ /**
+ * Fills the passed ical component with the base item's properties.
+ *
+ * @param icalcomp The ical component to write to.
+ */
+ fillIcalComponentFromBase(icalcomp) {
+ this.ensureNotDirty();
+
+ this.mapPropsToICS(icalcomp, this.icsBasePropMap);
+
+ let org = this.organizer;
+ if (org) {
+ icalcomp.addProperty(org.icalProperty);
+ }
+
+ for (let attendee of this.getAttendees()) {
+ icalcomp.addProperty(attendee.icalProperty);
+ }
+
+ for (let attachment of this.getAttachments()) {
+ icalcomp.addProperty(attachment.icalProperty);
+ }
+
+ for (let relation of this.getRelations()) {
+ icalcomp.addProperty(relation.icalProperty);
+ }
+
+ if (this.mRecurrenceInfo) {
+ for (let ritem of this.mRecurrenceInfo.getRecurrenceItems()) {
+ icalcomp.addProperty(ritem.icalProperty);
+ }
+ }
+
+ for (let cat of this.getCategories()) {
+ let catprop = cal.icsService.createIcalProperty("CATEGORIES");
+ catprop.value = cat;
+ icalcomp.addProperty(catprop);
+ }
+
+ if (this.mAlarms) {
+ for (let alarm of this.mAlarms) {
+ icalcomp.addSubcomponent(alarm.icalComponent);
+ }
+ }
+
+ let alarmLastAck = this.alarmLastAck;
+ if (alarmLastAck) {
+ let lastAck = cal.icsService.createIcalProperty("X-MOZ-LASTACK");
+ // - should we further ensure that those are UTC or rely on calAlarmService doing so?
+ lastAck.value = alarmLastAck.icalString;
+ icalcomp.addProperty(lastAck);
+ }
+ },
+
+ // Array<calIAlarm> getAlarms();
+ getAlarms() {
+ if (!this.mAlarms && this.mIsProxy) {
+ this.mAlarms = this.mParentItem.getAlarms();
+ }
+ if (this.mAlarms) {
+ return this.mAlarms.concat([]); // clone
+ }
+ return [];
+ },
+
+ /**
+ * Adds an alarm. The second parameter is for internal use only, i.e not
+ * provided on the interface.
+ *
+ * @see calIItemBase
+ * @param aDoNotValidate Don't serialize the component to check for
+ * errors.
+ */
+ addAlarm(aAlarm, aDoNotValidate) {
+ if (!aDoNotValidate) {
+ try {
+ // Trigger the icalComponent getter to make sure the alarm is valid.
+ aAlarm.icalComponent; // eslint-disable-line no-unused-expressions
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+
+ this.modify();
+ this.mAlarms = this.getAlarms();
+ this.mAlarms.push(aAlarm);
+ },
+
+ // void deleteAlarm(in calIAlarm aAlarm);
+ deleteAlarm(aAlarm) {
+ this.modify();
+ this.mAlarms = this.getAlarms();
+ for (let i = 0; i < this.mAlarms.length; i++) {
+ if (cal.data.compareObjects(this.mAlarms[i], aAlarm, Ci.calIAlarm)) {
+ this.mAlarms.splice(i, 1);
+ break;
+ }
+ }
+ },
+
+ // void clearAlarms();
+ clearAlarms() {
+ this.modify();
+ this.mAlarms = [];
+ },
+
+ // Array<calIItemBase> getOccurrencesBetween(in calIDateTime aStartDate, in calIDateTime aEndDate);
+ getOccurrencesBetween(aStartDate, aEndDate) {
+ if (this.recurrenceInfo) {
+ return this.recurrenceInfo.getOccurrences(aStartDate, aEndDate, 0);
+ }
+
+ if (cal.item.checkIfInRange(this, aStartDate, aEndDate)) {
+ return [this];
+ }
+
+ return [];
+ },
+};
+
+makeMemberAttrProperty(calItemBase, "CREATED", "creationDate");
+makeMemberAttrProperty(calItemBase, "SUMMARY", "title");
+makeMemberAttrProperty(calItemBase, "PRIORITY", "priority");
+makeMemberAttrProperty(calItemBase, "CLASS", "privacy");
+makeMemberAttrProperty(calItemBase, "STATUS", "status");
+makeMemberAttrProperty(calItemBase, "ALARMTIME", "alarmTime");
+
+/**
+ * Adds a member attribute to the given prototype.
+ *
+ * @param {Function} ctor - The constructor function of the prototype.
+ * @param {string} varname - The variable name to get/set.
+ * @param {string} attr - The attribute name to be used.
+ * @param {*} dflt - The default value in case none is set.
+ */
+function makeMemberAttr(ctor, varname, attr, dflt) {
+ let getter = function () {
+ return varname in this ? this[varname] : dflt;
+ };
+ let setter = function (value) {
+ this.modify();
+ this[varname] = value;
+ return value;
+ };
+
+ ctor.prototype.__defineGetter__(attr, getter);
+ ctor.prototype.__defineSetter__(attr, setter);
+}
+
+/**
+ * Adds a member attribute to the given prototype, using `getProperty` and
+ * `setProperty` for access.
+ *
+ * Default values are not handled here, but instead are set in constructors,
+ * which makes it possible to e.g. iterate through `mProperties` when cloning
+ * an object.
+ *
+ * @param {Function} ctor - The constructor function of the prototype.
+ * @param {string} name - The property name to get/set.
+ * @param {string} attr - The attribute name to be used.
+ */
+function makeMemberAttrProperty(ctor, name, attr) {
+ let getter = function () {
+ return this.getProperty(name);
+ };
+ let setter = function (value) {
+ this.modify();
+ return this.setProperty(name, value);
+ };
+ ctor.prototype.__defineGetter__(attr, getter);
+ ctor.prototype.__defineSetter__(attr, setter);
+}
diff --git a/comm/calendar/base/src/components.conf b/comm/calendar/base/src/components.conf
new file mode 100644
index 0000000000..b28918ca2d
--- /dev/null
+++ b/comm/calendar/base/src/components.conf
@@ -0,0 +1,208 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ "cid": "{7463258c-6ef3-40a2-89a9-bb349596e927}",
+ "contract_ids": ["@mozilla.org/calendar/acl-manager;1?type=default"],
+ "jsm": "resource:///modules/CalDefaultACLManager.jsm",
+ "constructor": "CalDefaultACLManager",
+ },
+ {
+ "cid": "{fcbadec9-aab7-49e2-a20a-8e93f41d68d1}",
+ "interfaces": ["calITimezoneDatabase"],
+ "contract_ids": ["@mozilla.org/calendar/timezone-database;1"],
+ "type": "TimezoneDatabase",
+ "headers": ["/comm/calendar/base/src/TimezoneDatabase.h"],
+ },
+ {
+ "cid": "{e736f2bd-7640-4715-ab35-887dc866c587}",
+ "contract_ids": ["@mozilla.org/calendar/timezone-service;1"],
+ "jsm": "resource:///modules/CalTimezoneService.jsm",
+ "constructor": "CalTimezoneService",
+ },
+ {
+ "cid": "{b8db7c7f-c168-4e11-becb-f26c1c4f5f8f}",
+ "contract_ids": ["@mozilla.org/calendar/alarm;1"],
+ "jsm": "resource:///modules/CalAlarm.jsm",
+ "constructor": "CalAlarm",
+ },
+ {
+ "cid": "{4b7ae030-ed79-11d9-8cd6-0800200c9a66}",
+ "contract_ids": ["@mozilla.org/calendar/alarm-monitor;1"],
+ "jsm": "resource:///modules/CalAlarmMonitor.jsm",
+ "constructor": "CalAlarmMonitor",
+ },
+ {
+ "cid": "{7a9200dd-6a64-4fff-a798-c5802186e2cc}",
+ "contract_ids": ["@mozilla.org/calendar/alarm-service;1"],
+ "jsm": "resource:///modules/CalAlarmService.jsm",
+ "constructor": "CalAlarmService",
+ },
+ {
+ "cid": "{5f76b352-ab75-4c2b-82c9-9206dbbf8571}",
+ "contract_ids": ["@mozilla.org/calendar/attachment;1"],
+ "jsm": "resource:///modules/CalAttachment.jsm",
+ "constructor": "CalAttachment",
+ },
+ {
+ "cid": "{5c8dcaa3-170c-4a73-8142-d531156f664d}",
+ "contract_ids": ["@mozilla.org/calendar/attendee;1"],
+ "jsm": "resource:///modules/CalAttendee.jsm",
+ "constructor": "CalAttendee",
+ },
+ {
+ "cid": "{f42585e7-e736-4600-985d-9624c1c51992}",
+ "contract_ids": ["@mozilla.org/calendar/manager;1"],
+ "jsm": "resource:///modules/CalCalendarManager.jsm",
+ "constructor": "CalCalendarManager",
+ },
+ {
+ "cid": "{36783242-ec94-4d8a-9248-d2679edd55b9}",
+ "contract_ids": ["@mozilla.org/calendar/datetime;1"],
+ "jsm": "resource:///modules/CalDateTime.jsm",
+ "constructor": "CalDateTime",
+ },
+ {
+ "cid": "{8e6799af-e7e9-4e6c-9a82-a2413e86d8c3}",
+ "contract_ids": ["@mozilla.org/calendar/deleted-items-manager;1"],
+ "jsm": "resource:///modules/CalDeletedItems.jsm",
+ "constructor": "CalDeletedItems",
+ "categories": {"profile-after-change": "deleted-items-manager"},
+ },
+ {
+ "cid": "{7436f480-c6fc-4085-9655-330b1ee22288}",
+ "contract_ids": ["@mozilla.org/calendar/duration;1"],
+ "jsm": "resource:///modules/CalDuration.jsm",
+ "constructor": "CalDuration",
+ },
+ {
+ "cid": "{974339d5-ab86-4491-aaaf-2b2ca177c12b}",
+ "contract_ids": ["@mozilla.org/calendar/event;1"],
+ "jsm": "resource:///modules/CalEvent.jsm",
+ "constructor": "CalEvent",
+ },
+ {
+ "cid": "{29c56cd5-d36e-453a-acde-0083bd4fe6d3}",
+ "contract_ids": ["@mozilla.org/calendar/freebusy-service;1"],
+ "jsm": "resource:///modules/CalFreeBusyService.jsm",
+ "constructor": "CalFreeBusyService",
+ },
+ {
+ "cid": "{6fe88047-75b6-4874-80e8-5f5800f14984}",
+ "contract_ids": ["@mozilla.org/calendar/ics-parser;1"],
+ "jsm": "resource:///modules/CalIcsParser.jsm",
+ "constructor": "CalIcsParser",
+ },
+ {
+ "cid": "{207a6682-8ff1-4203-9160-729ec28c8766}",
+ "contract_ids": ["@mozilla.org/calendar/ics-serializer;1"],
+ "jsm": "resource:///modules/CalIcsSerializer.jsm",
+ "constructor": "CalIcsSerializer",
+ },
+ {
+ "cid": "{c61cb903-4408-41b3-bc22-da0b27efdfe1}",
+ "contract_ids": ["@mozilla.org/calendar/ics-service;1"],
+ "jsm": "resource:///modules/CalICSService.jsm",
+ "constructor": "CalICSService",
+ },
+ {
+ "cid": "{f41392ab-dcad-4bad-818f-b3d1631c4d93}",
+ "contract_ids": ["@mozilla.org/calendar/itip-item;1"],
+ "jsm": "resource:///modules/CalItipItem.jsm",
+ "constructor": "CalItipItem",
+ },
+ {
+ "cid": "{394a281f-7299-45f7-8b1f-cce21258972f}",
+ "contract_ids": ["@mozilla.org/calendar/period;1"],
+ "jsm": "resource:///modules/CalPeriod.jsm",
+ "constructor": "CalPeriod",
+ },
+ {
+ "cid": "{1153c73a-39be-46aa-9ba9-656d188865ca}",
+ "contract_ids": ["@mozilla.org/network/protocol;1?name=webcal"],
+ "jsm": "resource:///modules/CalProtocolHandler.jsm",
+ "constructor": "CalProtocolHandlerWebcal",
+ "protocol_config": {
+ "scheme": "webcal",
+ "flags": [
+ "URI_STD",
+ "ALLOWS_PROXY",
+ "ALLOWS_PROXY_HTTP",
+ "URI_LOADABLE_BY_ANYONE",
+ "URI_IS_POTENTIALLY_TRUSTWORTHY",
+ ],
+ "default_port": 80,
+ },
+ },
+ {
+ "cid": "{bdf71224-365d-4493-856a-a7e74026f766}",
+ "contract_ids": ["@mozilla.org/network/protocol;1?name=webcals"],
+ "jsm": "resource:///modules/CalProtocolHandler.jsm",
+ "constructor": "CalProtocolHandlerWebcals",
+ "protocol_config": {
+ "scheme": "webcals",
+ "flags": [
+ "URI_STD",
+ "ALLOWS_PROXY",
+ "ALLOWS_PROXY_HTTP",
+ "URI_LOADABLE_BY_ANYONE",
+ "URI_IS_POTENTIALLY_TRUSTWORTHY",
+ ],
+ "default_port": 443,
+ },
+ },
+ {
+ "cid": "{806b6423-3aaa-4b26-afa3-de60563e9cec}",
+ "contract_ids": ["@mozilla.org/calendar/recurrence-date;1"],
+ "jsm": "resource:///modules/CalRecurrenceDate.jsm",
+ "constructor": "CalRecurrenceDate",
+ },
+ {
+ "cid": "{04027036-5884-4a30-b4af-f2cad79f6edf}",
+ "contract_ids": ["@mozilla.org/calendar/recurrence-info;1"],
+ "jsm": "resource:///modules/CalRecurrenceInfo.jsm",
+ "constructor": "CalRecurrenceInfo",
+ },
+ {
+ "cid": "{df19281a-5389-4146-b941-798cb93a7f0d}",
+ "contract_ids": ["@mozilla.org/calendar/recurrence-rule;1"],
+ "jsm": "resource:///modules/CalRecurrenceRule.jsm",
+ "constructor": "CalRecurrenceRule",
+ },
+ {
+ "cid": "{76810fae-abad-4019-917a-08e95d5bbd68}",
+ "contract_ids": ["@mozilla.org/calendar/relation;1"],
+ "jsm": "resource:///modules/CalRelation.jsm",
+ "constructor": "CalRelation",
+ },
+ {
+ "cid": "{7af51168-6abe-4a31-984d-6f8a3989212d}",
+ "contract_ids": ["@mozilla.org/calendar/todo;1"],
+ "jsm": "resource:///modules/CalTodo.jsm",
+ "constructor": "CalTodo",
+ },
+ {
+ "cid": "{6877bbdd-f336-46f5-98ce-fe86d0285cc1}",
+ "contract_ids": ["@mozilla.org/calendar/weekinfo-service;1"],
+ "jsm": "resource:///modules/CalWeekInfoService.jsm",
+ "constructor": "CalWeekInfoService",
+ },
+ {
+ "cid": "{2547331f-34c0-4a4b-b93c-b503538ba6d6}",
+ "contract_ids": ["@mozilla.org/calendar/startup-service;1"],
+ "jsm": "resource:///modules/CalStartupService.jsm",
+ "constructor": "CalStartupService",
+ "categories": {"profile-after-change": "calendar-startup-service"},
+ },
+ {
+ "cid": "{c70acb08-464e-4e55-899d-b2c84c5409fa}",
+ "contract_ids": ["@mozilla.org/calendar/mime-converter;1"],
+ "jsm": "resource:///modules/CalMimeConverter.jsm",
+ "constructor": "CalMimeConverter",
+ "categories": {"simple-mime-converters": "text/calendar"},
+ },
+]
diff --git a/comm/calendar/base/src/moz.build b/comm/calendar/base/src/moz.build
new file mode 100644
index 0000000000..350262f182
--- /dev/null
+++ b/comm/calendar/base/src/moz.build
@@ -0,0 +1,71 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "calInternalInterfaces.idl",
+]
+
+XPIDL_MODULE = "calbaseinternal"
+
+EXTRA_JS_MODULES += [
+ "CalAlarm.jsm",
+ "CalAlarmMonitor.jsm",
+ "CalAlarmService.jsm",
+ "CalAttachment.jsm",
+ "CalAttendee.jsm",
+ "CalCalendarManager.jsm",
+ "CalDateTime.jsm",
+ "CalDefaultACLManager.jsm",
+ "CalDeletedItems.jsm",
+ "CalDuration.jsm",
+ "CalEvent.jsm",
+ "CalFreeBusyService.jsm",
+ "CalIcsParser.jsm",
+ "CalIcsSerializer.jsm",
+ "CalICSService.jsm",
+ "CalItipItem.jsm",
+ "CalMetronome.jsm",
+ "CalMimeConverter.jsm",
+ "CalPeriod.jsm",
+ "CalProtocolHandler.jsm",
+ "CalReadableStreamFactory.jsm",
+ "CalRecurrenceDate.jsm",
+ "CalRecurrenceInfo.jsm",
+ "CalRecurrenceRule.jsm",
+ "CalRelation.jsm",
+ "CalStartupService.jsm",
+ "CalTimezone.jsm",
+ "CalTimezoneService.jsm",
+ "CalTodo.jsm",
+ "CalTransactionManager.jsm",
+ "CalWeekInfoService.jsm",
+]
+
+EXPORTS += [
+ "TimezoneDatabase.h",
+]
+
+UNIFIED_SOURCES += [
+ "TimezoneDatabase.cpp",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+# These files go in components so they can be packaged correctly.
+FINAL_TARGET_FILES.components += [
+ "calCachedCalendar.js",
+ "calICSService-worker.js",
+ "calItemBase.js",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Internal Components")
+
+with Files("calAlarm*"):
+ BUG_COMPONENT = ("Calendar", "Alarms")
+
+FINAL_LIBRARY = "xul"
diff --git a/comm/calendar/base/themes/common/calendar-alarms.css b/comm/calendar/base/themes/common/calendar-alarms.css
new file mode 100644
index 0000000000..c45660c496
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-alarms.css
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.reminder-details > .alarm-icons-box {
+ width: 15px;
+}
+
+/**
+ * Reminder icons (used from the event dialog, reminder dialog, views, ...)
+ */
+.reminder-icon,
+.reminder-list-icon .menu-iconic-left,
+.reminder-list-icon::part(icon) {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.reminder-list-icon[value="DISPLAY"] {
+ list-style-image: var(--icon-bell)
+}
+
+.reminder-list-icon[value="EMAIL"] {
+ list-style-image: var(--icon-mail-sm);
+}
+
+.reminder-list-icon[value="AUDIO"] {
+ list-style-image: var(--icon-bell-ring);
+}
+
+.alarm-icons-box[flashing="true"] > .reminder-icon[value="DISPLAY"] {
+ background: red;
+ fill: white;
+}
+
+.reminder-details > .alarm-icons-box > .reminder-icon {
+ width: 12px;
+ height: 12px;
+ margin-inline-start: 3px;
+}
+
+/**
+ * Reminder dialog (i.e "custom" alarm in the event dialog)
+ * Please make sure rules added here are very specific and won't hurt other
+ * dialogs.
+ */
+#reminder-listbox {
+ min-height: 100px;
+}
+
+#reminder-relative-radio > .radio-label-center-box > .radio-label-box,
+#reminder-absolute-radio > .radio-label-center-box > .radio-label-box {
+ display: none;
+}
+
+#reminder-actions-menulist > menupopup > menuitem > .menu-iconic-left {
+ display: flex;
+}
+
+#reminder-notifications > notification[type="warning"] > hbox > .messageImage {
+ width: 32px;
+ height: 32px;
+ fill: #ffbf00;
+}
+
+#reminder-notifications {
+ padding: 0 4px 4px;
+}
+
+#reminder-actions-caption,
+#reminder-details-caption {
+ padding-top: 20px;
+}
+
+#reminder-actions-menulist {
+ margin-bottom: 20px;
+}
+
+.reminder-icon > .menu-iconic-left > .menu-iconic-icon {
+ width: auto;
+ height: auto;
+}
diff --git a/comm/calendar/base/themes/common/calendar-attendees.css b/comm/calendar/base/themes/common/calendar-attendees.css
new file mode 100644
index 0000000000..15ad0d9691
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-attendees.css
@@ -0,0 +1,216 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+/* this is for attendee and organizer decoration in summary and event dialog */
+html|input.textbox-addressingWidget {
+ width: 100%;
+ background-color: transparent !important;
+ flex: 1;
+}
+html|input.textbox-addressingWidget:disabled {
+ color: inherit;
+ opacity: 0.5;
+}
+
+#outer {
+ max-height: calc(100vh - 210px);
+}
+
+.attendee-list-container {
+ width: 350px;
+}
+
+.item-attendees-list-container {
+ flex: 1 1 100px;
+ appearance: auto;
+ -moz-default-appearance: listbox;
+ margin: 2px 4px 0;
+ overflow-y: auto;
+}
+
+:root[lwt-tree] .item-attendees-list-container {
+ appearance: none;
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+ border: 1px solid var(--field-border-color);
+}
+
+.attendee-list {
+ display: block;
+ padding: 0;
+ margin: 0;
+}
+
+.attendee-list-item {
+ display: contents;
+}
+
+.attendee-label {
+ padding: 2px;
+ display: flex;
+ align-items: baseline;
+ user-select: text;
+}
+
+.attendee-list .attendee-label:focus {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+.itip-icon {
+ flex: 0 0 auto;
+}
+
+.attendee-name {
+ margin: 0 3px;
+ flex: 0 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* this is for the itip icon setup in calendar */
+
+.itip-icon {
+ --itip-icon-partstat: -16px -16px; /* default: NEEDS-ACTION */
+ --itip-icon-role: 0px; /* default: REQ-PARTICIPANT */
+ --itip-icon-usertype: -32px; /* default: INDIVIDUAL */
+ width: 16px;
+ height: 16px;
+ background-image: url(chrome://calendar/skin/shared/calendar-itip-icons.svg),
+ url(chrome://calendar/skin/shared/calendar-itip-icons.svg);
+ background-position: var(--itip-icon-partstat), var(--itip-icon-usertype) var(--itip-icon-role);
+}
+.itip-icon[partstat="ACCEPTED"] {
+ --itip-icon-partstat: 0px 0px;
+}
+.itip-icon[partstat="DECLINED"] {
+ --itip-icon-partstat: 0px -16px;
+}
+.itip-icon[partstat="DELEGATED"] {
+ --itip-icon-partstat: 0px -32px;
+}
+.itip-icon[partstat="TENTATIVE"] {
+ --itip-icon-partstat: -16px 0px;
+}
+.itip-icon[usertype="INDIVIDUAL"] {
+ --itip-icon-usertype: -32px;
+}
+.itip-icon[usertype="GROUP"] {
+ --itip-icon-usertype: -48px;
+}
+.itip-icon[usertype="RESOURCE"] {
+ --itip-icon-usertype: -64px;
+}
+.itip-icon[usertype="ROOM"] {
+ --itip-icon-usertype: -80px;
+}
+.itip-icon[usertype="UNKNOWN"] {
+ --itip-icon-usertype: -96px;
+}
+.itip-icon[attendeerole="REQ-PARTICIPANT"] {
+ --itip-icon-role: 0px;
+}
+.itip-icon[attendeerole="OPT-PARTICIPANT"] {
+ --itip-icon-role: -16px;
+}
+.itip-icon[attendeerole="NON-PARTICIPANT"] {
+ --itip-icon-role: -32px;
+}
+.itip-icon[attendeerole="CHAIR"] {
+ --itip-icon-role: -32px;
+ --itip-icon-usertype: -16px;
+}
+
+/* Autocomplete popup label formatting */
+
+html|span.ac-emphasize-text {
+ font-weight: bold;
+}
+
+/* the following will get obsolete once porting to new itip icons is complete */
+
+.role-icon > .menu-iconic-left,
+.usertype-icon > .menu-iconic-left {
+ visibility: inherit;
+}
+
+.role-icon {
+ margin: 0 3px;
+ width: 21px;
+ height: 16px;
+ object-fit: none;
+ object-position: top 0 left -138px;
+}
+
+.role-icon[disabled="true"] {
+ object-position: top 0 left -138px;
+}
+
+.role-icon[attendeerole="REQ-PARTICIPANT"] {
+ object-position: top 0 left -138px;
+}
+.role-icon[attendeerole="REQ-PARTICIPANT"][disabled="true"] {
+ object-position: top 0 left -138px;
+}
+
+.role-icon[attendeerole="OPT-PARTICIPANT"] {
+ object-position: top 0 left -159px;
+}
+.role-icon[attendeerole="OPT-PARTICIPANT"][disabled="true"] {
+ object-position: top 0 left -159px;
+}
+
+.role-icon[attendeerole="CHAIR"] {
+ object-position: top 0 left -180px;
+}
+.role-icon[attendeerole="CHAIR"][disabled="true"] {
+ object-position: top 0 left -180px;
+}
+
+.role-icon[attendeerole="NON-PARTICIPANT"] {
+ object-position: top 0 left -201px;
+}
+.role-icon[attendeerole="NON-PARTICIPANT"][disabled="true"] {
+ object-position: top 0 left -201px;
+}
+
+.usertype-icon {
+ margin: 0 3px;
+ width: 16px;
+ height: 16px;
+ object-fit: none;
+ object-position: top 0 left 0;
+}
+
+.usertype-icon[usertype="INDIVIDUAL"] {
+ object-position: top 0 left 0;
+}
+.usertype-icon[disabled="true"],
+.usertype-icon[usertype="INDIVIDUAL"][disabled="true"] {
+ object-position: top -16px left 0;
+}
+
+.usertype-icon[usertype="GROUP"] {
+ object-position: top 0 left -16px;
+}
+.usertype-icon[usertype="GROUP"][disabled="true"] {
+ object-position: top -16px left -16px;
+}
+
+.usertype-icon[usertype="RESOURCE"] {
+ object-position: top 0 left -32px;
+}
+.usertype-icon[usertype="RESOURCE"][disabled="true"] {
+ object-position: top -16px left -32px;
+}
+
+.usertype-icon[usertype="ROOM"] {
+ object-position: top 0 left -48px;
+}
+.usertype-icon[usertype="ROOM"][disabled="true"] {
+ object-position: top -16px left -48px;
+}
diff --git a/comm/calendar/base/themes/common/calendar-creation.css b/comm/calendar/base/themes/common/calendar-creation.css
new file mode 100644
index 0000000000..e09678a4ec
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-creation.css
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+td {
+ width: 100%;
+}
+
+th {
+ font-weight: normal;
+ text-align: end;
+}
+
+/* Match row spacing of calendar properties dialog. */
+th,
+td {
+ min-height: 26px;
+}
+
+.calendar-creation-text-input {
+ width: -moz-available;
+}
+
+/* Network status messages and images. */
+
+.network-status-image {
+ width: 16px;
+ height: 16px;
+}
+
+.network-status-row {
+ margin-top: 1ex;
+}
+
+.network-status-row .status-label {
+ display: none;
+}
+.network-status-row[status="loading"] .network-loading-label {
+ display: flex;
+}
+.network-status-row[status="notfound"] .network-notfound-label {
+ display: flex;
+}
+.network-status-row[status="authfail"] .network-authfail-label {
+ display: flex;
+}
+
+.network-status-row:not([status="loading"]) .network-status-image {
+ display: none;
+}
+
+/* Network Calendar List */
+
+#network-calendar-list {
+ flex: 1 1 0;
+ appearance: none;
+ margin: 0;
+ border-style: none;
+ background-color: transparent;
+ color: inherit;
+ padding: 0 10px;
+}
+
+#network-calendar-list > richlistitem {
+ align-items: center;
+ background-color: transparent;
+ border: 1px transparent solid;
+}
+
+#network-calendar-list > richlistitem > checkbox > .checkbox-label-box {
+ display: none;
+}
+
+#network-calendar-list > richlistitem .calendar-color {
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+}
+
+#network-calendar-list > richlistitem .calendar-name {
+ flex: 1;
+}
+
+#network-calendar-list > richlistitem .calendar-edit-button {
+ min-width: 0px;
+ font-size: 0.82em;
+}
+
+#network-calendar-list > richlistitem[calendar-disabled] > .calendar-color {
+ filter: grayscale(1);
+}
+
+#network-calendar-list
+ > richlistitem[calendar-disabled]:not([selected="true"])
+ > .calendar-name {
+ color: #808080;
+}
diff --git a/comm/calendar/base/themes/common/calendar-daypicker.css b/comm/calendar/base/themes/common/calendar-daypicker.css
new file mode 100644
index 0000000000..f2e7962144
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-daypicker.css
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+button.calendar-daypicker {
+ cursor: pointer;
+ margin: 0;
+}
+
+button.calendar-daypicker[mode="monthly-days"] {
+ min-width: 35px;
+ height: 15px;
+}
+
+button.calendar-daypicker[mode="daypicker-weekday"] {
+ min-width: 36px;
+ height: 32px;
+}
+
+hbox:last-child > button.calendar-daypicker:last-child[mode="monthly-days"] {
+ width: 140px;
+ height: 15px;
+}
+
+button.calendar-daypicker[checked="true"] {
+ background-color: var(--selected-item-color) !important;
+ color: var(--selected-item-text-color) !important;
+}
diff --git a/comm/calendar/base/themes/common/calendar-invitation-display.css b/comm/calendar/base/themes/common/calendar-invitation-display.css
new file mode 100644
index 0000000000..0bcae6acc2
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-invitation-display.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendarInvitationDisplayContainer {
+ overflow-y: auto;
+}
+
+#calendarInvitationDisplay:not([hidden]) {
+ padding: 25px 0px;
+ display: flex;
+ justify-content: center;
+}
diff --git a/comm/calendar/base/themes/common/calendar-item-summary.css b/comm/calendar/base/themes/common/calendar-item-summary.css
new file mode 100644
index 0000000000..deabbdf2d9
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-item-summary.css
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendar-item-summary {
+ max-width: 100vw;
+}
+
+.calendar-summary-table {
+ width: 100%;
+}
+
+.calendar-summary-table th {
+ user-select: text;
+ font-weight: normal;
+ padding-inline-end: 5px;
+ text-align: right;
+ vertical-align: baseline;
+ white-space: nowrap;
+}
+
+.calendar-summary-table td {
+ user-select: text;
+ padding-top: 0;
+ vertical-align: baseline;
+ width: 100%;
+ max-width: 0;
+}
+
+.calendar-summary-table td.item-location {
+ vertical-align: top;
+}
+
+.calendar-summary-table .item-location-link > label {
+ margin-inline: 0;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+}
+
+.calendar-summary-table .item-organizer-cell {
+ margin-inline: 0px;
+}
+
+.calendar-summary-table .attachments-label {
+ vertical-align: top;
+}
+
+.item-summary-splitter {
+ appearance: none;
+ border-style: none;
+ border-bottom: 1px solid var(--field-border-color);
+ min-height: 0;
+ height: 10px;
+ background-color: transparent;
+ margin: 10px;
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+.item-summary-splitter[state="collapsed"] {
+ transition: border-color .3s;
+}
+
+.item-summary-splitter[state="collapsed"]:hover {
+ border-bottom: 4px solid var(--selected-item-color);
+}
+
+.item-description-box {
+ flex: 1 1 10em;
+}
+
+.item-description {
+ flex: 1 1 0;
+}
diff --git a/comm/calendar/base/themes/common/calendar-itip-icons.svg b/comm/calendar/base/themes/common/calendar-itip-icons.svg
new file mode 100644
index 0000000000..ba482e47b3
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-itip-icons.svg
@@ -0,0 +1,122 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 112 48" width="112" height="48">
+
+ <!-- definitions -->
+ <style>
+ .req {
+ fill: #ffcc00;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .opt {
+ fill: #cccccc;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .non {
+ fill: #ffffff;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .status {
+ fill:#ffffff;
+ stroke:#ffffff;
+ stroke-width:0.5;
+ }
+ </style>
+ <clipPath id="cut-off-bottom">
+ <rect x="0" y="0" width="16" height="16" />
+ </clipPath>
+ <symbol id="chairsymbol" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="6" cy="16" rx="5.5" ry="8" fill="#000000" />
+ <circle cx="6" cy="6.25" r="4" fill="#ffffff"/>
+ <ellipse cx="10" cy="16" rx="5.5" ry="8" fill="#000000" />
+ <circle cx="10" cy="6.25" r="4" fill="#ffffff"/>
+ <ellipse cx="8" cy="16" rx="6.5" ry="9" fill="#ffcc00" />
+ <circle cx="8" cy="4.5" r="4" fill="#ffcc00"/>
+ <line x1="0.5" y1="15.75" x2="15.5" y2="15.75" />
+ </symbol>
+ <symbol id="individual" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="8" cy="16" rx="7.5" ry="8.5" />
+ <circle cx="8" cy="5" r="4.5"/>
+ <line x1="0.5" y1="15.75" x2="15.5" y2="15.75" />
+ </symbol>
+ <symbol id="group" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="5.75" cy="16" rx="5.5" ry="8" />
+ <circle cx="5.75" cy="6.5" r="4"/>
+ <ellipse cx="7.5" cy="16" rx="6.0" ry="8.5" />
+ <circle cx="7.5" cy="5.5" r="4"/>
+ <ellipse cx="9.25" cy="16" rx="6.25" ry="9" />
+ <circle cx="9.25" cy="4.5" r="4"/>
+ <line x1="0.25" y1="15.75" x2="15.55" y2="15.75" />
+ </symbol>
+ <symbol id="resource">
+ <rect x="5.25" y="0.5" rx="1" ry="1" width="10.25" height="12" />
+ <rect x="0.25" y="7" rx="1" ry="1" width="13" height="8" />
+ <circle cx="4.25" cy="11" r="2.5" style="fill:#ffffff" />
+ <line x1="8.25" y1="9" x2="12" y2="9" />
+ <line x1="8.25" y1="11" x2="12" y2="11" />
+ <line x1="8.25" y1="13" x2="12" y2="13" />
+ <rect x="1.25" y="15" width="4" height="1" style="fill:#000000; stroke-width:0" />
+ <rect x="8" y="15" width="4" height="1" style="fill:#000000; stroke-width:0" />
+ </symbol>
+ <symbol id="room">
+ <rect x="4" y="0.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="8.5" y="0.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="0.25" y="4.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="0.25" y="8.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="3.5" y="4" rx="1" ry="1" width="8.75" height="8" />
+ <rect x="13.5" y="4.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="13.5" y="8.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="4" y="13.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="8.5" y="13.5" rx="0.25" ry="0.25" width="3" height="2" />
+ </symbol>
+ <symbol id="unknown">
+ <path d="m 7.8339844,11.558594 -2.4902344,0 q -0.00977,-0.53711 -0.00977,-0.654297 0,-1.2109376 0.4003906,-1.9921876 Q 6.1347656,8.1308594 7.3359375,7.1542969 8.5371094,6.1777344 8.7714844,5.875 9.1328125,5.3964844 9.1328125,4.8203125 q 0,-0.8007813 -0.6445312,-1.3671875 -0.6347657,-0.5761719 -1.7187501,-0.5761719 -1.0449218,0 -1.7480468,0.5957032 -0.703125,0.5957031 -0.9667969,1.8164062 l -2.5195313,-0.3125 Q 1.6425781,3.2285156 3.0195313,2.0078125 4.40625,0.78710937 6.6523437,0.78710937 q 2.3632813,0 3.7597653,1.24023443 1.396485,1.2304687 1.396485,2.8710937 0,0.9082031 -0.517578,1.71875 Q 10.783203,7.4277344 9.1035156,8.8242188 8.234375,9.546875 8.0195313,9.9863281 7.8144531,10.425781 7.8339844,11.558594 Z M 5.34375,15.25 l 0,-2.744141 2.7441406,0 0,2.744141 -2.7441406,0 z" />
+ </symbol>
+ <symbol id="status" style="stroke-width:0.25;">
+ <circle cx="11.5" cy="11.5" r="4.5" style="fill:#000000; stroke:#ffffff" />
+ <circle cx="11.5" cy="11.5" r="4.25" style="stroke:#000000" />
+ </symbol>
+
+ <!-- status icons -->
+ <g id="accepted" class="status">
+ <use style="fill:#00a000;" xlink:href="#status" x="0" y="0" />
+ <rect x="9" y="11" width="5" height="1" />
+ <rect x="11" y="9" width="1" height="5" />
+ </g>
+ <g id="tentative" class="status">
+ <use style="fill:#0000ff;" xlink:href="#status" x="16" y="0" />
+ <path d="m 27.933594,13 -0.996094,0 q -0.0039,-0.214844 -0.0039,-0.261719 0,-0.484375 0.160156,-0.796875 0.160156,-0.3125 0.640625,-0.703125 0.480469,-0.390625 0.574219,-0.511719 0.144531,-0.191406 0.144531,-0.421875 0,-0.3203125 -0.257813,-0.546875 -0.253906,-0.2304687 -0.6875,-0.2304687 -0.417968,0 -0.699218,0.2382812 -0.28125,0.2382813 -0.386719,0.7265625 l -1.007813,-0.125 q 0.04297,-0.6992187 0.59375,-1.1875 0.554688,-0.4882812 1.453125,-0.4882812 0.945313,0 1.503907,0.4960937 0.558593,0.4921875 0.558593,1.1484375 0,0.363281 -0.207031,0.6875 -0.203125,0.324219 -0.875,0.882813 -0.347656,0.289062 -0.433594,0.464843 -0.08203,0.175782 -0.07422,0.628907 z M 26.9375,14.25 l 0,-1.097656 1.097656,0 0,1.097656 -1.097656,0 z" />
+ </g>
+ <g id="declined" class="status">
+ <use style="fill:#ee0000;" xlink:href="#status" x="0" y="16" />
+ <rect x="9.5" y="26.75" width="4.25" height="1.5" />
+ </g>
+ <use id="needs-action" style="stroke-width:0.5; fill:#f4f444;" xlink:href="#status" x="16" y="16" />
+ <g id="delegated" class="status">
+ <use style="fill:#444444;" xlink:href="#status" x="0" y="32" />
+ <rect x="9" y="43" width="2.5" height="1" />
+ <polygon points="11.5,41, 11.5,46, 14.5,43.5" />
+ </g>
+
+ <!-- role/partstat icons -->
+ <use id="chair" class="req" xlink:href="#chairsymbol" x="16" y="32" />
+ <use id="individual-reqparticipant" class="req" xlink:href="#individual" x="32" y="0" />
+ <use id="group-reqparticipant" class="req" xlink:href="#group" x="48" y="0" />
+ <use id="resource-reqparticipant" class="req" xlink:href="#resource" x="64" y="0" />
+ <use id="room-reqparticipant" class="req" xlink:href="#room" x="80" y="0" />
+ <use id="unknown-reqparticipant" class="req" xlink:href="#unknown" x="96" y="0" />
+ <use id="individual-optparticipant" class="opt" xlink:href="#individual" x="32" y="16" />
+ <use id="group-optparticipant" class="opt" xlink:href="#group" x="48" y="16" />
+ <use id="resource-optparticipant" class="opt" xlink:href="#resource" x="64" y="16" />
+ <use id="room-optparticipant" class="opt" xlink:href="#room" x="80" y="16" />
+ <use id="unknown-optparticipant" class="opt" xlink:href="#unknown" x="96" y="16" />
+ <use id="individual-nonparticipant" class="non" xlink:href="#individual" x="32" y="32" />
+ <use id="group-nonparticipant" class="non" xlink:href="#group" x="48" y="32" />
+ <use id="resource-nonparticipant" class="non" xlink:href="#resource" x="64" y="32" />
+ <use id="room-nonparticipant" class="non" xlink:href="#room" x="80" y="32" />
+ <use id="unknown-nonparticipant" class="non" xlink:href="#unknown" x="96" y="32" />
+</svg>
diff --git a/comm/calendar/base/themes/common/calendar-occurrence-prompt.css b/comm/calendar/base/themes/common/calendar-occurrence-prompt.css
new file mode 100644
index 0000000000..90e96b55ae
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-occurrence-prompt.css
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendar-occurrence-prompt {
+ padding: 0;
+ width: 25em;
+ height: 34ex;
+ min-width: 25em;
+ min-height: 34ex;
+ -moz-user-focus: ignore;
+}
+
+#occurrence-prompt-header {
+ height: 50px;
+ padding: 0 15px;
+ border: 1px solid var(--field-border-color);
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+}
+
+#title-label {
+ font-weight: bold;
+}
+
+#accept-buttons-box {
+ padding: 0 18px;
+}
+
+.occurrence-accept-buttons {
+ margin: 10px 0;
+}
+
+.occurrence-accept-buttons > .button-box > .button-icon {
+ width: 18px;
+ height: 18px;
+}
+
+.occurrence-accept-buttons > .button-box > .button-text {
+ margin: 0 3px !important;
+}
+
+#accept-buttons-box {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#accept-buttons-box[type="mixed"] > #accept-occurrence-button,
+#accept-buttons-box[type="event"] > #accept-occurrence-button {
+ list-style-image: url(chrome://calendar/skin/shared/calendar-occurrence.svg#event-single);
+}
+
+#accept-buttons-box[type="mixed"] > #accept-parent-button,
+#accept-buttons-box[type="mixed"] > #accept-allfollowing-button,
+#accept-buttons-box[type="event"] > #accept-parent-button,
+#accept-buttons-box[type="event"] > #accept-allfollowing-button {
+ list-style-image: url(chrome://calendar/skin/shared/calendar-occurrence.svg#event-all);
+}
+
+#accept-buttons-box[type="task"] > .occurrence-accept-buttons {
+ list-style-image: url(chrome://calendar/skin/shared/calendar-occurrence.svg#task-single);
+}
+
+#accept-buttons-box[type="task"] > #accept-parent-button,
+#accept-buttons-box[type="task"] > #accept-allfollowing-button {
+ list-style-image: url(chrome://calendar/skin/shared/calendar-occurrence.svg#task-all);
+}
diff --git a/comm/calendar/base/themes/common/calendar-occurrence.svg b/comm/calendar/base/themes/common/calendar-occurrence.svg
new file mode 100644
index 0000000000..6a1de2d527
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-occurrence.svg
@@ -0,0 +1,20 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 18 18">
+ <style>
+ path {
+ display: none;
+ fill: context-fill;
+ stroke: context-fill;
+ }
+ path:target {
+ display: block;
+ stroke-width: 1;
+ }
+ </style>
+ <path id="event-single" d="m 10.5,7.5 h 3 v 7 h -2 v -5 h -1 z m -9,-5 v 14 h 15 v -14 h -2 v 2 h -2 V 1 4.5 h -2 v -2 h -3 v 2 h -2 V 1 4.5 h -2 v -2 z m 0,3 h 15 v 11 h -15 z m 3,2 h 3 v 7 h -2 v -5 h -1 z"/>
+ <path id="event-all" style="fill-opacity:0;" d="m 8.5,10.5 h 2 v 4 h -1 v -3 h -1 z m -7,-3 v 9 h 11 v -9 h -1 v 1 h -2 V 7 8.5 h -2 v -1 h -1 v 1 h -2 V 7 8.5 h -2 v -1 z m 2,3 h 2 v 4 h -1 v -3 h -1 z m 9,3 h 2 v -9 h -1 v 1 h -2 V 4 5.5 h -2 v -1 h -1 v 1 h -2 V 4 5.5 h -2 v -1 h -1 V 7 m 11,3.5 h 2 V 1.5 h -1 v 1 h -2 v -1.5 1.5 h -2 v -1 h -1 v 1 h -2 v -1.5 1.5 h -2 v -1 h -1 V 4"/>
+ <path id="task-single" d="m 4.5,2.5 h -2 v 14 h 13 v -14 h -2 v 2 h 2 l 0,12 h -13 l 0,-12 h 2 z m 2,-1 h 5 v 3 h -5 z M 5.1,10.8 6.7,9.2 8.5,10.7 12,7 l 1.5,1.5 -5,5 z"/>
+ <path id="task-all" style="fill-opacity:0;" d="M 9,2.5 7,2.5 m 6.5,8 h 3 V 2.5 H 15 m -4.5,-1 h 3 v 1 h -3 z m -4.5,4 H 4 M 10.5,13.5 h 3 V 5.5 H 12 m -4.5,-1 h 3 v 1 h -3 z M 3,8.5 H 1.5 v 8 h 9 v -8 H 9 m -4.5,-1 h 3 v 1 h -3 z m -1,6 0.7,-0.6 1.1,0.9 2.6,-2.6 0.7,0.6 L 5.5,15 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/calendar-preferences.css b/comm/calendar/base/themes/common/calendar-preferences.css
new file mode 100644
index 0000000000..f60303e3ae
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-preferences.css
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#defaults-task-table,
+#alarm-defaults-table,
+#dayAndWeekViewsTable {
+ border-spacing: 0;
+}
+
+#defaults-task-table html|td,
+#alarm-defaults-table html|td,
+#dayAndWeekViewsTable html|td {
+ padding: 0;
+}
+
+#default_task_start,
+#default_task_due {
+ width: calc(100% - 16px); /* 16px is the sum of the element margins. */
+}
+
+#defaultsnoozelength {
+ margin-right: 10px;
+ margin-left: 10px;
+}
+
+#alarm-sound-table {
+ display: inline-table;
+}
+
+#eventdefalarm,
+#tododefalarm {
+ margin-inline-start: 12px;
+}
+
+#eventdefalarm,
+#tododefalarm,
+#alarmSoundFileField {
+ width: calc(100% - 8px); /* 8px is the sum of the element margins. */
+}
+
+#alarm-sound-buttons-box,
+#calendar\.prefs\.alarm\.sound\.useDefault {
+ width: 100%;
+}
+
+.defaultTimeBox {
+ width: 100%;
+ margin-inline-start: 8px;
+}
+
+#eventdefalarmunit,
+#tododefalarmunit {
+ margin-inline-end: 4px;
+}
+
+.dayOffCheckbox {
+ flex-direction: row-reverse;
+ margin-top: -5px;
+ margin-inline-end: -5px;
+ padding-inline-start: 6px;
+}
+
+.dayOffCheckbox > .checkbox-check {
+ margin-inline-start: 10px;
+}
+
+#daystarthour {
+ margin-inline-end: 18px;
+}
+
+#categorieslist {
+ height: 210px;
+}
diff --git a/comm/calendar/base/themes/common/calendar-print.css b/comm/calendar/base/themes/common/calendar-print.css
new file mode 100644
index 0000000000..1c7ec8b801
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-print.css
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url("chrome://calendar/skin/shared/widgets/minimonth.css");
+
+#calendar-print {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ font: menu;
+ overflow: auto;
+}
+
+#calendar-print fieldset.section-block {
+ border: none;
+ padding: 0;
+ display: flex;
+}
+
+#calendar-print select#from-month,
+#calendar-print select#to-month {
+ flex: 1;
+}
+
+#calendar-print input#from-year,
+#calendar-print input#to-year {
+ flex: 1;
+ margin-block: 0;
+ margin-inline: 3px 0;
+}
+
+#calendar-print select {
+ width: 100%;
+}
+
+#calendar-print label.indent {
+ padding-inline-start: 1.5em;
+ box-sizing: border-box;
+}
+
+calendar-minimonth {
+ padding: 0;
+ width: 100%;
+}
+
+#next-button-container,
+#back-button-container {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+}
+
+#next-button-container > button,
+#back-button-container > button {
+ flex: 1 1 auto;
+ margin: 0;
+}
+
+#back-button-container {
+ margin-bottom: 8px;
+}
+
+#button-container {
+ margin-top: 8px;
+}
diff --git a/comm/calendar/base/themes/common/calendar-providerUninstall-dialog.css b/comm/calendar/base/themes/common/calendar-providerUninstall-dialog.css
new file mode 100644
index 0000000000..a5d00adbc9
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-providerUninstall-dialog.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#provider-name-label {
+ font-weight: bold;
+ margin-inline-start: 3em;
+}
+
+#calendar-list richlistitem[selected] {
+ color: unset;
+ background-color: unset;
+}
diff --git a/comm/calendar/base/themes/common/calendar-task-tree.css b/comm/calendar/base/themes/common/calendar-task-tree.css
new file mode 100644
index 0000000000..1f44446dcf
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-task-tree.css
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.calendar-task-tree {
+ appearance: none;
+ margin: 0;
+}
+
+/* align the treechildren text */
+.calendar-task-tree > treechildren::-moz-tree-cell-text {
+ margin-top: 1px;
+ margin-bottom: 1px;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress) {
+ color: green !important;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress) {
+ color: var(--color-green-50) !important;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ background-color: green !important;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ background-color: var(--color-green-50) !important;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-cell-text(overdue) {
+ color: var(--color-red-50) !important;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-cell-text(overdue) {
+ color: var(--color-red-40) !important;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ background-color: var(--color-red-50) !important;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ background-color: var(--color-red-40) !important;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(inprogress, selected, focus),
+.calendar-task-tree > treechildren::-moz-tree-image(overdue, selected, focus),
+.calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress, selected, focus),
+:root[lwt-tree-brighttext] .calendar-task-tree >
+ treechildren::-moz-tree-cell-text(inprogress, selected, focus),
+.calendar-task-tree > treechildren::-moz-tree-cell-text(overdue, selected, focus),
+:root[lwt-tree-brighttext] .calendar-task-tree >
+ treechildren::-moz-tree-cell-text(overdue, selected, focus) {
+ color: var(--selected-item-text-color) !important;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-cell-text(completed) {
+ text-decoration: line-through;
+ font-style: italic;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-cell-text(duetoday) {
+ font-weight: bold;
+}
+
+.calendar-task-tree-col-priority {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority) {
+ margin-inline-start: -2px;
+ -moz-context-properties: stroke;
+ stroke: transparent;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, highpriority) {
+ list-style-image: var(--icon-priority);
+ stroke: red;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree >
+ treechildren::-moz-tree-image(calendar-task-tree-col-priority, highpriority) {
+ stroke: var(--color-red-40);
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, lowpriority) {
+ list-style-image: var(--icon-priority-low);
+ stroke: blue;
+}
+
+:root[lwt-tree-brighttext] .calendar-task-tree >
+ treechildren::-moz-tree-image(calendar-task-tree-col-priority, lowpriority) {
+ stroke: var(--color-white);
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, selected) {
+ stroke: currentColor !important;
+}
+
+treecol.calendar-task-tree-col-percentcomplete {
+ text-align: end;
+}
+
+.calendar-task-tree-col-completed >.treecol-icon,
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed) {
+ width: 14px;
+ height: 14px;
+ -moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
+ fill: currentColor;
+ stroke: currentColor;
+ stroke-opacity: 0;
+}
+
+.calendar-task-tree-col-completed >.treecol-icon {
+ fill-opacity: 1;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed) {
+ list-style-image: var(--icon-checkbox);
+ fill-opacity: 0;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, completed) {
+ fill-opacity: 1;
+}
+
+.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, repeating) {
+ fill-opacity: 0.6;
+}
diff --git a/comm/calendar/base/themes/common/calendar-task-view.css b/comm/calendar/base/themes/common/calendar-task-view.css
new file mode 100644
index 0000000000..02e21bd3de
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-task-view.css
@@ -0,0 +1,236 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root[lwt-tree-brighttext] #calendar-task-details-container {
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .25));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .5));
+ --toolbarbutton-header-bordercolor: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .25));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .7));
+ --toolbarbutton-active-boxshadow: 0 0 0 1px var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4)) inset;
+ --toolbarbutton-checked-background: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .3));
+}
+
+#calendar-task-details-container {
+ overflow: hidden;
+}
+
+#calendar-task-details {
+ background-color: var(--layout-background-2);
+ display: flex;
+ flex-direction: column;
+ min-height: 6ex;
+}
+
+#calendar-task-details-attachment-row > hbox {
+ padding-inline-start: 0.1em;
+}
+
+#calendar-task-details-grid {
+ padding: 1px 2px 0.2em;
+ width: 100%;
+}
+
+#calendar-task-details-title {
+ font-weight: bold;
+}
+
+#calendar-task-details-grid > tr > th {
+ display: flex;
+ justify-content: end;
+ font-weight: normal;
+ white-space: nowrap;
+}
+
+#calendar-task-details-grid > tr > td {
+ padding-inline-start: 6px;
+ text-align: left;
+ width: 100%;
+}
+
+#calendar-task-details-grid > tr > td > label {
+ margin-inline-start: 0;
+}
+
+#other-actions-box {
+ display: flex;
+ justify-content: end;
+ padding-block: 2px 0.3em;
+}
+
+#task-addition-box {
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+:root[lwt-tree] #task-addition-box {
+ background-color: var(--toolbar-bgcolor);
+ background-image: none;
+ color: var(--toolbar-color);
+}
+
+#task-addition-box:-moz-lwtheme {
+ border-color: var(--splitter-color);
+}
+
+:root[lwt-tree] #calendar-task-details-container {
+ background-color: var(--toolbar-bgcolor);
+ background-image: none;
+ color: var(--toolbar-color);
+ border-color: var(--sidebar-border-color, hsla(0,0%,60%,.4));
+}
+
+:root[lwt-tree-brighttext] #task-addition-box,
+:root[lwt-tree-brighttext] #calendar-task-details-container {
+ border-color: var(--sidebar-border-color, rgba(249,249,250,.2));
+}
+
+#calendar-task-details-box {
+ display: block;
+}
+
+#calendar-task-details-description-wrapper {
+ display: flex;
+}
+
+#calendar-task-details-description {
+ width: 100%;
+ box-sizing: border-box;
+ background-color: var(--layout-background-1);
+ border-width: 0;
+ border-top: 1px solid var(--splitter-color);
+ margin-block: 0;
+ padding-inline: 4px;
+ outline: none;
+ resize: none;
+}
+
+.task-details-name {
+ text-align: right;
+ color: windowtext;
+ opacity: 0.5; /* lower contrast */
+}
+
+#calendar-task-details-grid > .item-date-row > .headline {
+ font-weight: normal;
+ color: windowtext;
+ opacity: 0.5; /* lower contrast */
+}
+
+#calendar-task-details-attachment-row {
+ border-top: 1px solid var(--splitter-color);
+ padding-block: 2px;
+}
+
+#calendar-task-details-attachment-rows {
+ max-height: 60px;
+}
+
+.task-details-value {
+ text-align: left;
+ color: WindowText;
+}
+
+:root[lwt-tree] .task-details-name,
+:root[lwt-tree] .task-details-value,
+:root[lwt-tree] #calendar-task-details-grid > .item-date-row > .headline {
+ color: inherit;
+}
+
+#calendar-task-tree {
+ background-color: var(--layout-background-0);
+ color: var(--layout-color-1);
+ min-height: 98px;
+}
+
+#calendar-task-tree-detail {
+ border-top: 1px solid ThreeDShadow;
+ margin: 3px 0;
+}
+
+#calendar-task-tree-detail:-moz-lwtheme {
+ border-top-color: var(--splitter-color);
+}
+
+#view-task-edit-field {
+ margin: 5px;
+ padding-block: 0;
+}
+
+.task-edit-field[readonly="true"] {
+ color: GrayText;
+}
+
+#unifinder-task-edit-field {
+ margin: 3px;
+}
+
+#unifinder-todo-tree > .calendar-task-tree {
+ margin-bottom: 3px;
+}
+
+/* ::::: task actions toolbar ::::: */
+
+#calendar-add-task-button {
+ appearance: none;
+ -moz-user-focus: normal;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ margin-inline-start: 5px;
+ list-style-image: var(--icon-new-task);
+ -moz-context-properties: fill, fill-opacity, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#calendar-add-task-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ color: inherit;
+ outline: none;
+}
+
+#calendar-add-task-button:not([disabled="true"]):hover:active {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+#calendar-add-task-button:focus-visible:not(:hover) {
+ outline: 2px solid var(--focus-outline-color);
+ outline-offset: var(--focus-outline-offset);
+}
+
+#calendar-add-task-button > .toolbarbutton-text {
+ padding-inline-start: 5px;
+}
+
+#task-actions-category {
+ list-style-image: var(--icon-tag);
+}
+
+#task-actions-markcompleted {
+ list-style-image: var(--icon-check);
+}
+
+#task-actions-priority {
+ list-style-image: var(--icon-priority);
+}
+
+#calendar-delete-task-button {
+ list-style-image: var(--icon-trash);
+}
+
+.input-container {
+ display: flex;
+ align-items: stretch;
+}
+
+.input-container html|input {
+ flex-grow: 1;
+}
diff --git a/comm/calendar/base/themes/common/calendar-toolbar.css b/comm/calendar/base/themes/common/calendar-toolbar.css
new file mode 100644
index 0000000000..1ff326015c
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-toolbar.css
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Calendar tool bar button */
+#lightning-button-calendar {
+ list-style-image: var(--icon-calendar);
+}
+
+/* Tasks tool bar button */
+#lightning-button-tasks {
+ list-style-image: var(--icon-tasks);
+}
+
+#extractEventButton {
+ list-style-image: var(--icon-new-event);
+}
+
+#extractTaskButton {
+ list-style-image:var(--icon-new-task);
+}
diff --git a/comm/calendar/base/themes/common/calendar-unifinder.css b/comm/calendar/base/themes/common/calendar-unifinder.css
new file mode 100644
index 0000000000..50d1d0152c
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-unifinder.css
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root[lwt-tree-brighttext] #unifinder-searchBox {
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .25));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .5));
+ --toolbarbutton-header-bordercolor: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .25));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .7));
+ --toolbarbutton-active-boxshadow: 0 0 0 1px var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4)) inset;
+ --toolbarbutton-checked-background: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .3));
+}
+
+/* only format Unifinder lists */
+#unifinder-search-results-tree > treechildren::-moz-tree-cell-text(highpriority) {
+ font-weight: bold;
+}
+
+#unifinder-search-results-tree > treechildren::-moz-tree-cell-text(lowpriority) {
+ font-style: italic;
+ opacity: 0.6;
+}
+
+/* workaround to avoid Window Flick */
+#unifinder-search-results-tree {
+ appearance: none;
+ min-height: 92px;
+ margin: 0;
+}
+
+:root[lwt-tree] #unifinder-searchBox {
+ background-color: var(--toolbar-bgcolor);
+ background-image: none;
+ color: var(--toolbar-color);
+}
+
+#unifinder-searchBox:-moz-lwtheme {
+ border-bottom-color: var(--splitter-color);
+}
diff --git a/comm/calendar/base/themes/common/calendar-views.css b/comm/calendar/base/themes/common/calendar-views.css
new file mode 100644
index 0000000000..572207bffb
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar-views.css
@@ -0,0 +1,1232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root {
+ --viewColor: var(--layout-color-1);
+ --viewBackground: var(--layout-background-0);
+ --viewBorderColor: var(--layout-border-0);
+ --viewCalendarHeaderBackground: rgba(0, 0, 0, 0.03);
+ --viewHighlightBorderColor: var(--color-blue-50);
+ --viewTodayColor: inherit;
+ --viewTodayBackground: var(--layout-background-1);
+ --viewTodayLabelColor: var(--color-white);
+ --viewTodayLabelBackground: var(--color-blue-50);
+ --viewTodayOffBackground: color-mix(in srgb, var(--viewTodayBackground) 95%, black);
+ --viewTodayDayLabelBackground: var(--color-blue-10);
+ --viewTodayWeekendBackground: color-mix(in srgb, var(--color-blue-10) 20%, var(--viewWeekendBackground));
+ --viewWeekendBackground: var(--color-ink-10);
+ --viewHeaderSelectedBackground: color-mix(in srgb, var(--color-blue-10) 20%, var(--viewBackground));
+ --viewDayBoxSelectedBackground: color-mix(in srgb, var(--color-blue-10) 20%, var(--viewBackground));
+ --viewDayBoxOffSelectedBackground: color-mix(in srgb, var(--color-blue-20) 20%, var(--viewTodayOffBackground));
+ --viewDayBoxOtherSelectedBackground: color-mix(in srgb, var(--color-blue-10) 20%, var(--viewMonthOtherBackground));
+ --viewMonthOtherBackground: var(--layout-background-2);
+ --viewMonthDayBoxSelectedColor: var(--layout-color-1);
+ --viewMonthDayBoxLabelColor: var(--layout-color-1);
+ --viewMonthWeekLabelBackground: var(--color-ink-20);
+ --viewMonthDayOtherBackground: color-mix(in srgb, var(--layout-background-2) 50%, var(--viewWeekendBackground));
+ --viewMonthDayOffLabelBackground: color-mix(in srgb, var(--viewWeekendBackground) 95%, black);
+ --viewOffTimeBackground: color-mix(in srgb, var(--viewBackground) 95%, black);
+ --viewTimeBoxColor: var(--layout-color-2);
+ --viewDayLabelSelectedColor: currentColor;
+ --viewDayLabelSelectedBackground: var(--color-blue-50);
+ --viewDragboxColor: currentColor;
+ --viewDragboxBackground: var(--color-blue-50);
+ --viewDropshadowBackground: var(--color-blue-50);
+ --calendar-nav-control-bg-color: var(--color-white);
+ --calendar-nav-control-bg-color-hover: rgba(0, 0, 0, 0.1);
+ --calendar-view-nav-btn-padding: 4px;
+ --calendar-nav-control-padding: 6px;
+ --calendar-view-toggle-label-padding: 3px 15px;
+ --calendar-view-toolbar-gap: 9px;
+ --button-border-radius: 3px;
+ --calendar-view-toggle-background: var(--layout-background-3);
+ --calendar-view-toggle-hover-background: var(--layout-background-2);
+ --calendar-view-toggle-active-background: var(--layout-background-4);
+ --calendar-view-toggle-selected-background: var(--layout-background-1);
+ --calendar-view-toggle-selected-hover-background: var(--layout-background-0);
+ --calendar-view-toggle-border-color: transparent;
+ --calendar-view-toggle-shadow-color: color-mix(in srgb, var(--color-black) 30%, transparent);
+ --calendar-month-day-box-padding: 2px;
+ --today-week-pill-padding-block: 3px;
+ --today-week-pill-padding-inline: 6px;
+ --calendar-nav-gap: 3px;
+}
+
+:root[uidensity="compact"] {
+ --calendar-view-toggle-margin: 3px;
+ --calendar-view-toggle-label-padding: 2px 12px;
+ --calendar-view-toolbar-gap: 6px;
+ --calendar-month-day-box-padding: 1px;
+ --today-week-pill-padding-block: 1px;
+ --today-week-pill-padding-inline: 3px;
+ --calendar-nav-gap: 3px;
+}
+
+:root[uidensity="touch"] {
+ --calendar-view-toggle-margin: 6px 3px;
+ --calendar-view-toggle-label-padding: 6px 15px;
+ --calendar-view-toolbar-gap: 12px;
+ --calendar-month-day-box-padding: 3px;
+ --today-week-pill-padding-block: 3px;
+ --today-week-pill-padding-inline: 9px;
+ --calendar-nav-gap: 6px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --viewTodayDayLabelBackground: color-mix(in srgb, var(--color-blue-90) 30%, var(--viewBackground));
+ --viewHeaderSelectedBackground: color-mix(in srgb, var(--color-blue-90) 20%, var(--viewBackground));
+ --viewDayBoxSelectedBackground: color-mix(in srgb, var(--color-blue-90) 20%, var(--viewBackground));
+ --viewDayBoxOffSelectedBackground: color-mix(in srgb, var(--color-blue-80) 20%, var(--viewTodayOffBackground));
+ --viewDayBoxOtherSelectedBackground: color-mix(in srgb, var(--color-blue-90) 20%, var(--viewMonthOtherBackground));
+ --viewMonthDayOffLabelBackground: color-mix(in srgb, var(--viewWeekendBackground) 95%, var(--color-ink-30));
+ --viewOffTimeBackground: color-mix(in srgb, var(--viewBackground) 95%, var(--color-ink-30));
+ --viewWeekendBackground: color-mix(in srgb, var(--viewBackground) 80%, var(--color-ink-40));
+ --viewMonthOtherBackground: var(--layout-background-1);
+ --viewMonthWeekLabelBackground: var(--color-ink-60);
+ --viewMonthDayOtherBackground: color-mix(in srgb, var(--layout-background-1) 50%, var(--viewWeekendBackground));
+ --calendar-nav-control-bg-color: var(--color-black);
+ --calendar-nav-control-bg-color-hover: rgba(0, 0, 0, 0.3);
+ --calendar-view-toggle-hover-background: var(--layout-background-4);
+ --calendar-view-toggle-active-background: var(--layout-background-2);
+ --calendar-view-toggle-shadow-color: color-mix(in srgb, var(--color-black) 50%, transparent);
+ }
+}
+
+@media (prefers-contrast) {
+ :root:not(:-moz-lwtheme) {
+ --calendar-view-toggle-background: transparent;
+ --calendar-view-toggle-hover-background: SelectedItem;
+ --calendar-view-toggle-active-background: SelectedItem;
+ --calendar-view-toggle-selected-background: SelectedItem;
+ --calendar-view-toggle-selected-hover-background: SelectedItem;
+ --calendar-view-toggle-border-color: WindowText;
+ --calendar-view-toggle-shadow-color: transparent;
+ --viewColor: WindowText;
+ --viewBackground: Field;
+ --viewBorderColor: ThreeDShadow;
+ --viewHighlightBorderColor: var(--selected-item-color);
+ --viewTodayColor: inherit;
+ --viewTodayBackground: Field;
+ --viewTodayLabelColor: var(--selected-item-text-color);
+ --viewTodayLabelBackground: var(--selected-item-color);
+ --viewTodayOffBackground: ButtonFace;
+ --viewTodayDayLabelBackground: ButtonFace;
+ --viewTodayWeekendBackground: ButtonFace;
+ --viewWeekendBackground: hsla(0, 0%, 60%, 0.1);
+ --viewHeaderSelectedBackground: ButtonFace;
+ --viewDayBoxSelectedBackground: Field;
+ --viewDayBoxOffSelectedBackground: hsla(0, 0%, 60%, 0.05);
+ --viewDayBoxOtherSelectedBackground: hsla(0, 0%, 60%, 0.05);
+ --viewMonthOtherBackground: ButtonFace;
+ --viewMonthWeekLabelBackground: ButtonFace;
+ --viewMonthDayBoxSelectedColor: var(--selected-item-color);
+ --viewMonthDayBoxLabelColor: WindowText;
+ --viewMonthDayOtherBackground: hsla(0, 0%, 60%, 0.1);
+ --viewMonthDayOffLabelBackground: hsla(0, 0%, 60%, 0.1);
+ --viewOffTimeBackground: hsla(0, 0%, 60%, 0.1);
+ --viewTimeBoxColor: GrayText;
+ --viewDayLabelSelectedColor: var(--selected-item-text-color);
+ --viewDayLabelSelectedBackground: var(--selected-item-color);
+ --viewDragboxColor: GrayText;
+ --viewDragboxBackground: var(--selected-item-color);
+ --viewDropshadowBackground: var(--selected-item-color) !important;
+ }
+
+ button.calview-toggle-item:not(:-moz-lwtheme):hover,
+ button.calview-toggle-item[role="tab"][aria-selected="true"]:not(:-moz-lwtheme) {
+ --button-border-color: SelectedItem;
+ --calendar-view-toggle-border-color: SelectedItem;
+ color: SelectedItemText;
+ }
+}
+
+/* Calendar Items */
+
+.calendar-item-container {
+ display: flow-root;
+ overflow: hidden;
+ min-height: 100%;
+ width: 100%;
+}
+
+.calendar-item-container > .location-desc {
+ /* Margin matches the padding of .calendar-item-flex. */
+ margin: 2px;
+}
+
+.calendar-item-flex {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ padding: 4px 5px;
+ overflow-x: hidden;
+}
+
+.calendar-item-flex > .alarm-icons-box {
+ display: contents;
+}
+
+.item-time-label {
+ flex: 0 0 auto;
+ font-weight: 600;
+}
+
+.calendar-item-flex img {
+ flex: 0 0 auto;
+ width: 12px;
+ height: 12px;
+}
+
+.item-type-icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.item-type-icon:not([src]) {
+ display: none;
+}
+
+.item-type-icon.rotated-to-read-direction:-moz-locale-dir(ltr) {
+ transform: rotate(-90deg);
+}
+
+.item-type-icon.rotated-to-read-direction:-moz-locale-dir(rtl) {
+ transform: rotate(90deg);
+}
+
+.event-name-input,
+.event-name-label,
+.location-desc {
+ font-weight: normal;
+ overflow-x: hidden;
+}
+
+.event-name-label,
+.location-desc {
+ text-overflow: ellipsis;
+}
+
+.event-name-label {
+ white-space: nowrap;
+}
+
+:is(calendar-day-view, calendar-week-view) .event-name-label {
+ white-space: normal;
+}
+
+.event-name-input,
+.event-name-label {
+ flex: 1 1 auto;
+}
+
+.event-name-input {
+ padding: 0;
+ margin: 0;
+ background: transparent;
+ color: inherit;
+}
+
+.location-desc {
+ opacity: 0.5;
+ white-space: pre;
+}
+
+.item-classification-icon,
+.item-recurrence-icon {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ pointer-events: none;
+}
+
+.item-classification-icon:not([src]),
+.item-recurrence-icon:not([src]) {
+ display: none;
+}
+
+.calendar-category-box {
+ display: block;
+ width: 4px;
+ position: absolute;
+ inset-block: 2px;
+ inset-inline-end: 2px;
+ border-radius: 2px;
+ z-index: -1;
+}
+
+/* Multiday view */
+
+:is(calendar-day-view, calendar-week-view):not([hidden]) {
+ flex: 1 auto;
+ display: grid;
+ height: 0;
+}
+
+.multiday-grid {
+ overflow: auto;
+ display: grid;
+ grid-auto-flow: column;
+ /* Columns: timebar, days, multiday-end-border.
+ * --multiday-num-days is set in javascript on the grid. */
+ grid-template-columns: min-content repeat(var(--multiday-num-days), 1fr) min-content;
+ /* Rows: heading, all-day header, event column.
+ * NOTE: We use "min-content" instead of "auto" because otherwise the grid
+ * will grow "auto" rows if it has extra vertical space, and this can effect
+ * calculating the pixelsPerMinute for the view. */
+ grid-template-rows: min-content min-content min-content;
+}
+
+.multiday-grid.multiday-grid-rotated {
+ grid-auto-flow: row;
+ grid-template-columns: min-content min-content min-content;
+ grid-template-rows: min-content repeat(var(--multiday-num-days), 1fr) min-content;
+}
+
+.multiday-header-corner {
+ position: sticky;
+ z-index: 3;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ grid-row: 1 / 3;
+ grid-column: 1 / 2;
+}
+
+.multiday-grid-rotated .multiday-header-corner {
+ grid-row: 1 / 2;
+ grid-column: 1 / 3;
+}
+
+.multiday-timebar {
+ /* NOTE: This also helps position multiday-timebar-now-indicator. */
+ position: sticky;
+ z-index: 2;
+}
+
+.multiday-grid:not(.multiday-grid-rotated) .multiday-timebar {
+ min-width: 10ex;
+ inset-inline-start: 0;
+}
+
+.multiday-grid.multiday-grid-rotated .multiday-timebar {
+ min-height: 40px;
+ inset-block-start: 0;
+}
+
+.multiday-timebar-time {
+ color: var(--viewTimeBoxColor);
+ padding: 1px 5px;
+ white-space: nowrap;
+}
+
+.multiday-grid:not(.multiday-grid-rotated) .multiday-timebar-time {
+ text-align: end;
+}
+
+.day-column-container {
+ /* Container children become grid items. */
+ display: contents;
+}
+
+.day-column-heading {
+ color: var(--viewColor);
+ font-size: inherit;
+ box-sizing: border-box;
+ white-space: nowrap;
+ padding-inline: 5px;
+ padding-block: 1px 2px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+}
+
+.day-column-heading > * {
+ flex: 0 0 auto;
+}
+
+.day-column-heading {
+ position: sticky;
+ z-index: 2;
+ inset-block-start: 0;
+ inset-inline-start: auto;
+}
+
+.multiday-grid-rotated .day-column-heading {
+ inset-block-start: auto;
+ inset-inline-start: 0;
+}
+
+calendar-header-container {
+ position: sticky;
+ z-index: 2;
+ /* NOTE: calendar-header-container must have its inset-*-start set in
+ * javascript, when we know the height/width of the .day-column-heading. */
+ /* Give child all available space. */
+ display: grid;
+}
+
+.allday-events-list {
+ display: block;
+ min-height: 30px;
+ max-height: 120px;
+ min-width: 100px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ margin: 0;
+ padding-block: 0;
+ padding-inline: 1px 6px;
+ /* Prevent overscrolling from moving into the parent view.
+ * NOTE: This only works if the list is overflowing. For cases where it does
+ * not overflow, we must prevent scrolling the parent in javascript. */
+ overscroll-behavior: none;
+}
+
+.multiday-grid-rotated .allday-events-list:not(:empty) {
+ min-width: 150px;
+}
+
+.allday-event-listitem {
+ display: block;
+ margin: 2px;
+}
+
+.multiday-events-list {
+ padding: 0;
+ position: relative;
+}
+
+.multiday-event-listitem {
+ /* This acts as sized wrapper to an calendar-event-box, so we use the grid
+ * display to give it the same dimensions. */
+ display: grid;
+ position: absolute;
+ padding: 1px;
+ box-sizing: border-box;
+}
+
+/* Margin that allows event creation by click and drag when the time slot is
+ full of events. On the right side in normal view ... */
+calendar-event-column .multiday-events-list {
+ margin-inline: 0 5px;
+ margin-block: 0;
+}
+/* ... and on bottom in rotate view. */
+.multiday-grid-rotated calendar-event-column .multiday-events-list {
+ margin-block: 0 5px;
+ margin-inline: 0;
+}
+
+.multiday-end-border {
+ grid-row: 1 / 4;
+ grid-column: auto;
+}
+
+.multiday-grid-rotated .multiday-end-border {
+ grid-row: auto;
+ grid-column: 1 / 4;
+}
+
+.multiday-hour-box-container {
+ display: flex;
+ flex-direction: column;
+}
+
+.multiday-grid-rotated .multiday-hour-box-container {
+ flex-direction: row;
+}
+
+.multiday-hour-box {
+ flex: 1 1 0;
+}
+
+/* Borders. */
+
+.multiday-header-corner {
+ border-inline-start: none;
+ border-inline-end: 2px solid var(--viewBorderColor);
+ border-block-start: none;
+ border-block-end: 2px solid var(--viewBorderColor);
+}
+
+.multiday-timebar {
+ border-inline-start: none;
+ border-inline-end: 2px solid var(--viewBorderColor);
+ border-block: none;
+}
+
+.multiday-grid-rotated .multiday-timebar {
+ border-block-start: 1px solid var(--viewBorderColor);
+ border-block-end: 2px solid var(--viewBorderColor);
+ border-inline: none;
+}
+
+.day-column-heading {
+ border-block: 1px solid var(--viewBorderColor);
+ border-inline-start: 1px solid var(--viewBorderColor);
+ border-inline-end: none;
+}
+
+.multiday-grid-rotated .day-column-heading {
+ border-inline: 1px solid var(--viewBorderColor);
+ border-block-start: 1px solid var(--viewBorderColor);
+ border-block-end: none;
+}
+
+calendar-header-container {
+ /* Block start border is given by .day-column-heading. */
+ border-block-start: none;
+ border-block-end: 2px solid var(--viewBorderColor);
+ border-inline-start: 1px solid var(--viewBorderColor);
+ /* Inline end border is given by the next header. */
+ border-inline-end: none;
+}
+
+.multiday-grid-rotated calendar-header-container {
+ border-inline-start: none;
+ border-inline-end: 2px solid var(--viewBorderColor);
+ border-block-start: 1px solid var(--viewBorderColor);
+ border-block-end: none;
+}
+
+calendar-event-column,
+.multiday-end-border {
+ /* NOTE: For calendar-event-column, the calendar-header-container and last
+ * .multiday-hour-box elements provide the starting end ending block borders,
+ * respectively. */
+ border-inline-start: 1px solid var(--viewBorderColor);
+ border-inline-end: none;
+ border-block: none;
+}
+
+.multiday-grid-rotated :is(
+ calendar-event-column,
+ .multiday-end-border
+) {
+ border-block-start: 1px solid var(--viewBorderColor);
+ border-block-end: none;
+ border-inline: none;
+}
+
+.multiday-hour-box {
+ border-block-start: none;
+ border-block-end: 1px solid var(--viewBorderColor);
+ border-inline: none;
+}
+
+.multiday-grid:not(.multiday-grid-rotated) .multiday-timebar .multiday-hour-box {
+ /* Timebar has same border as in the calendar-event-column, so that they
+ * align, but we make it transparent to hide it. */
+ border-color: transparent;
+}
+
+.multiday-grid-rotated .multiday-hour-box {
+ border-inline-start: none;
+ border-inline-end: 1px solid var(--viewBorderColor);
+ border-block: none;
+}
+
+/* Background.
+ * Styling priority, from lowest to highest:
+ * + .day-column-weekend
+ * + .day-column-today
+ * + .day-column-selected
+ */
+
+.multiday-header-corner,
+.day-column-heading,
+calendar-header-container,
+calendar-event-column,
+.multiday-hour-box {
+ background-color: var(--viewBackground);
+}
+
+.day-column-weekend :is(
+ calendar-header-container,
+ .multiday-hour-box
+) {
+ background-color: var(--viewWeekendBackground);
+}
+
+.day-column-today :is(
+ calendar-header-container,
+ .multiday-hour-box
+) {
+ background-color: var(--viewTodayBackground);
+}
+
+.day-column-today .day-column-heading {
+ color: var(--viewTodayLabelBackground);
+}
+
+.day-column-today.day-column-weekend:not(.day-column-selected) :is(
+ calendar-header-container,
+ .multiday-hour-box
+) {
+ background-color: var(--viewTodayWeekendBackground);
+}
+
+.day-column-selected :is(
+ calendar-header-container,
+ .multiday-hour-box,
+ .day-column-heading
+) {
+ background-color: var(--viewHeaderSelectedBackground);
+ border-inline: 1px solid var(--viewHighlightBorderColor);
+}
+
+.multiday-hour-box.multiday-hour-box-off-time {
+ background-color: var(--viewOffTimeBackground);
+}
+
+.day-column-weekend .multiday-hour-box.multiday-hour-box-off-time {
+ background-color: var(--viewMonthDayOffLabelBackground);
+}
+
+.day-column-today .multiday-hour-box.multiday-hour-box-off-time {
+ background-color: var(--viewTodayOffBackground);
+}
+
+.day-column-selected .multiday-hour-box.multiday-hour-box-off-time {
+ background-color: var(--viewDayBoxOffSelectedBackground);
+}
+
+.fgdragbox {
+ -moz-box-orient: inherit;
+ display: none;
+}
+
+.fgdragbox[dragging="true"] {
+ display: flex;
+ background: var(--viewDragboxBackground);
+ border: 5px var(--viewBackground);
+ opacity: 0.5;
+ min-height: 2px;
+ min-width: 2px;
+}
+
+.fgdragcontainer {
+ -moz-box-orient: inherit;
+ display: none;
+}
+
+.fgdragcontainer[dragging="true"] {
+ display: flex;
+ /* This is a workaround for a stack bug and display: hidden in underlying
+ * elements -- the display: hidden bits get misrendered as being on top.
+ * Setting an opacity here forces a view to be created for this element, too.
+ */
+ opacity: 0.9999;
+}
+
+.fgdragbox-label {
+ font-weight: bold;
+ text-align: center;
+ overflow: hidden;
+ color: var(--viewDragboxColor);
+}
+
+.timeIndicator {
+ opacity: 0.7;
+}
+
+.multiday-grid:not(.multiday-grid-rotated) .timeIndicator {
+ min-width: 1px;
+ margin-inline: -1px;
+ border-block-start: 2px solid var(--color-red-50);
+}
+
+.multiday-grid.multiday-grid-rotated .timeIndicator {
+ min-height: 1px;
+ margin-block: -1px;
+ border-inline-start: 2px solid var(--color-red-50);
+}
+
+.multiday-timebar-now-indicator {
+ background-color: var(--viewBackground);
+ position: absolute;
+ display: block;
+ border: 2px solid var(--color-red-50);
+ border-radius: 50%;
+}
+
+.multiday-grid:not(.multiday-grid-rotated) .multiday-timebar-now-indicator {
+ margin-block-start: -4px;
+ margin-inline-end: -6px;
+ height: 6px;
+ width: 6px;
+ inset-inline-end: 0;
+}
+
+.multiday-grid.multiday-grid-rotated .multiday-timebar-now-indicator {
+ margin-inline-start: -1px;
+ height: 8px;
+ width: 4px;
+ inset-block-end: 0px;
+}
+
+/* Take care to ensure the dummy event box in a calendar-event-column remains hidden. */
+calendar-event-box:not([hidden]) {
+ /* Be the containing block for the gripbar-start/gripbar-end elements. */
+ display: block;
+ position: relative;
+}
+
+calendar-month-day-box-item[selected="true"].calendar-color-box,
+calendar-event-box[selected="true"].calendar-color-box,
+calendar-editable-item[selected="true"].calendar-color-box {
+ color: var(--color-white) !important;
+ background-color: var(--color-blue-50) !important;
+ box-shadow: 0 3px 8px -3px rgba(0, 0, 0, 0.3);
+}
+
+calendar-day-label {
+ color: var(--viewColor);
+ background-color: var(--viewBackground);
+}
+
+calendar-day-label[relation="today"] {
+ background-color: var(--viewTodayDayLabelBackground);
+ color: var(--viewTodayColor);
+}
+
+.calendar-day-label-name {
+ text-align: center;
+}
+
+/* Multiweek/Month View */
+calendar-month-view,
+calendar-multiweek-view {
+ padding: 0px 2px 2px;
+ /* Only have a single child. Grid display will automatically stretch to fill
+ * the given space. */
+ display: grid;
+}
+
+calendar-month-view[hidden],
+calendar-multiweek-view[hidden] {
+ display: none;
+}
+
+.monthtable {
+ display: grid;
+ /* Equal-width columns. To ensure they remain equal width when spacing
+ * becomes small, we must make sure each variable width element has
+ * overflow-x set to ensure they can shrink.
+ * NOTE: we don't set the number of columns to a fixed 7 days of the week
+ * since some days of the week can be hidden. This requires the grid-row
+ * to be set for each child to ensure they end up on the correct row. */
+ grid-auto-columns: 1fr;
+ /* The first row is headers, and should not be stretched. */
+ grid-template-rows: auto;
+ /* All other rows share equal height. Again, variable height elements should
+ * have overflow-y set so they can shrink. */
+ grid-auto-rows: 1fr;
+ border-spacing: 0;
+ margin: 0;
+ padding: 0;
+ /* Complete the end borders. */
+ border-inline-end: 1px solid var(--viewBorderColor);
+ border-block-end: 1px solid var(--viewBorderColor);
+}
+
+.monthtable :is(tbody, thead, tr, td, th) {
+ /* Allow the calendar-month-day-box and calendar-day-label elements be the
+ * children of the grid. */
+ display: contents;
+}
+
+.monthtable :is(tr, th, td)[hidden] {
+ /* NOTE: Need this CSS rule because the hidden attribute behaviour is
+ * overridden by the "display: contents" rule above. */
+ display: none;
+}
+
+.monthtable :is(td, th) > * {
+ /* Hidden overflow ensures each cell has the same width/height, even when the
+ * space becomes limited, because this allows the cells to shrink. */
+ overflow: hidden;
+}
+
+.monthtable :is(td, th) > * {
+ /* Every cell in the body gets a starting border, which acts as the end border
+ * for the previous cell. */
+ border: 1px solid transparent;
+ border-inline-start: 1px solid var(--viewBorderColor);
+ border-block-start: 1px solid var(--viewBorderColor);
+}
+
+.monthtable calendar-day-label {
+ display: block;
+}
+
+calendar-month-day-box {
+ display: flex;
+ flex-direction: column;
+}
+
+.calendar-month-day-box-dates {
+ flex: 0 0 auto;
+ overflow-x: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 1rem;
+ padding: var(--calendar-month-day-box-padding);
+ margin: 0;
+}
+
+.calendar-month-day-box-date-label[data-label="day"] {
+ margin-inline-start: auto;
+}
+
+.calendar-month-day-box-list {
+ display: block;
+ margin: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0;
+ flex: 1 1 0;
+}
+
+.calendar-month-day-box-list-item {
+ display: block;
+ margin: 2px;
+}
+
+.calendar-month-day-box-current-month {
+ background-color: var(--viewBackground);
+}
+
+.calendar-month-day-box-day-off {
+ background-color: var(--viewWeekendBackground);
+}
+
+.calendar-month-day-box-other-month {
+ background-color: var(--viewMonthDayOtherBackground);
+}
+
+.calendar-month-day-box-other-month.calendar-month-day-box-day-off {
+ background-color: var(--viewMonthDayOtherBackground);
+}
+
+.calendar-month-day-box-current-month[relation="today"],
+.calendar-month-day-box-day-off[relation="today"],
+.calendar-month-day-box-other-month[relation="today"] {
+ background-color: var(--viewTodayBackground);
+}
+
+.calendar-month-day-box-date-label[relation="today"] {
+ color: var(--color-white);
+ position: relative;
+ font-weight: bold;
+ background-color: var(--viewTodayLabelBackground);
+ border-radius: 1000px;
+ text-align: center;
+ padding-block: var(--today-week-pill-padding-block);
+ padding-inline: var(--today-week-pill-padding-inline);
+}
+
+.calendar-month-day-box-current-month[selected="true"],
+.calendar-month-day-box-day-off[selected="true"],
+.calendar-month-day-box-other-month[selected="true"] {
+ border: 1px solid var(--viewHighlightBorderColor);
+}
+
+.calendar-month-day-box-current-month[selected="true"] {
+ background-color: var(--viewDayBoxSelectedBackground);
+}
+
+.calendar-month-day-box-day-off[selected="true"] {
+ background-color: var(--viewDayBoxSelectedBackground);
+}
+
+.calendar-month-day-box-other-month[selected="true"] {
+ background-color: var(--viewDayBoxOtherSelectedBackground);
+}
+
+.calendar-month-day-box-date-label[selected="true"] {
+ color: var(--viewMonthDayBoxSelectedColor);
+}
+
+.calendar-month-day-box-date-label[relation="today"][selected="true"] {
+ color: var(--viewTodayLabelColor);
+}
+
+.calendar-month-day-box-date-label {
+ color: var(--viewMonthDayBoxLabelColor);
+ font-size: 0.9rem;
+ font-weight: normal;
+ text-align: right;
+ margin: 0;
+ padding: 2px;
+}
+
+.calendar-month-day-box-week-label {
+ color: color-mix(in srgb, var(--viewMonthDayBoxLabelColor) 70%, transparent);
+ font-size: 0.75rem;
+ font-weight: bold;
+ margin: 0;
+ background-color: var(--viewMonthWeekLabelBackground);
+ border-radius: 1000px;
+ text-align: center;
+ padding-block: var(--today-week-pill-padding-block);
+ padding-inline: var(--today-week-pill-padding-inline);
+}
+
+.calendar-color-box {
+ /* FIXME: Is min-height needed? */
+ min-height: 13px;
+ border-radius: 2px;
+ background-color: var(--item-backcolor);
+ color: var(--item-forecolor);
+ position: relative;
+ overflow: hidden;
+}
+
+calendar-month-day-box calendar-month-day-box-item[allday="true"].calendar-color-box {
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, black 35%, var(--item-backcolor)),
+ inset 0 0 0 2px color-mix(in srgb, white 50%, var(--item-backcolor));
+}
+
+.dropshadow {
+ height: 1.2em;
+ background-color: color-mix(in srgb, var(--viewDropshadowBackground) 30%, transparent);
+ border: 2px dashed var(--viewDropshadowBackground);
+ border-radius: 3px;
+}
+
+:is(.gripbar-start, .gripbar-end) {
+ /* Invisible by default. */
+ visibility: hidden;
+ pointer-events: auto;
+ position: absolute;
+ /* Center the image. */
+ display: grid;
+ align-content: center;
+ justify-content: center;
+ overflow: clip;
+}
+
+.event-readonly :is(.gripbar-start, .gripbar-end),
+:is(.gripbar-start, .gripbar-end)[hidden] {
+ display: none;
+}
+
+calendar-event-box:hover :is(.gripbar-start, .gripbar-end) {
+ visibility: visible;
+}
+
+.gripbar-start {
+ cursor: n-resize;
+ inset-block: 0 auto;
+ inset-inline: 0;
+ padding-block: 1px 0;
+ padding-inline: 0;
+}
+
+.gripbar-end {
+ cursor: s-resize;
+ inset-block: auto 0;
+ inset-inline: 0;
+ padding-block: 0 1px;
+ padding-inline: 0;
+}
+
+/* Rotate the event-grippy.png image in rotated view. */
+.multiday-grid-rotated :is(.gripbar-start, .gripbar-end) img:-moz-locale-dir(ltr) {
+ transform: rotate(-90deg);
+}
+
+.multiday-grid-rotated :is(.gripbar-start, .gripbar-end) img:-moz-locale-dir(rtl) {
+ transform: rotate(90deg);
+}
+
+.multiday-grid-rotated :is(.gripbar-start, .gripbar-end) {
+ /* Explicitly set the content width to 3px (the height of the event-grippy.png
+ * img src) since we rotated the image. Otherwise the auto-width uses the
+ * width of the src. */
+ width: 3px;
+}
+
+.multiday-grid-rotated .gripbar-start {
+ inset-inline: 0 auto;
+ inset-block: 0;
+ padding-inline: 1px 0;
+ padding-block: 0;
+}
+
+.multiday-grid-rotated .gripbar-end {
+ inset-inline: auto 0;
+ inset-block: 0;
+ padding-inline: 0 1px;
+ padding-block: 0;
+}
+
+.multiday-grid-rotated :is(
+ .gripbar-start:-moz-locale-dir(ltr),
+ .gripbar-end:-moz-locale-dir(rtl),
+) {
+ cursor: w-resize;
+}
+
+.multiday-grid-rotated :is(
+ .gripbar-end:-moz-locale-dir(ltr),
+ .gripbar-start:-moz-locale-dir(rtl),
+) {
+ cursor: e-resize;
+}
+
+/* tooltips */
+.tooltipBox {
+ max-width: 40em;
+}
+
+.tooltipValueColumn {
+ max-width: 35em; /* tooltipBox max-width minus space for label */
+}
+
+.tooltipHeaderTable {
+ border-spacing: 0;
+}
+
+.tooltipHeaderLabel {
+ text-align: end;
+ padding-inline-end: 0.5em;
+}
+
+.tooltipBodySeparator {
+ height: 1ex; /* 1ex space above body text, below last header. */
+}
+
+.tooltipBody {
+ font-weight: normal;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ margin: 0pt;
+}
+
+#conflicts-vbox .tooltipBody {
+ overflow: auto;
+ min-height: 250px;
+}
+
+#calendar-view-context-menu[type="event"] .todo-only,
+#calendar-view-context-menu[type="todo"] .event-only,
+#calendar-view-context-menu[type="mixed"] .event-only,
+#calendar-view-context-menu[type="mixed"] .todo-only,
+#calendar-item-context-menu[type="event"] .todo-only,
+#calendar-item-context-menu[type="todo"] .event-only,
+#calendar-item-context-menu[type="mixed"] .event-only,
+#calendar-item-context-menu[type="mixed"] .todo-only {
+ display: none;
+}
+
+.attendance-menu[itemType="single"] > menupopup > *[scope="all-occurrences"] {
+ display: none;
+}
+
+.calendar-context-heading-label {
+ font-weight: bold;
+ color: menutext;
+}
+
+calendar-event-box,
+calendar-editable-item,
+calendar-month-day-box-item {
+ opacity: 0.99;
+ /* Do not change next line, since it would break item selection */
+ -moz-user-focus: normal;
+ overflow: hidden;
+}
+
+calendar-event-box[invitation-status="NEEDS-ACTION"],
+calendar-editable-item[invitation-status="NEEDS-ACTION"],
+calendar-month-day-box-item[invitation-status="NEEDS-ACTION"],
+.agenda-listitem[status="NEEDS-ACTION"] .agenda-listitem-details {
+ outline: 2px dotted black;
+ outline-offset: -2px;
+ opacity: 0.6;
+}
+
+calendar-event-box[invitation-status="TENTATIVE"],
+calendar-editable-item[invitation-status="TENTATIVE"],
+calendar-month-day-box-item[invitation-status="TENTATIVE"],
+calendar-event-box[status="TENTATIVE"],
+calendar-editable-item[status="TENTATIVE"],
+calendar-month-day-box-item[status="TENTATIVE"],
+.agenda-listitem[status="TENTATIVE"] .agenda-listitem-details {
+ opacity: 0.6;
+}
+
+calendar-event-box[invitation-status="DECLINED"],
+calendar-editable-item[invitation-status="DECLINED"],
+calendar-month-day-box-item[invitation-status="DECLINED"],
+.agenda-listitem[status="DECLINED"] .agenda-listitem-details,
+calendar-event-box[status="CANCELLED"],
+calendar-editable-item[status="CANCELLED"],
+calendar-month-day-box-item[status="CANCELLED"],
+.agenda-listitem[status="CANCELLED"] .agenda-listitem-details {
+ opacity: 0.5;
+}
+
+calendar-month-day-box-item[status="CANCELLED"],
+calendar-event-box[status="CANCELLED"],
+calendar-editable-item[status="CANCELLED"],
+.agenda-listitem[status="CANCELLED"] .agenda-event-start .agenda-listitem-details {
+ text-decoration: line-through;
+}
+
+/* Calendar body */
+#view-box {
+ background-color: var(--layout-background-1);
+}
+
+:root[lwt-tree] #view-box {
+ background-color: var(--sidebar-background-color);
+}
+
+/* Calendar control bar: Cal navigation, Date interval, Week# and View toggle*/
+
+#calendarViewHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--calendar-nav-control-padding);
+ gap: 6px;
+}
+
+ #calendarViewHeader > div > span {
+ -webkit-line-clamp: 2;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.navigation-inner-box {
+ display: flex;
+ align-items: center;
+ gap: var(--calendar-view-toolbar-gap);
+}
+
+#calendarControls {
+ --button-margin: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--calendar-nav-gap);
+}
+
+.view-header {
+ font-weight: bold;
+ font-size: 1rem;
+ color: inherit;
+}
+
+#previousViewButton {
+ background-image: var(--icon-nav-left);
+}
+
+#nextViewButton {
+ background-image: var(--icon-nav-right);
+}
+
+#todayViewButton {
+ background-image: var(--icon-calendar-today);
+}
+
+.calview-toggle {
+ border-radius: 1000px;
+ background-color: var(--calendar-view-toggle-background);
+ border: 1px solid var(--calendar-view-toggle-border-color);
+ box-shadow: inset 0 0 6px -3px var(--calendar-view-toggle-shadow-color);
+ display: flex;
+ font-weight: 500;
+}
+
+button.calview-toggle-item {
+ appearance: none;
+ min-width: unset;
+ min-height: unset;
+ border: 1px solid transparent;
+ border-radius: 1000px;
+ background-color: var(--calendar-view-toggle-background);
+ color: inherit;
+ padding: var(--calendar-view-toggle-label-padding);
+ margin: 3px;
+}
+
+button.calview-toggle-item:focus-visible {
+ outline: var(--focus-outline);
+ outline-offset: 1px;
+}
+
+button.calview-toggle-item[role="tab"][aria-selected="true"] {
+ background-color: var(--calendar-view-toggle-selected-background);
+ border: 1px solid var(--calendar-view-toggle-border-color);
+ box-shadow: 0 0 3px var(--calendar-view-toggle-shadow-color);
+}
+
+button.calview-toggle-item[role="tab"][aria-selected="true"]:hover {
+ background-color: var(--calendar-view-toggle-selected-hover-background);
+}
+
+button.calview-toggle-item:hover,
+button.calview-toggle-item[role="tab"][aria-selected="false"]:hover {
+ background-color: var(--calendar-view-toggle-hover-background);
+}
+
+button.calview-toggle-item:hover:active,
+button.calview-toggle-item[role="tab"][aria-selected="false"]:hover:active {
+ background-color: var(--calendar-view-toggle-active-background);
+}
+
+.today-navigation-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ color: inherit;
+ outline: none;
+}
+
+:root[lwt-tree-brighttext] .today-navigation-button:not([disabled="true"]):hover {
+ background: rgba(255, 255, 255, .25);
+ border-color: rgba(255, 255, 255, .5);
+}
+
+.today-navigation-button:not([disabled="true"]):hover:active {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+ :root[lwt-tree-brighttext] .today-navigation-button:not([disabled="true"]):hover:active {
+ background: rgba(255, 255, 255, .4);
+ border-color: rgba(255, 255, 255, .7);
+ box-shadow: inset 0 0 rgba(255, 255, 255, .4);
+}
+
+#calendarControlBarMenu {
+ background-image: var(--icon-display-options);
+}
+
+.fgdragspacer {
+ display: inherit;
+ overflow: hidden;
+}
+
+.fgdragcontainer {
+ min-width: 1px;
+ min-height: 1px;
+ overflow:hidden;
+}
+
+.multiday-events-list,
+.timeIndicator {
+ pointer-events: none;
+}
diff --git a/comm/calendar/base/themes/common/calendar.css b/comm/calendar/base/themes/common/calendar.css
new file mode 100644
index 0000000000..4914623d85
--- /dev/null
+++ b/comm/calendar/base/themes/common/calendar.css
@@ -0,0 +1,255 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* For deactivating calendar, see calCalendarDeactivator.jsm. */
+:root[calendar-deactivated] .hide-when-calendar-deactivated {
+ display: none;
+}
+
+/* ::: new tab buttons ::: */
+#calendar-tab-button,
+#newMsgButton-calendar-menuitem {
+ list-style-image: var(--icon-calander);
+}
+
+#task-tab-button,
+#newMsgButton-task-menuitem {
+ list-style-image: var(--icon-tasks);
+}
+
+:root[lwt-tree] #calSidebar {
+ background-color: var(--sidebar-background-color);
+ color: var(--sidebar-text-color);
+}
+
+#calSidebar {
+ background-color: var(--layout-background-2);
+ overflow: hidden;
+ flex-shrink: 0;
+ min-width: 175px;
+}
+
+#calendarContent {
+ background-color: var(--layout-background-0);
+}
+
+/* Avoids contributing to the min width when Calendar is not selected. */
+#calendarTabPanel:not([selected]) {
+ visibility: collapse;
+}
+
+#calendarDisplayBox {
+ overflow: auto;
+}
+
+/* Invitations link in status bar */
+#calendar-invitations-label {
+ margin-block: 3px;
+}
+
+/* Today pane button in status bar */
+#calendar-status-todaypane-button,
+#calendar-status-todaypane-button[checked="true"] {
+ min-width: 0;
+ min-height: 0;
+ margin: 1px 0 0;
+ appearance: none;
+ border-radius: var(--button-border-radius);
+ border: 1px solid transparent;
+}
+
+#calendar-status-todaypane-button:hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+}
+
+#calendar-status-todaypane-button:hover:active {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+#calendar-status-todaypane-button > stack > .toolbarbutton-icon-begin {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#calendar-status-todaypane-button > stack > .toolbarbutton-day-text {
+ text-align: center;
+ margin-inline-start: 0;
+ margin-bottom: 0;
+ font-size: 7pt;
+ font-family: Arial, Helvetica, sans-serif;
+ font-weight: bold;
+ text-shadow: none;
+ background-color: transparent;
+}
+
+#calendar-status-todaypane-button[hideLabel] > .toolbarbutton-text,
+#calendar-status-todaypane-button[hideLabel] > .toolbarbutton-icon-end {
+ display: none;
+}
+
+#calendar-status-todaypane-button > .toolbarbutton-icon-end {
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+#calendar-status-todaypane-button[checked="true"] > .toolbarbutton-icon-end {
+ transform: scaleY(-1);
+}
+
+/* iMIP notification bar */
+.calendar-notification-bar {
+ background-color: var(--color-blue-10);
+ padding: 3px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .calendar-notification-bar {
+ background-color: var(--color-purple-40);
+ }
+}
+
+@media (prefers-contrast) {
+ .calendar-notification-bar:not(:-moz-lwtheme) {
+ background-color: var(--selected-item-color);
+ }
+
+ .calendar-notification-bar:not(:-moz-lwtheme) > *:not(#imip-view-toolbox) {
+ color: var(--selected-item-text-color);
+ }
+}
+
+.calendar-notification-bar {
+ margin: 0 4px 4px;
+ border-radius: 4px;
+ box-shadow: 0 1px 2px rgba(58, 57, 68, 0.3);
+}
+
+#imip-view-toolbar {
+ --imip-button-background: var(--layout-background-1);
+}
+
+#imip-bar > img {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ width: 20px;
+}
+
+#imip-view-toolbar > .toolbarbutton-1[is="toolbarbutton-menu-button"] {
+ border-radius: var(--button-border-radius);
+}
+
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ margin-inline-start: 6px;
+}
+
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button > .toolbarbutton-menubutton-button,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button > .toolbarbutton-menubutton-dropmarker {
+ border-color: var(--toolbarbutton-header-bordercolor);
+ background-image: linear-gradient(var(--imip-button-background), var(--imip-button-background));
+}
+
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button:not(:active):hover,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button:is(:hover,[open="true"]) >
+ .toolbarbutton-menubutton-button,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button:hover >
+ .toolbarbutton-menubutton-dropmarker {
+ background-image: linear-gradient(var(--toolbarbutton-hover-background),
+ var(--toolbarbutton-hover-background)),
+ linear-gradient(var(--imip-button-background), var(--imip-button-background));
+}
+
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button:not([is="toolbarbutton-menu-button"]):hover:active,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button[is="toolbarbutton-menu-button"] >
+ .toolbarbutton-menubutton-button:hover:active,
+#imip-view-toolbar > .toolbarbutton-1.message-header-view-button[open="true"] >
+ .toolbarbutton-menubutton-dropmarker {
+ background-image: linear-gradient(var(--toolbarbutton-active-background),
+ var(--toolbarbutton-active-background)),
+ linear-gradient(var(--imip-button-background), var(--imip-button-background));
+}
+
+.imipMoreButton > .toolbarbutton-icon {
+ display: none;
+}
+
+.imipAcceptRecurrencesButton,
+.imipAcceptButton {
+ list-style-image: var(--icon-check);
+}
+
+.imipDeclineCounterButton,
+.imipDeclineRecurrencesButton,
+.imipDeclineButton {
+ list-style-image: var(--icon-close);
+}
+
+.imipTentativeRecurrencesButton,
+.imipTentativeButton {
+ list-style-image: var(--icon-tentative);
+}
+
+.imipDetailsButton {
+ list-style-image: var(--icon-search);
+}
+
+.imipAddButton {
+ list-style-image: var(--icon-new-event);
+}
+
+.imipRescheduleButton,
+.imipUpdateButton {
+ list-style-image: var(--icon-sync);
+}
+
+.imipDeleteButton {
+ list-style-image: var(--icon-trash);
+}
+
+.imipReconfirmButton {
+ list-style-image: var(--icon-priority);
+}
+
+.imipGoToCalendarButton {
+ list-style-image: var(--icon-calendar-imip);
+}
+
+.imipAcceptLabel {
+ font-weight: bold;
+ font-style: italic;
+}
+
+#calsidebar_splitter,
+#today-splitter {
+ /* splitter grip area */
+ width: 5px;
+ border-width: 0;
+ min-width: 0;
+ margin-top: 0;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+ transition: border-width .3s ease-in;
+}
+
+#calsidebar_splitter[state="collapsed"] {
+ border-inline-start: 1px solid transparent;
+}
+
+#calsidebar_splitter[state="collapsed"]:hover {
+ border-inline-start: 4px solid var(--selected-item-color);
+}
diff --git a/comm/calendar/base/themes/common/datetimepickers.css b/comm/calendar/base/themes/common/datetimepickers.css
new file mode 100644
index 0000000000..94c184b8eb
--- /dev/null
+++ b/comm/calendar/base/themes/common/datetimepickers.css
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*--------------------------------------------------------------------
+ * Datepicker (text field with minimonth popup)
+ *-------------------------------------------------------------------*/
+
+@import url("chrome://messenger/skin/menulist.css");
+
+timepicker-grids {
+ --tpMainColor: -moz-DialogText;
+ --tpMainBackground: -moz-Dialog;
+ --tpBorderColor: ThreeDShadow;
+ --tpSubColor: -moz-DialogText;
+ --tpSubBackground: -moz-Dialog;
+ --tpFiveminColor: WindowText;
+ --tpFiveminBackground: Window;
+ --tpItemHoverColor: InactiveCaptionText;
+ --tpItemHoverBackground: InactiveCaption;
+ --tbHighlightColor: var(--selected-item-text-color);
+ --tbHighlightBackground: var(--selected-item-color);
+}
+
+:root[lwt-tree] timepicker-grids {
+ --tpMainColor: var(--sidebar-text-color);
+ --tpMainBackground: var(--sidebar-background-color);
+ --tpBorderColor: rgba(0, 0, 0, 0.5);
+ --tpSubColor: var(--tpMainColor);
+ --tpSubBackground: rgba(0, 0, 0, 0.1);
+ --tpFiveminColor: var(--tpMainColor);
+ --tpFiveminBackground: transparent;
+ --tpItemHoverColor: var(--tpMainColor);
+ --tpItemHoverBackground: rgba(0, 0, 0, 0.3);
+}
+
+:root[lwt-tree-brighttext] timepicker-grids {
+ --tpBorderColor: rgba(255, 255, 255, 0.5);
+ --tpSubBackground: rgba(255, 255, 255, 0.15);
+ --tpItemHoverBackground: rgba(255, 255, 255, 0.3);
+ --tbHighlightColor: #fff;
+ --tbHighlightBackground: #0a84ff;
+}
+
+/* menulist */
+datepicker > menulist::part(text-input) {
+ width: 9em;
+}
+
+/*-------------------------------------------------------------------
+ * Timepicker (text menulist with popup)
+ *-------------------------------------------------------------------*/
+
+timepicker > menulist::part(text-input) {
+ width: 6em;
+}
+
+/*-------------------------------------------------------------------
+ * popup (from timepicker/timepicker.css)
+ *-------------------------------------------------------------------*/
+
+.timepicker-menulist > menupopup::part(content) {
+ --panel-padding: 3px;
+}
+
+/* Box that occupies whole window */
+
+.time-picker-grids {
+ background-color: var(--tpMainBackground);
+ font-size: 8pt;
+ margin: 1px 1px 2px;
+}
+
+/* Grid for hours */
+
+.time-picker-hour-grid {
+ border-block: 1px solid var(--tpBorderColor);
+ border-inline-end: 1px solid var(--tpBorderColor);
+ margin: 1px;
+}
+
+/* Boxes with AM/PM labels */
+
+.timepicker-amLabelBox-class,
+.timepicker-pmLabelBox-class {
+ border-inline-start: 3px double var(--tpBorderColor);
+ background-color: var(--tpFiveminBackground);
+ color: var(--tpFiveminColor);
+}
+
+/* Box in each cell of the grid for hours */
+
+.time-picker-hour-box-class {
+ background-color: var(--tpSubBackground);
+ color: var(--tpMainColor);
+}
+
+.time-picker-hour-grid[format12hours="false"] .time-picker-hour-box-class {
+ min-width: 28px;
+ align-items: center;
+ border-inline-start: 1px solid var(--tpBorderColor);
+}
+
+.time-picker-hour-grid[format12hours="true"] .time-picker-hour-box-class {
+ min-width: 24px;
+ align-items: center;
+ border-inline-start: 1px solid var(--tpBorderColor);
+}
+
+.timepicker-topRow-hour-class {
+ border-bottom: 1px solid var(--tpBorderColor);
+}
+
+.time-picker-hour-grid[format12hours="true"] .timepicker-topRow-hour-class {
+ border-bottom: 3px double var(--tpBorderColor);
+}
+
+.time-picker-hour-box-class:hover {
+ background-color: var(--tpItemHoverBackground);
+ color: var(--tpItemHoverColor);
+ cursor: pointer;
+}
+
+/* selected hour box */
+
+.time-picker-hour-box-class[selected="true"],
+.time-picker-hour-box-class[selected="true"]:hover {
+ background-color: var(--tbHighlightBackground);
+ color: var(--tbHighlightColor);
+}
+
+/* label inside each minute/hour */
+
+.time-picker-minute-label,
+.time-picker-hour-label {
+ text-align: center;
+}
+
+.time-picker-minute-label:hover,
+.time-picker-hour-label:hover {
+ cursor: pointer !important;
+}
+
+
+.time-picker-minute-box-class {
+ align-items: center;
+ border-inline-end: 1px solid var(--tpBorderColor);
+ border-bottom: 1px solid var(--tpBorderColor);
+}
+
+.time-picker-minute-box-class:hover {
+ cursor: pointer;
+ background-color: var(--tpSubBackground);
+ color: var(--tpSubColor);
+}
+
+/* box around five minute grid */
+
+.time-picker-five-minute-grid-box {
+ min-width: 195px;
+ margin-inline-start: 1px;
+}
+
+/* five minute grid */
+
+.time-picker-five-minute-grid {
+ margin-top: 2px;
+ margin-inline-end: 1px;
+ border-top: 1px solid var(--tpBorderColor);
+ border-inline: 1px solid var(--tpBorderColor);
+ background-color: var(--tpFiveminBackground);
+ color: var(--tpFiveminColor);
+}
+
+
+/* box in five-minute grid elements */
+
+.time-picker-five-minute-class:hover {
+ background-color: var(--tpItemHoverBackground);
+ color: var(--tpItemHoverColor);
+ cursor: pointer;
+}
+
+.time-picker-minute-label[selected="true"]:hover {
+ background-color: var(--tbHighlightBackground);
+ color: var(--tbHighlightColor);
+ cursor: pointer;
+}
+
+
+/* selected five-minute grid element box */
+
+.time-picker-five-minute-class[selected="true"] {
+ background-color: var(--tbHighlightBackground);
+ color: var(--tbHighlightColor);
+}
+
+/* box around one minute grid */
+.time-picker-one-minute-grid-box {
+ min-width: 195px;
+ margin-inline-start: 1px;
+}
+
+/* one minute grid */
+
+.time-picker-one-minute-grid {
+ margin-top: 2px;
+ margin-inline-end: 1px;
+ border-top: 1px solid var(--tpBorderColor);
+ border-inline: 1px solid var(--tpBorderColor);
+ background-color: var(--tpFiveminBackground);
+ color: var(--tpFiveminColor);
+}
+
+/* box in one-minute grid elements */
+
+.time-picker-one-minute-class {
+ align-items: center;
+}
+
+.time-picker-one-minute-class:hover {
+ background-color: var(--tpItemHoverBackground);
+ color: var(--tpItemHoverColor);
+ cursor: pointer;
+}
+
+.time-picker-one-minute-class[selected="true"]>label:hover {
+ background-color: var(--tbHighlightBackground) !important;
+ color: var(--tbHighlightColor) !important;
+ cursor: pointer;
+}
+
+/* selected one-minute grid element box */
+
+.time-picker-one-minute-class[selected="true"]{
+ background-color: var(--tbHighlightBackground);
+ color: var(--tbHighlightColor);
+}
+
+.time-picker-more-control-label {
+ background-color: var(--tbHighlightBackground);
+ color: var(--tbHighlightColor);
+ margin: 0;
+ border: 1px solid var(--tpSubBackground);
+ padding-inline: 8px;
+ font-size: 1rem;
+}
+
+.time-picker-more-control-label:hover {
+ border-color: var(--tpBorderColor);
+}
+
+/* line across the bottom of the minute boxes, made to line up with more & less tabs */
+
+.time-picker-minutes-bottom {
+ background-color: var(--tpSubBackground);
+ color: var(--tpSubColor);
+ border: 1px solid var(--tpBorderColor);
+ margin-inline-end: 1px;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css
new file mode 100644
index 0000000000..cfcf1615fc
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendar-alarm-dialog[lwt-tree] {
+ background-color: var(--lwt-accent-color);
+ color: var(--lwt-text-color);
+}
+
+/* Alarm dialog styles */
+#alarm-richlist {
+ margin: 10px;
+ text-shadow: none;
+}
+
+#alarm-actionbar {
+ min-width: 1px;
+ margin: 0 5px 10px;
+}
+
+/* Alarm widget specific styles */
+richlistitem[is="calendar-alarm-widget-richlistitem"] {
+ border-bottom: 1px dotted ThreeDShadow;
+ padding: 6px 7px;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] {
+ padding: 0 5px;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] .alarm-title-label {
+ font-weight: bold;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] .alarm-action-buttons {
+ display: flex;
+ color: var(--field-text-color);
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] > hbox {
+ margin: 5px;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] .alarm-relative-date-label,
+.additional-information-box,
+.alarm-action-buttons {
+ display: none;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] .additional-information-box,
+richlistitem[is="calendar-alarm-widget-richlistitem"][selected="true"] .action-buttons-box {
+ display: flex;
+}
+
+.alarm-calendar-event {
+ overflow: hidden;
+}
+
+.alarm-location-description {
+ display: flex;
+}
+
+.alarm-details-label {
+ color: inherit !important;
+ text-decoration: underline;
+}
+
+.alarm-calendar-image {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ margin-top: 2px;
+}
+
+richlistitem[is="calendar-alarm-widget-richlistitem"]:not([selected="true"]) .alarm-calendar-image {
+ display: none;
+}
+
+.snooze-popup-button {
+ min-width: 0;
+ appearance: none;
+ margin-inline-end: 2px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.snooze-popup-button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+.snooze-popup-ok-button {
+ list-style-image: url(chrome://calendar/skin/shared/icons/complete.svg);
+}
+
+.snooze-popup-cancel-button {
+ list-style-image: url(chrome://calendar/skin/shared/icons/decline.svg);
+}
+
+.snooze-popup-button > .button-box > .button-icon {
+ margin: 0;
+}
+
+.snooze-popup-button > .button-box {
+ border: 0;
+ padding: 0;
+ justify-content: center;
+ align-items: center;
+}
+
+.snooze-popup-button:focus > .button-box {
+ border: 1px dotted ThreeDDarkShadow;
+ padding: 0;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-event-dialog-attendees.css b/comm/calendar/base/themes/common/dialogs/calendar-event-dialog-attendees.css
new file mode 100644
index 0000000000..ede57761dd
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-event-dialog-attendees.css
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#spacer {
+ border: 1px solid transparent;
+}
+
+#attendee-list {
+ margin: 0;
+ border: 1px solid var(--field-border-color);
+ overflow-x: scroll;
+ background-attachment: local;
+ background-color: var(--field-background-color);
+ background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2250%22%20height%3D%2221%22%3E%3Cpath%20d%3D%22M49.5%2020.5H0%22%20fill%3D%22none%22%20stroke%3D%22threedshadow%22%20stroke-width%3D%221%22%2F%3E%3C%2Fsvg%3E%0A);
+ color: var(--field-text-color);
+}
+
+.attendees-grid-container {
+ width: 540px;
+ overflow-y: auto;
+}
+
+event-attendee {
+ height: 21px;
+ padding: 0 4px 1px;
+ display: flex;
+ align-items: center;
+}
+
+event-attendee > .role-icon {
+ margin: 0;
+}
+
+event-attendee > input {
+ flex: 1 1 auto;
+ outline: none !important;
+}
+
+/* Autocomplete labels
+ * These styles match those in chrome://messenger/skin/shared/messenger.css. */
+
+span.ac-emphasize-text {
+ font-weight: bold;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-url,
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-separator {
+ display: flex;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-url {
+ order: 1;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-separator {
+ order: 2;
+}
+
+.autocomplete-richlistitem[type$="-abook"]:not([ac-comment=""]) > .ac-title {
+ order: 3;
+}
+
+.ac-url-text {
+ max-width: unset !important;
+}
+
+#outer > splitter {
+ background-color: transparent;
+ border: none;
+}
+
+#day-header-outer {
+ border: 1px solid var(--field-border-color);
+ border-bottom: none;
+ overflow: hidden;
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+ text-align: center;
+}
+
+#day-header-inner {
+ padding-bottom: 6px;
+ background-position: left bottom;
+ background-repeat: repeat-x;
+}
+
+#day-header-inner.twoMinorColumns {
+ background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2260%22%20height%3D%226%22%20fill%3D%22none%22%20stroke%3D%22threedshadow%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M59.5%200V6%22%2F%3E%3C%2Fsvg%3E);
+}
+
+#day-header-inner.threeMinorColumns {
+ background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2290%22%20height%3D%226%22%20fill%3D%22none%22%20stroke%3D%22threedshadow%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M89.5%200V6%22%2F%3E%3C%2Fsvg%3E);
+}
+
+calendar-day {
+ border-inline-end: 1px solid var(--field-border-color);
+ flex-direction: column;
+ justify-content: flex-end;
+}
+
+.day-label {
+ margin: 2px 0;
+ text-align: center;
+ font-weight: 600;
+}
+
+.hour-label {
+ margin: 0 0 2px;
+}
+
+#freebusy-grid {
+ margin: 0;
+ border: 1px solid var(--field-border-color);
+ overflow: scroll auto;
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+}
+
+.day-column {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+}
+
+.day-off {
+ background-color: var(--viewWeekendBackground);
+}
+
+.time-off {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ background-color: var(--viewOffTimeBackground);
+}
+
+.day-off > .time-off {
+ background-color: var(--viewMonthDayOffLabelBackground);
+}
+
+#freebusy-grid-inner {
+ overflow: hidden;
+}
+
+#freebusy-grid-inner.twoMinorColumns {
+ background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2260%22%20height%3D%2221%22%20fill%3D%22none%22%20stroke%3D%22threedshadow%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M59.5%200V20.5H0%22%2F%3E%3Cpath%20d%3D%22M29.5%200V20.5%22%20stroke-opacity%3D%220.33%22%20stroke-dasharray%3D%2215%25%22%2F%3E%3C%2Fsvg%3E);
+}
+
+#freebusy-grid-inner.threeMinorColumns {
+ background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2290%22%20height%3D%2221%22%20fill%3D%22none%22%20stroke%3D%22threedshadow%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M89.5%200V20.5H0%22%2F%3E%3Cpath%20d%3D%22M29.5%200V20.5%22%20stroke-opacity%3D%220.44%22%2F%3E%3Cpath%20d%3D%22M59.5%200V20.5%22%20stroke-opacity%3D%220.44%22%2F%3E%3C%2Fsvg%3E);
+}
+
+.freebusy-row {
+ position: relative;
+ height: 21px;
+ box-sizing: border-box;
+ padding: 2px 0 3px;
+}
+
+.freebusy-row > .pending {
+ position: absolute;
+ top: 0;
+ bottom: 1px;
+ background-color: #999;
+}
+
+.freebusy-row > .busy,
+.freebusy-row > .tentative,
+.freebusy-row > .unavailable,
+.freebusy-row > .unknown {
+ position: absolute;
+ top: 2px;
+ bottom: 3px;
+}
+.freebusy-row > .busy {
+ background-color: #153e7e;
+}
+.freebusy-row > .tentative {
+ background-color: #1589ff;
+}
+.freebusy-row > .unavailable {
+ background-color: #4e387e;
+}
+.freebusy-row > .unknown {
+ background-color: #e09ebd;
+}
+
+#event-bar-top {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ box-sizing: border-box;
+ border: 1px #f045a5 solid;
+ border-bottom: none;
+ cursor: move;
+ background-color: rgba(240, 69, 165, 0.44);
+}
+
+#event-bar-bottom {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ box-sizing: border-box;
+ border: 1px #f045a5 solid;
+ border-top: none;
+ background-color: rgba(240, 69, 165, 0.44);
+}
+
+.zoom-in-icon,
+.zoom-out-icon {
+ margin: 3px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+.zoom-in-icon[disabled="true"],
+.zoom-out-icon[disabled="true"] {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 10%, transparent);
+ stroke: color-mix(in srgb, currentColor 40%, transparent);
+}
+
+.zoom-in-icon {
+ list-style-image: var(--font-size-increase);
+}
+
+.zoom-out-icon {
+ list-style-image: var(--font-size-decrease);
+}
+
+.legend {
+ display: flex;
+ width: 3em;
+ height: 1em;
+ border-top: 1px solid #a1a1a1;
+ border-bottom: 1px solid #dddddd;
+ border-inline: 1px solid #c3c3c3;
+}
+
+.legend[status="FREE"] {
+ background-color: #ebebe4;
+ color: #ebebe4;
+}
+
+.legend[status="BUSY"] {
+ background-color: #153e7e;
+ color: #153e7e;
+}
+
+.legend[status="BUSY_TENTATIVE"] {
+ background-color: #1589ff;
+ color: #1589ff;
+}
+
+.legend[status="BUSY_UNAVAILABLE"] {
+ background-color: #4e387e;
+ color: #4e387e;
+}
+
+.legend[status="UNKNOWN"] {
+ background-color: #e09ebd;
+ color: #e09ebd;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-event-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-event-dialog.css
new file mode 100644
index 0000000000..e2fa244a2c
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-event-dialog.css
@@ -0,0 +1,677 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+#calendar-event-window,
+#calendar-task-window {
+ min-width: 43em;
+ min-height: 51em;
+}
+
+#calendar-event-dialog:-moz-lwtheme,
+#calendar-task-dialog:-moz-lwtheme {
+ background-color: transparent;
+}
+
+:root:not([lwt-tree]):-moz-lwtheme #calendar-item-panel-iframe {
+ background-color: -moz-Dialog;
+}
+#calendar-event-dialog-inner:-moz-lwtheme,
+#calendar-task-dialog-inner:-moz-lwtheme {
+ background-image: none !important;
+}
+
+#calendar-event-dialog,
+#calendar-task-dialog,
+#calendar-event-dialog-inner,
+#calendar-event-summary-dialog,
+#calendar-task-dialog-inner {
+ padding: 0;
+}
+
+#calendar-event-dialog .todo-only,
+#calendar-task-dialog .event-only,
+#calendar-event-dialog-inner .todo-only,
+#calendar-task-dialog-inner .event-only {
+ display: none;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog toolbar buttons
+ *-------------------------------------------------------------------*/
+
+.cal-event-toolbarbutton {
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#button-save {
+ list-style-image: var(--icon-download);
+}
+
+#button-save[mode="send"] {
+ list-style-image: var(--icon-sent);
+}
+
+#saveandcloseButton,
+#button-saveandclose {
+ list-style-image: var(--icon-download);
+}
+
+#button-saveandclose[mode="send"] {
+ list-style-image: var(--icon-sent);
+}
+
+#button-attendees {
+ list-style-image: var(--icon-address-book);
+}
+
+#button-privacy {
+ list-style-image: var(--icon-lock);
+}
+
+#button-url {
+ list-style-image: var(--icon-attachment);
+}
+
+#deleteButton,
+#button-delete.cal-event-toolbarbutton {
+ /* !important to override the SM #button-delete states */
+ list-style-image: var(--icon-trash) !important;
+}
+
+#button-priority {
+ list-style-image: var(--icon-priority);
+}
+
+#button-status {
+ list-style-image: var(--icon-event-status);
+}
+
+#button-freebusy {
+ list-style-image: var(--icon-clock);
+}
+
+#button-timezones {
+ list-style-image: var(--icon-globe);
+}
+
+#acceptButton {
+ list-style-image: var(--icon-check);
+}
+
+#tentativeButton {
+ list-style-image: var(--icon-tentative);
+}
+
+#declineButton {
+ list-style-image: var(--icon-close);
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog counter box section
+ *-------------------------------------------------------------------*/
+
+#counter-proposal-box {
+ background-color: rgb(186, 238, 255);
+ border-bottom: 1px solid #444444;
+}
+
+#counter-proposal-box > vbox:not(#counter-buttons) {
+ color: #000;
+}
+
+#counter-proposal-property-values > description {
+ margin-bottom: 2px;
+}
+
+#counter-proposal-summary {
+ font-weight: bold;
+}
+
+.counter-buttons {
+ max-height: 25px;
+}
+
+#yearly-period-of-label,
+label.label {
+ text-align: right;
+}
+
+#item-calendar,
+.item-calendar,
+#item-categories,
+#item-repeat,
+.item-alarm,
+.datepicker-text-class {
+ min-width: 12em;
+}
+
+.cal-event-toolbarbutton .toolbarbutton-icon {
+ width: 18px;
+ height: 18px;
+}
+
+#event-grid {
+ padding-top: 8px;
+ padding-inline-start: 8px;
+ padding-inline-end: 10px;
+ border-spacing: 0;
+}
+
+#event-grid > tr > th {
+ text-align: left;
+ font-weight: normal;
+}
+
+#event-grid > tr > td {
+ width: 100%;
+}
+
+#event-grid-tab-box-row,
+#event-grid-tabbox,
+.event-grid-tabpanels {
+ flex: 1;
+}
+
+.event-input-td > input {
+ flex: 1;
+}
+
+#item-calendar,
+.item-calendar,
+#item-categories {
+ flex: 1;
+ width: 100%;
+}
+
+#item-calendar::part(icon) {
+ margin-inline: 7px 3px;
+}
+
+#item-calendar::part(icon),
+#item-calendar > menupopup > menuitem .menu-iconic-icon,
+#item-categories::part(color) {
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+ background-color: var(--item-color);
+}
+
+#item-categories > menupopup > menuitem .menu-iconic-left {
+ margin-inline-end: 3px;
+}
+
+#item-categories > menupopup > menuitem .menu-iconic-text {
+ padding-inline-start: 15px;
+ background-image: var(--icon-circle-small);
+ background-position: left center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: var(--item-color);
+}
+
+#item-categories > menupopup > menuitem .menu-iconic-text:dir(rtl) {
+ background-position-x: right;
+}
+
+#item-categories::part(color) {
+ margin-inline-end: 1px;
+}
+
+#item-categories::part(color first) {
+ margin-inline-start: 7px;
+}
+
+#item-categories::part(color last) {
+ margin-inline-end: 3px !important;
+}
+
+#item-categories-textbox {
+ margin: 1px 8px;
+}
+
+#event-grid-item-calendar-td,
+#event-grid-category-color-td,
+.event-input-td {
+ display: flex;
+}
+
+#event-grid > tr > td > menulist,
+#event-grid checkbox,
+#event-grid td,
+#event-grid th {
+ margin: 0;
+ padding: 0;
+ margin-inline-end: 0;
+ margin-inline-start: 0;
+}
+
+.item-location-link,
+#item-location,
+.item-location,
+#item-title,
+.item-title {
+ margin: 0;
+ margin-inline-end: 0;
+ margin-inline-start: 0;
+ padding-inline-start: 4px;
+}
+
+.item-location-link {
+ padding-inline-start: 0;
+}
+
+.item-location-link > label {
+ cursor: pointer;
+}
+
+#todo-status,
+#item-repeat,
+.item-alarm {
+ margin: 0;
+}
+
+#event-grid td,
+#event-grid th {
+ padding: 4px 0;
+}
+
+#event-grid-startdate-row td,
+#event-grid-startdate-row th,
+#event-grid-enddate-row td,
+#event-grid-enddate-row th {
+ padding: 0;
+}
+
+.datepicker-menulist {
+ margin-left: 0 !important;
+}
+
+#event-grid-tab-vbox {
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 10px;
+ padding-inline: 8px 10px;
+}
+
+.separator td {
+ border-bottom: 1px solid var(--field-border-color);
+ padding: 0 !important;
+}
+
+#completed-date-picker {
+ margin-inline-start: 4px;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog tabbox section
+ *-------------------------------------------------------------------*/
+
+#event-grid-tabbox {
+ margin: 5px 0;
+}
+
+#event-grid-tabbox #notify-options {
+ padding: 0px 9px;
+}
+
+#FormatToolbox {
+ appearance: none;
+ padding-inline: 4px;
+}
+
+#FormatToolbar {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#boldButton {
+ list-style-image: url("chrome://messenger/skin/icons/bold.svg");
+}
+
+#italicButton {
+ list-style-image: url("chrome://messenger/skin/icons/italics.svg");
+}
+
+#underlineButton {
+ list-style-image: url("chrome://messenger/skin/icons/underline.svg");
+}
+
+#linkButton {
+ list-style-image: url("chrome://global/skin/icons/link.svg");
+}
+
+#ulButton {
+ list-style-image: url("chrome://messenger/skin/icons/bullet-list.svg");
+}
+
+#olButton {
+ list-style-image: url("chrome://messenger/skin/icons/number-list.svg");
+}
+
+#outdentButton {
+ list-style-image: url("chrome://messenger/skin/icons/outdent.svg");
+}
+
+#indentButton {
+ list-style-image: url("chrome://messenger/skin/icons/indent.svg");
+}
+
+#AlignPopupButton {
+ list-style-image: url("chrome://messenger/skin/icons/left-align.svg");
+}
+
+#AlignPopup > menuitem {
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+#AlignLeftItem, #AlignPopupButton[state="left"] {
+ list-style-image: url("chrome://messenger/skin/icons/left-align.svg");
+}
+
+#AlignCenterItem, #AlignPopupButton[state="center"] {
+ list-style-image: url("chrome://messenger/skin/icons/center-align.svg");
+}
+
+#AlignRightItem, #AlignPopupButton[state="right"] {
+ list-style-image: url("chrome://messenger/skin/icons/right-align.svg");
+}
+
+#AlignJustifyItem, #AlignPopupButton[state="justify"] {
+ list-style-image: url("chrome://messenger/skin/icons/justify.svg");
+}
+
+#paragraphButton {
+ list-style-image: url("chrome://messenger/skin/icons/paragraph.svg");
+}
+
+#smileButtonMenu {
+ list-style-image: url("chrome://messenger/skin/icons/smiley.svg");
+}
+
+.formatting-button {
+ appearance: none;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ color: inherit;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ transition-property: background-color, border-color, box-shadow;
+ transition-duration: 150ms;
+}
+
+.formatting-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+}
+
+.formatting-button:not([disabled="true"]):is([open="true"],[checked="true"],:hover:active) {
+ background: var(--toolbarbutton-checked-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+}
+
+.formatting-button:not([disabled="true"]):is([open="true"],:hover:active) {
+ background: var(--toolbarbutton-active-background) !important;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ list-style-image: url("chrome://messenger/skin/messengercompose/format-dropmarker.svg");
+ -moz-context-properties: fill;
+ fill: currentColor;
+ display: inline-block;
+}
+
+.formatting-button[disabled="true"] > .toolbarbutton-icon,
+.formatting-button[disabled="true"] > .toolbarbutton-menu-dropmarker {
+ opacity: 0.4;
+}
+
+#item-description {
+ border: 1px solid var(--field-border-color);
+ border-radius: 2px;
+ margin: 2px 4px;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog keep duration button
+ *-------------------------------------------------------------------*/
+
+#keepduration-button {
+ appearance: none;
+ list-style-image: url(chrome://calendar/skin/shared/chain-unlock.svg);
+ margin-bottom: -15px;
+ border-radius: var(--button-border-radius);
+ position: relative;
+ -moz-user-focus: normal;
+ -moz-context-properties: fill;
+ fill: CurrentColor;
+}
+
+#keepduration-button:hover {
+ background-color: var(--button-hover-background-color);
+}
+
+#keepduration-button:hover:active {
+ background-color: var(--button-active-background-color);
+}
+
+#keepduration-button[keep="true"] {
+ list-style-image: url(chrome://calendar/skin/shared/chain-lock.svg);
+ fill: var(--selected-item-color);
+}
+
+#keepduration-button[disabled="true"] {
+ fill: GrayText;
+}
+
+#keepduration-button > label {
+ display: none;
+}
+
+.keepduration-link-image {
+ margin-inline-start: -1px;
+ -moz-context-properties: fill, stroke-opacity;
+ fill: CurrentColor;
+ stroke-opacity: 0;
+}
+
+#link-image-top {
+ margin-top: 0.6em;
+ margin-bottom: -0.6em;
+}
+
+#link-image-top[keep="true"] {
+ stroke-opacity: 1;
+}
+
+#link-image-bottom {
+ margin-top: -0.6em;
+ margin-bottom: 0.6em;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog statusbar images
+ *-------------------------------------------------------------------*/
+
+.cal-statusbar-1 {
+ flex-direction: column;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog statusbarpanels
+ *-------------------------------------------------------------------*/
+
+#status-privacy,
+#status-priority,
+#status-status,
+#status-freebusy {
+ overflow: hidden;
+}
+
+/*--------------------------------------------------------------------
+ * Recurrence dialog
+ *-------------------------------------------------------------------*/
+
+#calendar-event-dialog-recurrence dialog {
+ overflow: scroll;
+}
+
+.recurrence-pattern-hbox-label {
+ margin-top: 6px;
+}
+
+#recurrencePreviewCalendars {
+ display: block;
+ margin: 2px;
+}
+
+#recurrencePreview {
+ display: flex;
+}
+
+#recurrencePreview calendar-minimonth {
+ display: inline-block;
+ margin: 2px;
+}
+
+#recurrencePreview calendar-minimonth[hidden="true"] {
+ display: none;
+}
+
+#recurrencePreviewNavigation {
+ display: block;
+}
+
+#daypicker-weekday {
+ margin-top: 2px;
+ -moz-user-focus: normal;
+}
+
+.daypicker-monthday {
+ margin-top: 2px;
+ -moz-user-focus: normal;
+}
+
+.headline {
+ font-weight: bold;
+}
+
+.headline[align="end"],
+.headline[align="right"] {
+ text-align: right;
+}
+
+.default-spacer {
+ width: 1em;
+ height: 1em;
+}
+
+.default-indent {
+ margin-inline-start: 1.5em;
+}
+
+#dialog-box {
+ padding-block: 8px 10px;
+ padding-inline: 8px 10px;
+}
+
+.checkbox-no-label > .checkbox-label-box {
+ display: none;
+}
+
+/* Thunderbird Light Theme (not system theme) */
+@media (prefers-color-scheme: light) {
+ :root[lwt-tree]:not([lwt-tree-brighttext]) #recurrencePreview calendar-minimonth {
+ background-color: var(--color-gray-05);
+ border-color: var(--color-gray-30);
+ }
+}
+
+/*--------------------------------------------------------------------
+ * Event summary dialog
+ *-------------------------------------------------------------------*/
+#summary-toolbox {
+ margin-bottom: 5px;
+}
+
+#status-notifications > .notificationbox-stack {
+ background-color: transparent;
+ margin-inline: 6px;
+}
+
+#calendar-item-summary {
+ margin-block: 3px 10px;
+ margin-inline: 8px 10px;
+}
+
+#calendar-summary-dialog,
+#calendar-ics-file-dialog {
+ min-width: 35em;
+}
+
+#calendar-summary-dialog .item-location,
+#calendar-summary-dialog .item-title,
+#calendar-event-summary-dialog .item-location,
+#calendar-event-summary-dialog .item-title,
+#calendar-task-summary-dialog .item-location,
+#calendar-task-summary-dialog .item-title,
+#calendar-ics-file-dialog .item-location,
+#calendar-ics-file-dialog .item-title {
+ padding-inline-start: 1px;
+}
+
+#calendar-summary-dialog .item-attachment-cell,
+#calendar-event-summary-dialog .item-attachment-cell,
+#calendar-task-summary-dialog .item-attachment-cell,
+#calendar-ics-file-dialog .item-attachment-cell {
+ margin-left: 0px;
+}
+
+#calendar-summary-dialog .item-attachment-cell-label,
+#calendar-event-summary-dialog .item-attachment-cell-label,
+#calendar-task-summary-dialog .item-attachment-cell-label,
+#calendar-ics-file-dialog .item-attachment-cell-label {
+ margin-left: 0;
+}
+
+#calendar-summary-dialog .item-description,
+#calendar-event-summary-dialog .item-description,
+#calendar-task-summary-dialog .item-description,
+#calendar-ics-file-dialog .item-description {
+ border: 1px solid var(--field-border-color);
+ margin: 2px 4px 0;
+}
+
+#calendar-summary-dialog .item-description a,
+#calendar-event-summary-dialog .item-description a,
+#calendar-task-summary-dialog .item-description a,
+#calendar-ics-file-dialog .item-description a {
+ color: -moz-nativehyperlinktext;
+}
+
+:root[lwt-tree-brighttext] #calendar-summary-dialog .item-description a,
+:root[lwt-tree-brighttext] #calendar-event-summary-dialog .item-description a,
+:root[lwt-tree-brighttext] #calendar-task-summary-dialog .item-description a,
+:root[lwt-tree-brighttext] #calendar-ics-file-dialog .item-description a {
+ color: #0aa5ff;
+}
+
+#calendar-summary-dialog #item-start-row .headline,
+#calendar-event-summary-dialog #item-start-row .headline,
+#calendar-task-summary-dialog #item-start-row .headline,
+#calendar-ics-file-dialog #item-start-row .headline,
+#calendar-summary-dialog #item-end-row .headline,
+#calendar-event-summary-dialog #item-end-row .headline,
+#calendar-task-summary-dialog #item-end-row .headline,
+#calendar-ics-file-dialog #item-end-row .headline {
+ font-weight: normal;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-ics-file-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-ics-file-dialog.css
new file mode 100644
index 0000000000..4675e09351
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-ics-file-dialog.css
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* We want the calendar items container to span the full width of the dialog
+ window, so there's no space between its scrollbar and the right side of the
+ window. Set the dialog element's padding to 0 and then set the equivalent
+ padding on its children. The dialog buttons area is in the shadow dom, so we
+ add padding to it via JS in the window load handler function. */
+#calendar-ics-file-dialog {
+ padding-inline: 0;
+}
+
+#calendar-ics-file-dialog-header,
+#calendar-ics-file-dialog-items-container,
+#calendar-ics-file-dialog-progress-pane,
+#calendar-ics-file-dialog-result-pane {
+ /* This padding needs to change elsewhere if it changes here.
+ See the note above the styles for #calendar-ics-file-dialog */
+ padding-inline: 10px;
+}
+
+#calendar-ics-file-dialog-items-container,
+#calendar-ics-file-dialog-progress-pane,
+#calendar-ics-file-dialog-result-pane {
+ flex: 1 1 0;
+}
+
+#calendar-ics-file-dialog-calendar-menu-label {
+ margin-top: 1em;
+}
+
+#calendar-ics-file-dialog-calendar-menu > menupopup > menuitem .menu-iconic-left {
+ display: flex;
+}
+
+#calendar-ics-file-dialog-calendar-menu::part(icon) {
+ margin-inline-start: 4px;
+ margin-inline-end: 3px;
+}
+
+#calendar-ics-file-dialog-calendar-menu::part(icon),
+#calendar-ics-file-dialog-calendar-menu > menupopup > menuitem .menu-iconic-icon {
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+ background-color: var(--item-color);
+}
+
+#calendar-ics-file-dialog-sort-button {
+ list-style-image: var(--icon-sort);
+ min-width: 0;
+ margin-block: 2px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+#calendar-ics-file-dialog-sort-button > .button-box > dropmarker {
+ display: none;
+}
+
+#calendar-ics-file-dialog-items-container {
+ border-block: 1px solid var(--field-border-color);
+ margin-block: 0.7em;
+ margin-inline: -8px -10px;
+ padding-block: 0 1.5em;
+ padding-inline: 18px 20px;
+ min-height: 200px;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+#calendar-ics-file-dialog-items-loading-message {
+ text-align: center;
+ margin-top: 3em;
+}
+
+.calendar-ics-file-dialog-item-frame {
+ background-color: var(--field-background-color);
+ border: 1px solid var(--field-border-color);
+ margin-block: 1.5em 0;
+ margin-inline: 6px 5px;
+ padding: 0.7em;
+}
+
+.calendar-ics-file-dialog-item-import-button {
+ margin-block: 0.7em 0;
+}
+
+.calendar-caption {
+ display: none;
+}
+
+.item-description {
+ padding: 2px;
+}
+
+#calendar-ics-file-dialog-progress-pane,
+#calendar-ics-file-dialog-result-pane {
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+#calendar-ics-file-dialog-progress {
+ width: 400px;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css
new file mode 100644
index 0000000000..e43e08cb4e
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#dialog-box {
+ overflow: auto;
+ height: 290px;
+}
+
+.calendar-invitations-updating-icon {
+ opacity: 0.5;
+}
+
+#updating-box {
+ background-color: var(--field-background-color);
+ color: var(--field-text-color);
+ border: 1px solid var(--field-border-color);
+}
+
+#invitationContainer {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+richlistitem[is="calendar-invitations-richlistitem"] {
+ padding: 6px 7px;
+ min-height: 25px;
+ border-bottom: 1px dotted #808080;
+}
+
+.calendar-invitations-richlistitem-title {
+ font-weight: bold;
+ white-space: break-spaces;
+}
+
+.calendar-invitations-richlistitem-icon {
+ /* Crops the src of the image. */
+ object-fit: none;
+ width: 32px;
+ height: 32px;
+}
+
+.calendar-invitations-richlistitem-icon[status="NEEDS-ACTION"] {
+ object-position: top 0 left 0;
+}
+
+.calendar-invitations-richlistitem-icon[status="ACCEPTED"] {
+ object-position: top 0 left -32px;
+}
+
+.calendar-invitations-richlistitem-icon[status="DECLINED"] {
+ object-position: top 0 left -64px;
+}
+
+.calendar-invitations-richlistitem-button {
+ margin-bottom: 10px;
+ visibility: hidden;
+}
+
+richlistitem[is="calendar-invitations-richlistitem"][selected="true"] .calendar-invitations-richlistitem-button {
+ visibility: visible;
+}
+
+.calendar-invitations-richlistitem-button .button-icon {
+ margin-block: 0;
+ margin-inline: 0 5px;
+ border-radius: 3px;
+ -moz-context-properties: fill, fill-opacity;
+ fill: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.calendar-invitations-richlistitem-accept-button {
+ list-style-image: url(chrome://calendar/skin/shared/icons/complete.svg);
+}
+
+.calendar-invitations-richlistitem-decline-button {
+ list-style-image: url(chrome://calendar/skin/shared/icons/decline.svg);
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-itip-identity-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-itip-identity-dialog.css
new file mode 100644
index 0000000000..3721503818
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-itip-identity-dialog.css
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendar-itip-identity-warning {
+ margin-bottom: 1em;
+}
+
+#identity-menu-label {
+ height: 1.3em;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-properties-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-properties-dialog.css
new file mode 100644
index 0000000000..2da1a5c49a
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-properties-dialog.css
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ color: var(--lwt-text-color);
+}
+
+:root[canDisable] #calendar-properties-table {
+ margin-inline-start: 20px;
+}
+
+body {
+ overflow: auto;
+}
+
+#calendar-properties-table th,
+#calendar-properties-table td {
+ min-height: 26px;
+}
+
+#calendar-properties-table th {
+ text-align: start;
+ font-weight: normal;
+}
+
+#calendar-properties-table td {
+ width: 100%;
+}
+
+#calendar-properties-table html|input[type="number"].size3 {
+ width: calc(3ch + 55px);
+}
+
+#calendar-email-identity-row > td,
+#calendar-refreshInterval-row > td {
+ display: flex;
+}
+
+#calendar-refreshInterval-menulist,
+#email-identity-menulist {
+ flex: 1;
+}
+
+#calendar-notifications {
+ margin-inline-start: 24px;
+}
+
+#calendar-notifications-title {
+ font-size: 1.1em;
+ font-weight: 600;
+}
+
+calendar-notifications-setting {
+ display: block;
+}
+
+calendar-notifications-setting .add-button {
+ list-style-image: var(--icon-add);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+calendar-notifications-setting .add-button .button-icon {
+ margin-inline-end: 6px;
+}
+
+calendar-notifications-setting .remove-button {
+ width: 26px;
+ min-width: 0;
+ padding: 0 4px;
+ list-style-image: var(--icon-trash);
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%,transparent);
+ stroke: currentColor;
+ background: none;
+ border: none;
+}
+
+calendar-notifications-setting .calendar-notifications-row:last-child {
+ margin-bottom: 20px;
+}
+
+#global-notifications-row {
+ width: 100%;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-summary-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-summary-dialog.css
new file mode 100644
index 0000000000..6ba129a24a
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-summary-dialog.css
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+notification {
+ border-inline-width: 1px;
+}
+
+:root:not([lwt-tree]):-moz-lwtheme #summary-toolbox {
+ background-image: var(--lwt-header-image) !important;
+ background-repeat: no-repeat;
+ background-position: right top !important;
+}
+
+:root:not([lwt-tree]):-moz-lwtheme #summary-toolbar {
+ background-color: var(--toolbar-bgcolor);
+}
+
+.item-attendees-description {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.item-attendees,
+.item-attendees-list-container {
+ height: 90px;
+ min-height: 50px;
+}
+
+.item-description-box {
+ height: 100%;
+}
+
+#calendar-summary-dialog {
+ min-height: 20em;
+}
+
+#calendar-event-summary-dialog {
+ height: 100vh;
+ overflow-y: scroll;
+}
+
+#calendar-summary-dialog-custom-button-footer {
+ margin-block: 10px;
+ margin-inline: 8px 10px;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css b/comm/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css
new file mode 100644
index 0000000000..5a0932d2fb
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css
@@ -0,0 +1,142 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#timezone-stack {
+ margin: 4px 4px 0;
+}
+
+.timezone-highlight {
+ object-fit: none;
+ width: 460px;
+ height: 287px;
+}
+.timezone-highlight[tzid="none"] {
+ display: none;
+}
+.timezone-highlight[tzid="+0000"] {
+ object-position: top 0 left -6900px;
+}
+.timezone-highlight[tzid="+0100"] {
+ object-position: top 0 left -7360px;
+}
+.timezone-highlight[tzid="+0200"] {
+ object-position: top 0 left -7820px;
+}
+.timezone-highlight[tzid="+0300"] {
+ object-position: top 0 left -8280px;
+}
+.timezone-highlight[tzid="+0330"] {
+ object-position: top 0 left -8740px;
+}
+.timezone-highlight[tzid="+0400"] {
+ object-position: top 0 left -9200px;
+}
+.timezone-highlight[tzid="+0430"] {
+ object-position: top 0 left -9660px;
+}
+.timezone-highlight[tzid="+0500"] {
+ object-position: top 0 left -10120px;
+}
+.timezone-highlight[tzid="+0530"] {
+ object-position: top 0 left -10580px;
+}
+.timezone-highlight[tzid="+0545"] {
+ object-position: top 0 left -11040px;
+}
+.timezone-highlight[tzid="+0600"] {
+ object-position: top 0 left -11500px;
+}
+.timezone-highlight[tzid="+0630"] {
+ object-position: top 0 left -11960px;
+}
+.timezone-highlight[tzid="+0700"] {
+ object-position: top 0 left -12420px;
+}
+.timezone-highlight[tzid="+0800"] {
+ object-position: top 0 left -12880px;
+}
+.timezone-highlight[tzid="+0845"] {
+ display: none;
+}
+.timezone-highlight[tzid="+0900"] {
+ object-position: top 0 left -13340px;
+}
+.timezone-highlight[tzid="+0930"] {
+ object-position: top 0 left -13800px;
+}
+.timezone-highlight[tzid="+1000"] {
+ object-position: top 0 left -14260px;
+}
+.timezone-highlight[tzid="+1030"] {
+ object-position: top 0 left -14720px;
+}
+.timezone-highlight[tzid="+1100"] {
+ object-position: top 0 left -15180px;
+}
+.timezone-highlight[tzid="+1130"] {
+ object-position: top 0 left -15180px;
+}
+.timezone-highlight[tzid="+1200"] {
+ object-position: top 0 left -16100px;
+}
+.timezone-highlight[tzid="+1245"] {
+ object-position: top 0 left -16560px;
+}
+.timezone-highlight[tzid="+1300"] {
+ object-position: top 0 left -17020px;
+}
+.timezone-highlight[tzid="+1400"] {
+ object-position: top 0 left -17480px;
+}
+.timezone-highlight[tzid="-0100"] {
+ object-position: top 0 left -6440px;
+}
+.timezone-highlight[tzid="-0200"] {
+ object-position: top 0 left -5980px;
+}
+.timezone-highlight[tzid="-0300"] {
+ object-position: top 0 left -5520px;
+}
+.timezone-highlight[tzid="-0330"] {
+ object-position: top 0 left -5060px;
+}
+.timezone-highlight[tzid="-0400"] {
+ object-position: top 0 left -4600px;
+}
+.timezone-highlight[tzid="-0430"] {
+ display: none;
+}
+.timezone-highlight[tzid="-0500"] {
+ object-position: top 0 left -4140px;
+}
+.timezone-highlight[tzid="-0600"] {
+ object-position: top 0 left -3680px;
+}
+.timezone-highlight[tzid="-0700"] {
+ object-position: top 0 left -3220px;
+}
+.timezone-highlight[tzid="-0800"] {
+ object-position: top 0 left -17940px;
+}
+.timezone-highlight[tzid="-0830"] {
+ object-position: top 0 left -2760px;
+}
+.timezone-highlight[tzid="-0900"] {
+ object-position: top 0 left -2300px;
+}
+.timezone-highlight[tzid="-0930"] {
+ object-position: top 0 left -1840px;
+}
+.timezone-highlight[tzid="-1000"] {
+ object-position: top 0 left -1380px;
+}
+.timezone-highlight[tzid="-1100"] {
+ object-position: top 0 left -920px;
+}
+.timezone-highlight[tzid="-1245"] {
+ object-position: top 0 left -460px;
+}
+.timezone-highlight[tzid="-1200"] {
+ object-position: top 0 left 0;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/calendar-uri-redirect-dialog.css b/comm/calendar/base/themes/common/dialogs/calendar-uri-redirect-dialog.css
new file mode 100644
index 0000000000..8d1f0a6708
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/calendar-uri-redirect-dialog.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#calendar-uri-redirect-dialog {
+ min-width: 600px;
+ min-height: 220px;
+ height: 100vh;
+}
+
+p {
+ padding-block: 5px;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/chooseCalendarDialog.css b/comm/calendar/base/themes/common/dialogs/chooseCalendarDialog.css
new file mode 100644
index 0000000000..a675464983
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/chooseCalendarDialog.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ min-width: 300px;
+ min-height: 200px;
+}
+
+richlistitem {
+ padding: 4px 8px;
+ align-items: center;
+}
+
+richlistitem > box:first-child {
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+}
diff --git a/comm/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png b/comm/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png
new file mode 100644
index 0000000000..ea52cbc19d
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png b/comm/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png
new file mode 100644
index 0000000000..db33ad817c
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/dialogs/images/chain-lock.svg b/comm/calendar/base/themes/common/dialogs/images/chain-lock.svg
new file mode 100644
index 0000000000..ed42599db3
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/chain-lock.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="8" height="24" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 8 24">
+ <path d="M 3,2 C 1.35,2 0,3.34 0,5 v 3 c 0,1.907 0.76,3 2,3 V 5 C 2,4.45 2.45,4 3,4 h 2 c 0.55,0 1.05,0.45 1,1 v 6 C 7.24,11 8,9.986 8,8 V 5 C 8,3.35 6.66,2 5,2 Z M 4,8 C 3.04,8 3,9 3,9 v 6 c 0,0 0,1 1,1 1,0 1,-1 1,-1 V 9 C 5,9 4.956,8 4,8 Z m -2,5 c -1.2397,0 -2,0.93 -2,3 v 3 c 0,1.66 1.35,3 3,3 h 2 c 1.66,0 3,-1.35 3,-3 V 16 C 8,13.99 7.24,13 6,13 v 6 c 0.05,0.55 -0.45,1 -1,1 H 3 C 2.45,20 2,19.55 2,19 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/chain-unlock.svg b/comm/calendar/base/themes/common/dialogs/images/chain-unlock.svg
new file mode 100644
index 0000000000..7a7c0fa9c1
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/chain-unlock.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="8" height="24" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 8 24">
+ <path d="M 3,0 C 1.35,0 0,1.34 0,3 V 6 C 0,7.91 0.76,9 2,9 V 3 C 2,2.45 2.45,2 3,2 H 5 C 5.55,2 6.05,2.45 6,3 V 9 C 7.24,9 8,7.99 8,6 V 3 C 8,1.35 6.66,0 5,0 Z m 0,14 v 3 c 0,0 0,1 1,1 1,0 1,-1 1,-1 V 14 Z M 5,10 V 7 C 5,7 4.96,6 4,6 3.04,6 3,7 3,7 v 3 z m -3,5 c -1.2397,0 -2,0.93 -2,3 v 3 c 0,1.66 1.35,3 3,3 h 2 c 1.66,0 3,-1.35 3,-3 V 18 C 8,15.99 7.24,15 6,15 v 6 c 0.05,0.55 -0.45,1 -1,1 H 3 C 2.45,22 2,21.55 2,21 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/link-image-bottom.svg b/comm/calendar/base/themes/common/dialogs/images/link-image-bottom.svg
new file mode 100644
index 0000000000..99925c77f0
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/link-image-bottom.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="9" height="7">
+ <path fill="context-fill" d="M 2,7 0,5 V 4 L 2,2 H 3 V 4 H 8 V 0 H 9 V 5 H 3 v 2 z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/link-image-top.svg b/comm/calendar/base/themes/common/dialogs/images/link-image-top.svg
new file mode 100644
index 0000000000..60b4574146
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/link-image-top.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="9" height="7">
+ <path fill="context-fill" d="M 9,7 V 2 H 0 v 1 h 8 v 4 z"/>
+ <path fill="context-fill" fill-opacity="context-stroke-opacity" d="M 0,2 2,0 H 3 V 5 H 2 L 0,3 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-high.svg b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-high.svg
new file mode 100644
index 0000000000..36dab0b7bc
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-high.svg
@@ -0,0 +1,8 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="48" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 48 16">
+ <rect width="15" height="15" x="32" y="0"/>
+ <rect width="11" height="11" x="18" y="4"/>
+ <rect width="8" height="8" x="7" y="7"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-low.svg b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-low.svg
new file mode 100644
index 0000000000..d676ea5dad
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-low.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 16 16">
+ <rect width="8" height="8" x="7" y="7"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-normal.svg b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-normal.svg
new file mode 100644
index 0000000000..9ddc40cbcd
--- /dev/null
+++ b/comm/calendar/base/themes/common/dialogs/images/statusbar-priority-normal.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="32" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 32 16">
+ <rect width="11" height="11" x="18" y="4"/>
+ <rect width="8" height="8" x="7" y="7"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/alarm-no.svg b/comm/calendar/base/themes/common/icons/alarm-no.svg
new file mode 100644
index 0000000000..82245b1a58
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/alarm-no.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 0C5.135 0 3.072 2.45 3 5.359c-.019 2.422-.129 4.453-1.752 6.101-.526.81-.184 1.52.756 1.54L1 14l1 1L15 2l-1-1-1.61 1.607C11.94 1.15 10.12 0 8 0zm0 2c1.503.02 2.49.853 2.85 2.154L4 11c1.011-1.025.955-3.748 1-5.596C5.05 3.436 6.064 2.046 8 2zm5 4l-2 2c0 .8.44 2.42 1 3H8l-2 2h7.97c.97 0 1.31-.71.77-1.52C13.49 10.23 13 7.719 13 6zm-7 8c0 .98 1.048 2 2.014 2C8.984 16 10 14.98 10 14z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/alarm.svg b/comm/calendar/base/themes/common/icons/alarm.svg
new file mode 100644
index 0000000000..12eed1320c
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/alarm.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6 14c0 .98 1.047 2 2.013 2C8.983 16 10 14.98 10 14zm8.74-2.52C13.07 9.673 13 7.644 13 5.291 13 2.094 10.89 0 8 0 5.135 0 3.072 2.451 3 5.36c-.019 2.422-.128 4.451-1.751 6.1-.533.82-.178 1.54.791 1.54h11.93c.97 0 1.31-.71.77-1.52zM4 11c1.011-1.025.955-3.748 1-5.596C5.05 3.436 6.064 2.046 8 2c1.907.02 3 1.351 3 3.291-.1 2.715.2 4.889 1 5.709z"/>
+</svg> \ No newline at end of file
diff --git a/comm/calendar/base/themes/common/icons/complete.svg b/comm/calendar/base/themes/common/icons/complete.svg
new file mode 100644
index 0000000000..8f01223a8b
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/complete.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M6 14a1 1 0 0 1-.707-.293l-3-3a1 1 0 0 1 1.414-1.414l2.157 2.157 6.316-9.023a1 1 0 0 1 1.639 1.146l-7 10a1 1 0 0 1-.732.427A.863.863 0 0 1 6 14z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/confidential.svg b/comm/calendar/base/themes/common/icons/confidential.svg
new file mode 100644
index 0000000000..22317ad501
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/confidential.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M8 1a7 7 0 1 0 7 7 7 7 0 0 0-7-7zm0 13a6 6 0 1 1 6-6 6 6 0 0 1-6 6zm0-7a1 1 0 0 0-1 1v3a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1zm0-3.2A1.2 1.2 0 1 0 9.2 5 1.2 1.2 0 0 0 8 3.812z"></path>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/decline.svg b/comm/calendar/base/themes/common/icons/decline.svg
new file mode 100644
index 0000000000..027afeb111
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/decline.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M9.41 8l4.3-4.3c.9-.94-.47-2.32-1.42-1.4L8 6.58l-4.3-4.3c-.93-.9-2.32.47-1.4 1.42L6.58 8l-4.3 4.3c-.97.94.47 2.38 1.42 1.4L8 9.42l4.3 4.3c.94.9 2.32-.47 1.4-1.42z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/edit.svg b/comm/calendar/base/themes/common/icons/edit.svg
new file mode 100644
index 0000000000..5be5ab638e
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/edit.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M14.35 2.35l-.7-.7a2 2 0 00-2.83 0l-.38.38a.5.5 0 000 .7l2.83 2.83a.5.5 0 00.7 0l.38-.38a2 2 0 000-2.83zM9.73 3.44a.5.5 0 00-.7 0L3.24 9.22a1.99 1.99 0 00-.46.71l-1.75 4.39a.5.5 0 00.46.68.5.5 0 00.19-.04l4.38-1.75a1.97 1.97 0 00.72-.45l5.77-5.78a.5.5 0 000-.7zM5.16 12.5l-2.55 1.02a.1.1 0 01-.13-.13l1.02-2.56a.1.1 0 01.16-.03l1.54 1.53a.1.1 0 01-.04.17z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/email.svg b/comm/calendar/base/themes/common/icons/email.svg
new file mode 100644
index 0000000000..5c56028d6c
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/email.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M13 2H3a3.013 3.013 0 0 0-3 3v6a3.013 3.013 0 0 0 3 3h10a3.013 3.013 0 0 0 3-3V5a3.013 3.013 0 0 0-3-3zm1 9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z"/>
+ <path d="M8 9a.5.5 0 0 1-.294-.1l-5.5-4a.5.5 0 1 1 .588-.8L8 7.88 13.2 4.1a.5.5 0 0 1 .588.81-5.5 4A.5.5 0 0 1 8 9z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/event.svg b/comm/calendar/base/themes/common/icons/event.svg
new file mode 100644
index 0000000000..0eba7b20be
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/event.svg
@@ -0,0 +1,10 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6 2h4v2H6zM3 15c-.199.05-2-.59-2-3V5c0-1.305.835-2.417 2-2.83m12 8.33V12c0 1.81-1.32 3-3 3h-1.5z"/>
+ <path d="M3 5v1h7.5l1-1zm1 2v3h2V7zm6.99.89l2.08 2.16-5.51 5.51c-.12.12-.307.27-.56.44H5v-2c.172-.25.319-.44.44-.56zm1.51-1.51l.94-.94c1.41-1.366 3.49.71 2.12 2.12l-.94.94z"/>
+ <path d="M10.99 7.89l2.08 2.16-5.51 5.51c-.12.12-.307.27-.56.44H5v-2c.172-.25.319-.44.44-.56zm1.51-1.51l.94-.94c1.41-1.366 3.49.71 2.12 2.12l-.94.94z"/>
+ <rect x="4" width="1" height="4" rx=".5"/>
+ <rect x="11" width="1" height="4" rx=".5"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/find.svg b/comm/calendar/base/themes/common/icons/find.svg
new file mode 100644
index 0000000000..5c239013cc
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/find.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M15.707 14.293l-4.822-4.822a6.019 6.019 0 1 0-1.414 1.414l4.822 4.822a1 1 0 0 0 1.414-1.414zM6 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/freebusy.svg b/comm/calendar/base/themes/common/icons/freebusy.svg
new file mode 100644
index 0000000000..928f9d9167
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/freebusy.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M8 0a8 8 0 1 0 8 8 8 8 0 0 0-8-8zm0 14a6 6 0 1 1 6-6 6 6 0 0 1-6 6zm3.5-6H8V4.5a.5.5 0 0 0-1 0v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 0-1z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/icon32.svg b/comm/calendar/base/themes/common/icons/icon32.svg
new file mode 100644
index 0000000000..9f29f18fc5
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/icon32.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32">
+ <path d="M3 1c-1.668.06-3 1.35-3 3v24c0 1.66 1.343 3 3 3h25c1.66 0 3-1.34 3-3V4c0-1.65-1.33-3.056-3-3zm1 3h23c.55 0 1 .45 1 1v1H3V5c0-.55.451-1 1-1zM3 9h25v18c0 .55-.45 1-1 1H4c-.552 0-1.029-.45-1-1zm7 1v5h5v-5zm6 0v5h5v-5zm6 0v5h5v-5zM4 16v5h5v-5zm6 0v5h5v-5zm6 0v5h5v-5zm6 0v5h5v-5zM4 22v5h5v-5zm6 0v5h5v-5zm6 0v5h5v-5z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/imip-bar.svg b/comm/calendar/base/themes/common/icons/imip-bar.svg
new file mode 100644
index 0000000000..c5c37def72
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/imip-bar.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 24 24">
+ <path d="M4 1c-1.668.056-3 1.354-3 3v16c0 1.66 1.343 3 3 3h17c1.66 0 3-1.34 3-3V4c0-1.646-1.33-3.056-3-3H4zm0 2h17c.55 0 1 .448 1 1H3c0-.546.451-1 1-1zM3 6h19v14c0 .55-.45 1-1 1H4c-.552 0-1-.45-1-1V6zm6 2v3h3V8H9zm4 0v3h3V8h-3zm4 0v3h3V8h-3zM5 12v3h3v-3H5zm4 0v3h3v-3H9zm4 0v3h3v-3h-3zm4 0v3h3v-3h-3zM5 16v3h3v-3H5zm4 0v3h3v-3H9zm4 0v3h3v-3h-3z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/locked.svg b/comm/calendar/base/themes/common/icons/locked.svg
new file mode 100644
index 0000000000..c237394a8c
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/locked.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M8 1a4 4 0 00-4 4v2H3a1 1 0 00-1 1v6a1 1 0 001 1h10a1 1 0 001-1V8a1 1 0 00-1-1h-1V5a4 4 0 00-4-4zm0 2a2 2 0 012 2v2H6V5c0-1.1.9-2 2-2z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/low-priority.svg b/comm/calendar/base/themes/common/icons/low-priority.svg
new file mode 100644
index 0000000000..849282a79a
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/low-priority.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 2a1 1 0 00-1 1v8.1L5.86 9.97c-.94-.8-2.21.47-1.41 1.41l2.82 2.82h.01c.4.39 1.1.38 1.48 0l2.78-2.79a.98.98 0 000-1.41 1 1 0 00-1.41 0L9 11.07V3a1 1 0 00-1-1z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/newevent.svg b/comm/calendar/base/themes/common/icons/newevent.svg
new file mode 100644
index 0000000000..87a1e56434
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/newevent.svg
@@ -0,0 +1,10 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15 8.758a4.474 4.474 0 0 0-2-.73V2.17c1.165.412 2 1.523 2 2.829v3.758zM8.758 15H4a3 3 0 0 1-3-3V5c0-1.306.835-2.417 2-2.83V12a1 1 0 0 0 1 1h4.027c.082.734.34 1.416.73 2zM6 2h4v2H6V2z"/>
+ <rect x="4" width="1" height="4" rx=".5"/>
+ <rect x="11" width="1" height="4" rx=".5"/>
+ <path d="M4 7h3v3H4zM3 5h10v1H3z"/>
+ <path d="M12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm.5-4v-1.5a.5.5 0 1 0-1 0V12h-1.5a.5.5 0 1 0 0 1H12v1.5a.5.5 0 1 0 1 0V13h1.5a.5.5 0 1 0 0-1H13z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/newtask.svg b/comm/calendar/base/themes/common/icons/newtask.svg
new file mode 100644
index 0000000000..7baa6aa6ff
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/newtask.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15 8.758a4.474 4.474 0 0 0-2-.73V2H2v11h6.027c.082.734.34 1.416.73 2H2c-1.105 0-2-.84-2-1.875V1.875C0 .839.895 0 2 0h11c1.105 0 2 .84 2 1.875v6.883zM8.758 10c-.196.293-.36.61-.483.947A.5.5 0 0 1 8.5 10h.258zM8.5 4h3a.5.5 0 1 1 0 1h-3a.5.5 0 0 1 0-1zm0 3h3a.5.5 0 1 1 0 1h-3a.5.5 0 0 1 0-1zM6.146 3.146a.5.5 0 1 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-1-1a.5.5 0 1 1 .708-.708l.646.647 1.646-1.647zm0 3a.5.5 0 1 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-1-1a.5.5 0 1 1 .708-.708l.646.647 1.646-1.647zM12.5 16a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7zm.5-4v-1.5a.5.5 0 1 0-1 0V12h-1.5a.5.5 0 1 0 0 1H12v1.5a.5.5 0 1 0 1 0V13h1.5a.5.5 0 1 0 0-1H13z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/pane.svg b/comm/calendar/base/themes/common/icons/pane.svg
new file mode 100644
index 0000000000..1e281bf57e
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/pane.svg
@@ -0,0 +1,8 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6 2h4v2H6zm7 0c1.65 0 3 1.5 3 3v8c0 1.65-1.34 3-3 3H3c-1.65 0-3-1.34-3-3V5c0-1.65 1.35-3 3-3v2c-.55 0-1 .55-1 1v8c0 .55.45 1 1 1h10c.55 0 1.05-.45 1-1V5c0-.55-.45-1-1-1z"/>
+ <rect x="4" width="1" height="4" rx=".5"/>
+ <rect x="11" width="1" height="4" rx=".5"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/priority.svg b/comm/calendar/base/themes/common/icons/priority.svg
new file mode 100644
index 0000000000..22d6809bc6
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/priority.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M8 2a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1zm0 11.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/private.svg b/comm/calendar/base/themes/common/icons/private.svg
new file mode 100644
index 0000000000..6647f48120
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/private.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M1.433 3.2l2.434 2.436a1.243 1.243 0 1 0 1.757-1.757L3.188 1.445A3.956 3.956 0 0 1 4.988 1a3.976 3.976 0 0 1 3.434 6l6.268 6.27a1 1 0 1 1-1.41 1.42L7 8.434A3.96 3.96 0 0 1 4.99 9a4 4 0 0 1-4-4 3.95 3.95 0 0 1 .445-1.8z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/recurrence-exception.svg b/comm/calendar/base/themes/common/icons/recurrence-exception.svg
new file mode 100644
index 0000000000..e374baaf9e
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/recurrence-exception.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg fill="context-fill" fill-opacity="context-fill-opacity" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <path d="m13 0-1 1H2.5A2.5 2.5 0 0 0 0 3.5v7c0 .7.28 1.32.73 1.77L0 13l1 1L14 1l-1-1zm2.7 2.3L14 4v6.5a.5.5 0 0 1-.5.5h-3.3l.8-1c.91-.93-.47-2.32-1.41-1.4l-2.3 2.7a1 1 0 0 0 0 1.41l2.3 2.71c.94.91 2.32-.47 1.41-1.41l-.8-1h3.3a2.5 2.5 0 0 0 2.5-2.5v-7c0-.43-.1-.85-.3-1.21zM2.5 3H10l-7.86 7.86A.5.5 0 0 1 2 10.5v-7c0-.28.23-.5.5-.5z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/recurrence.svg b/comm/calendar/base/themes/common/icons/recurrence.svg
new file mode 100644
index 0000000000..dffdfec186
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/recurrence.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg fill="context-fill" fill-opacity="context-fill-opacity" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+ <path d="M2.5 1A2.5 2.5 0 0 0 0 3.5v7A2.5 2.5 0 0 0 2.5 13H4c1.33 0 1.33-2 0-2H2.5a.5.5 0 0 1-.5-.5v-7c0-.28.23-.5.5-.5h11c.28 0 .5.22.5.5v7a.5.5 0 0 1-.5.5h-3.3l.8-1c.91-.94-.47-2.32-1.41-1.41l-2.3 2.7a1 1 0 0 0 0 1.42l2.3 2.7c.94.91 2.32-.47 1.41-1.41l-.8-1h3.3a2.5 2.5 0 0 0 2.5-2.5v-7C16 2.12 14.87.87 13.5 1h-11z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/save-close.svg b/comm/calendar/base/themes/common/icons/save-close.svg
new file mode 100644
index 0000000000..1b51faf67a
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/save-close.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M15 8.758a4.474 4.474 0 0 0-2-.73v-2.2L10.172 3H3v3h5a1 1 0 0 0 1-1V3H3v10h5.027c.082.734.34 1.416.73 2H3a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h7.172a2 2 0 0 1 1.414.586l2.828 2.828A2 2 0 0 1 15 5.828v2.93zm-5.287.209A4.494 4.494 0 0 0 8.027 12H8a2 2 0 1 1 1.713-3.033zM9 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0zm5.116-1.32l-2.212 2.655-1.127-.751a.5.5 0 1 0-.554.832l1.5 1a.5.5 0 0 0 .661-.096l2.5-3a.5.5 0 1 0-.768-.64z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/sort.svg b/comm/calendar/base/themes/common/icons/sort.svg
new file mode 100644
index 0000000000..a3d32a20bf
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/sort.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M11 7H5a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2zm-3 4H5a1 1 0 0 0 0 2h3a1 1 0 0 0 0-2zm6-8H5a1 1 0 0 0 0 2h9a1 1 0 0 0 0-2zM2 3a1 1 0 1 0 1 1 1 1 0 0 0-1-1zm0 4a1 1 0 1 0 1 1 1 1 0 0 0-1-1zm0 4a1 1 0 1 0 1 1 1 1 0 0 0-1-1z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/status.svg b/comm/calendar/base/themes/common/icons/status.svg
new file mode 100644
index 0000000000..14d3289f3e
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/status.svg
@@ -0,0 +1,10 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M6 2h4v2H6V2zm7 .17c1.165.413 2 1.524 2 2.83v7a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V5c0-1.306.835-2.417 2-2.83V12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2.17z"/>
+ <rect x="4" width="1" height="4" rx=".5"/>
+ <rect x="11" width="1" height="4" rx=".5"/>
+ <path d="M3 5h10v1H3z"/>
+ <path d="M10.1 7.2a.5.5 0 0 1 .8.6l-3 4a.5.5 0 0 1-.754.054l-2-2a.5.5 0 1 1 .708-.708l1.592 1.593L10.1 7.2z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/synchronize.svg b/comm/calendar/base/themes/common/icons/synchronize.svg
new file mode 100644
index 0000000000..5c6e0eb688
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/synchronize.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M14 1a1 1 0 0 0-1 1v1.146A6.948 6.948 0 0 0 1.227 6.307a1 1 0 1 0 1.94.484A4.983 4.983 0 0 1 8 3a4.919 4.919 0 0 1 3.967 2H10a1 1 0 0 0 0 2h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm.046 7.481a1 1 0 0 0-1.213.728A4.983 4.983 0 0 1 8 13a4.919 4.919 0 0 1-3.967-2H6a1 1 0 0 0 0-2H2a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0v-1.146a6.948 6.948 0 0 0 11.773-3.161 1 1 0 0 0-.727-1.212z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/task-tab.svg b/comm/calendar/base/themes/common/icons/task-tab.svg
new file mode 100644
index 0000000000..8874ff05c3
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/task-tab.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M2 0h11a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm0 2v12h11V2H2zm6.5 3h3a.5.5 0 1 1 0 1h-3a.5.5 0 0 1 0-1zm0 3h3a.5.5 0 1 1 0 1h-3a.5.5 0 0 1 0-1zm0 3h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1zM6.146 4.146a.5.5 0 1 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-1-1a.5.5 0 1 1 .708-.708l.646.647 1.646-1.647zm0 3a.5.5 0 1 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-1-1a.5.5 0 1 1 .708-.708l.646.647 1.646-1.647z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/task.svg b/comm/calendar/base/themes/common/icons/task.svg
new file mode 100644
index 0000000000..05472c3563
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/task.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M3 1c-1.105 0-2 .839-2 1.875V13.13C1 14.16 1.895 15 3 15V3h12c0-1.035-.89-2-2-2H3zm4.521 2.99a.485.485 0 0 0-.375.156L5.5 5.793l-.646-.647c-.472-.472-1.18.236-.708.708l1 1a.5.5 0 0 0 .708 0l2-2c.354-.354.044-.842-.333-.864zm7.039 1.002c-.37-.024-.77.103-1.12.444l-.94.939 2.12 2.121.94-.939c1.03-1.06.11-2.491-1-2.565zM9.5 5c-.667 0-.667 1 0 1h1c.67 0 .67-1 0-1h-1zM7.521 6.99a.485.485 0 0 0-.375.156L5.5 8.793l-.646-.647c-.472-.472-1.18.236-.708.708l1 1a.5.5 0 0 0 .708 0l2-2c.354-.354.044-.842-.333-.864zm3.469.899L5.439 13.44c-.121.12-.267.31-.439.56v2h1.996c.26-.17.441-.32.561-.44l5.513-5.51-2.08-2.161zM15 10.5L10.5 15H13c.94 0 2-1.06 2-2v-2.5z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/tentative.svg b/comm/calendar/base/themes/common/icons/tentative.svg
new file mode 100644
index 0000000000..d7f55f7ffe
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/tentative.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
+ <path fill="context-fill" fill-opacity="context-fill-opacity" d="M 7,6 H 5 C 5,4.6 5,2 8,2 c 2,0 4,0 4,3.5 0,3 -3,3 -3,5.5 H 7 C 7,7.5 10.1,7.5 10,5.5 10,4 9,4 8,4 7,4 7,5 7,6 Z m 0,6 h 2 v 2 H 7 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/timezones.svg b/comm/calendar/base/themes/common/icons/timezones.svg
new file mode 100644
index 0000000000..bd02529163
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/timezones.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M8 0a8 8 0 1 0 8 8 8 8 0 0 0-8-8zm5.163 4.958h-1.552a7.7 7.7 0 0 0-1.051-2.376 6.03 6.03 0 0 1 2.603 2.376zM14 8a5.963 5.963 0 0 1-.335 1.958h-1.821A12.327 12.327 0 0 0 12 8a12.327 12.327 0 0 0-.156-1.958h1.821A5.963 5.963 0 0 1 14 8zm-6 6c-1.075 0-2.037-1.2-2.567-2.958h5.135C10.037 12.8 9.075 14 8 14zM5.174 9.958a11.084 11.084 0 0 1 0-3.916h5.651A11.114 11.114 0 0 1 11 8a11.114 11.114 0 0 1-.174 1.958zM2 8a5.963 5.963 0 0 1 .335-1.958h1.821a12.361 12.361 0 0 0 0 3.916H2.335A5.963 5.963 0 0 1 2 8zm6-6c1.075 0 2.037 1.2 2.567 2.958H5.433C5.963 3.2 6.925 2 8 2zm-2.56.582a7.7 7.7 0 0 0-1.051 2.376H2.837A6.03 6.03 0 0 1 5.44 2.582zm-2.6 8.46h1.549a7.7 7.7 0 0 0 1.051 2.376 6.03 6.03 0 0 1-2.603-2.376zm7.723 2.376a7.7 7.7 0 0 0 1.051-2.376h1.552a6.03 6.03 0 0 1-2.606 2.376z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/today.svg b/comm/calendar/base/themes/common/icons/today.svg
new file mode 100644
index 0000000000..ed891c7473
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/today.svg
@@ -0,0 +1,9 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 16 16">
+ <path d="M6 2h4v2H6V2zm7 .17c1.165.413 2 1.524 2 2.83v7a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V5c0-1.305.835-2.417 2-2.83V12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2.17z"/>
+ <rect x="4" width="1" height="4" rx=".5"/>
+ <rect x="11" width="1" height="4" rx=".5"/>
+ <path d="M4 7h3v3H4zM3 5h10v1H3z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/icons/warn.svg b/comm/calendar/base/themes/common/icons/warn.svg
new file mode 100644
index 0000000000..a96f74a4aa
--- /dev/null
+++ b/comm/calendar/base/themes/common/icons/warn.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M14.74 12.11L9.79 2.2a2 2 0 0 0-3.58 0l-4.95 9.91A2 2 0 0 0 3.05 15h9.9a2 2 0 0 0 1.79-2.89zM7 5a1 1 0 0 1 2 0v4a1 1 0 0 1-2 0zm1 8.25A1.25 1.25 0 1 1 9.25 12 1.25 1.25 0 0 1 8 13.25z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/attendee-icons.png b/comm/calendar/base/themes/common/images/attendee-icons.png
new file mode 100644
index 0000000000..c425552e76
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/attendee-icons.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/images/event-continue.svg b/comm/calendar/base/themes/common/images/event-continue.svg
new file mode 100644
index 0000000000..5347ef480a
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/event-continue.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 11 11">
+ <path d="M 6,4 H 9 L 5.5,1 2,4 H 5 V 7 H 2 L 5.5,10 9,7 H 6 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/event-end.svg b/comm/calendar/base/themes/common/images/event-end.svg
new file mode 100644
index 0000000000..ebe1568724
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/event-end.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 11 11">
+ <path d="M 2,10 H 9 V 9 H 5.5 L 9,6 H 6 V 1 H 5 V 6 H 2 L 5.5,9 H 2 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/event-grippy.png b/comm/calendar/base/themes/common/images/event-grippy.png
new file mode 100644
index 0000000000..3bb8eba113
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/event-grippy.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/images/event-start.svg b/comm/calendar/base/themes/common/images/event-start.svg
new file mode 100644
index 0000000000..89fde1f69e
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/event-start.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 11 11">
+ <path d="M 2,1 H 9 V 2 H 6 V 7 H 9 L 5.5,10 2,7 H 5 V 2 H 2 Z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/timezone_map.png b/comm/calendar/base/themes/common/images/timezone_map.png
new file mode 100644
index 0000000000..1d3f84445f
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/timezone_map.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/images/timezones.png b/comm/calendar/base/themes/common/images/timezones.png
new file mode 100644
index 0000000000..9170d9d79c
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/timezones.png
Binary files differ
diff --git a/comm/calendar/base/themes/common/images/todayButton-arrow.svg b/comm/calendar/base/themes/common/images/todayButton-arrow.svg
new file mode 100644
index 0000000000..54ec731c10
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/todayButton-arrow.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 7" width="9" height="7">
+ <path fill="context-fill" d="M4.5.74a.9.9 0 0 0-.7.2L.2 4.64a.92.92 0 0 0 1.3 1.3l3-3 3 3a.9.9 0 0 0 1.2-1.3L5.1.94a.9.9 0 0 0-.6-.2z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/todo-complete.svg b/comm/calendar/base/themes/common/images/todo-complete.svg
new file mode 100644
index 0000000000..ef60bcd5a4
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/todo-complete.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 11 11">
+ <path d="M1 0C.5 0 0 .5 0 1v9c0 .5.5 1 1 1h9c.5 0 1-.5 1-1V1c0-.5-.5-1-1-1zm0 1h9v9H1zm6.82 2.99a.52.52 0 0 0-.37.16L4.8 6.79 3.65 5.65c-.46-.48-1.17.23-.7.7l1.5 1.5c.2.2.5.2.7 0l3-3c.36-.35.05-.84-.33-.86z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/images/todo.svg b/comm/calendar/base/themes/common/images/todo.svg
new file mode 100644
index 0000000000..194d5fce37
--- /dev/null
+++ b/comm/calendar/base/themes/common/images/todo.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 11 11">
+ <path d="M1 0C.5 0 0 .5 0 1v9c0 .5.5 1 1 1h9c.5 0 1-.5 1-1V1c0-.5-.5-1-1-1zm0 1h9v9H1zm1.49 2c-.65 0-.66 1 0 1h.11c.65 0 .65-1 0-1zM5.3 3c-.65 0-.66 1 0 1h3c.65 0 .65-1 0-1zM2.5 5c-.65 0-.65 1 0 1h.11c.65 0 .65-1 0-1zM5.3 5c-.65 0-.65 1 0 1h3c.65 0 .65-1 0-1zM2.5 7c-.65 0-.66 1 0 1h.11c.65 0 .65-1 0-1zM5.3 7c-.65 0-.66 1 0 1h3c.65 0 .65-1 0-1z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/imip.css b/comm/calendar/base/themes/common/imip.css
new file mode 100644
index 0000000000..893e633706
--- /dev/null
+++ b/comm/calendar/base/themes/common/imip.css
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.invitation-details {
+ text-align: center;
+ margin: 1em 0;
+ padding: 1em;
+}
+
+.invitation-details[open] > summary {
+ margin-bottom: 0.5em;
+}
+
+.invitation-border {
+ border: 3px solid -moz-default-color;
+ margin-inline-start: auto;
+ margin-inline-end: auto;
+ width: -moz-fit-content;
+ height: -moz-fit-content;
+}
+
+.invitation-table {
+ border-collapse: collapse;
+ width: 40em;
+}
+
+.invitation-table :is(td, th) {
+ padding: 3px;
+ vertical-align: baseline;
+}
+
+.invitation-table td {
+ overflow-wrap: anywhere;
+}
+
+.invitation-table .description {
+ width: 9em;
+ text-align: end;
+ font-weight: normal;
+ border-inline-end: 1px solid hsla(0, 0%, 50%, .2);
+ background-color: hsla(0, 0%, 50%, .2);
+}
+
+.invitation-table .content {
+ width: 29em;
+}
+
+.invitation-table .added {
+ color: rgb(255, 0, 0);
+ text-decoration-line: none;
+}
+
+.invitation-table .modified {
+ color: rgb(255, 0, 0);
+ font-style: italic;
+}
+
+.invitation-table .removed {
+ color: rgb(125, 125, 125);
+}
+
+@media (prefers-contrast) {
+ .invitation-table .added {
+ color: currentColor;
+ font-weight: bold;
+ }
+
+ .invitation-table .modified {
+ color: currentColor;
+ }
+
+ .invitation-table .removed {
+ color: currentColor;
+ }
+}
diff --git a/comm/calendar/base/themes/common/jar.inc.mn b/comm/calendar/base/themes/common/jar.inc.mn
new file mode 100644
index 0000000000..201f8d9a91
--- /dev/null
+++ b/comm/calendar/base/themes/common/jar.inc.mn
@@ -0,0 +1,99 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# This is not a complete / proper jar manifest. It is included by the
+# actual theme-specific manifests, so that shared resources need only
+# be specified once. As a result, the source file paths are relative
+# to the location of the actual manifest.
+ skin/classic/calendar/dialogs/calendar-uri-redirect-dialog.css (../common/dialogs/calendar-uri-redirect-dialog.css)
+ skin/classic/calendar/shared/calendar.css (../common/calendar.css)
+ skin/classic/calendar/shared/calendar-alarms.css (../common/calendar-alarms.css)
+ skin/classic/calendar/shared/calendar-attendees.css (../common/calendar-attendees.css)
+ skin/classic/calendar/shared/calendar-creation.css (../common/calendar-creation.css)
+ skin/classic/calendar/shared/calendar-daypicker.css (../common/calendar-daypicker.css)
+ skin/classic/calendar/shared/calendar-item-summary.css (../common/calendar-item-summary.css)
+ skin/classic/calendar/shared/calendar-itip-icons.svg (../common/calendar-itip-icons.svg)
+ skin/classic/calendar/shared/calendar-invitation-display.css (../common/calendar-invitation-display.css)
+ skin/classic/calendar/shared/calendar-occurrence-prompt.css (../common/calendar-occurrence-prompt.css)
+ skin/classic/calendar/shared/calendar-occurrence.svg (../common/calendar-occurrence.svg)
+ skin/classic/calendar/shared/calendar-print.css (../common/calendar-print.css)
+ skin/classic/calendar/shared/calendar-preferences.css (../common/calendar-preferences.css)
+ skin/classic/calendar/shared/calendar-providerUninstall-dialog.css (../common/calendar-providerUninstall-dialog.css)
+ skin/classic/calendar/shared/calendar-task-tree.css (../common/calendar-task-tree.css)
+ skin/classic/calendar/shared/calendar-task-view.css (../common/calendar-task-view.css)
+ skin/classic/calendar/shared/calendar-toolbar.css (../common/calendar-toolbar.css)
+ skin/classic/calendar/shared/calendar-unifinder.css (../common/calendar-unifinder.css)
+ skin/classic/calendar/shared/calendar-views.css (../common/calendar-views.css)
+ skin/classic/calendar/shared/datetimepickers.css (../common/datetimepickers.css)
+ skin/classic/calendar/shared/dialogs/calendar-alarm-dialog.css (../common/dialogs/calendar-alarm-dialog.css)
+ skin/classic/calendar/shared/dialogs/calendar-event-dialog.css (../common/dialogs/calendar-event-dialog.css)
+ skin/classic/calendar/shared/dialogs/calendar-event-dialog-attendees.css (../common/dialogs/calendar-event-dialog-attendees.css)
+ skin/classic/calendar/shared/dialogs/calendar-ics-file-dialog.css (../common/dialogs/calendar-ics-file-dialog.css)
+ skin/classic/calendar/shared/dialogs/calendar-invitations-dialog.css (../common/dialogs/calendar-invitations-dialog.css)
+ skin/classic/calendar/shared/dialogs/calendar-itip-identity-dialog.css (../common/dialogs/calendar-itip-identity-dialog.css)
+ skin/classic/calendar/shared/dialogs/chooseCalendarDialog.css (../common/dialogs/chooseCalendarDialog.css)
+ skin/classic/calendar/shared/calendar-properties-dialog.css (../common/dialogs/calendar-properties-dialog.css)
+ skin/classic/calendar/shared/dialogs/calendar-summary-dialog.css (../common/dialogs/calendar-summary-dialog.css)
+ skin/classic/calendar/shared/calendar-timezone-highlighter.css (../common/dialogs/calendar-timezone-highlighter.css)
+ skin/classic/calendar/shared/calendar-event-dialog-attendees.png (../common/dialogs/images/calendar-event-dialog-attendees.png)
+ skin/classic/calendar/shared/calendar-invitations-dialog-list-images.png (../common/dialogs/images/calendar-invitations-dialog-list-images.png)
+ skin/classic/calendar/shared/chain-lock.svg (../common/dialogs/images/chain-lock.svg)
+ skin/classic/calendar/shared/chain-unlock.svg (../common/dialogs/images/chain-unlock.svg)
+ skin/classic/calendar/shared/link-image-bottom.svg (../common/dialogs/images/link-image-bottom.svg)
+ skin/classic/calendar/shared/link-image-top.svg (../common/dialogs/images/link-image-top.svg)
+ skin/classic/calendar/shared/statusbar-priority-low.svg (../common/dialogs/images/statusbar-priority-low.svg)
+ skin/classic/calendar/shared/statusbar-priority-normal.svg (../common/dialogs/images/statusbar-priority-normal.svg)
+ skin/classic/calendar/shared/statusbar-priority-high.svg (../common/dialogs/images/statusbar-priority-high.svg)
+ skin/classic/calendar/shared/icons/alarm-no.svg (../common/icons/alarm-no.svg)
+ skin/classic/calendar/shared/icons/alarm.svg (../common/icons/alarm.svg)
+ skin/classic/calendar/shared/icons/complete.svg (../common/icons/complete.svg)
+ skin/classic/calendar/shared/icons/confidential.svg (../common/icons/confidential.svg)
+ skin/classic/calendar/shared/icons/decline.svg (../common/icons/decline.svg)
+ skin/classic/calendar/shared/icons/edit.svg (../common/icons/edit.svg)
+ skin/classic/calendar/shared/icons/email.svg (../common/icons/email.svg)
+ skin/classic/calendar/shared/icons/event.svg (../common/icons/event.svg)
+ skin/classic/calendar/shared/icons/find.svg (../common/icons/find.svg)
+ skin/classic/calendar/shared/icons/freebusy.svg (../common/icons/freebusy.svg)
+ skin/classic/calendar/shared/icons/icon32.svg (../common/icons/icon32.svg)
+ skin/classic/calendar/shared/icons/imip-bar.svg (../common/icons/imip-bar.svg)
+ skin/classic/calendar/shared/icons/locked.svg (../common/icons/locked.svg)
+ skin/classic/calendar/shared/icons/low-priority.svg (../common/icons/low-priority.svg)
+ skin/classic/calendar/shared/icons/newevent.svg (../common/icons/newevent.svg)
+ skin/classic/calendar/shared/icons/newtask.svg (../common/icons/newtask.svg)
+ skin/classic/calendar/shared/icons/pane.svg (../common/icons/pane.svg)
+ skin/classic/calendar/shared/icons/priority.svg (../common/icons/priority.svg)
+ skin/classic/calendar/shared/icons/private.svg (../common/icons/private.svg)
+ skin/classic/calendar/shared/icons/recurrence-exception.svg (../common/icons/recurrence-exception.svg)
+ skin/classic/calendar/shared/icons/recurrence.svg (../common/icons/recurrence.svg)
+ skin/classic/calendar/shared/icons/save-close.svg (../common/icons/save-close.svg)
+ skin/classic/calendar/shared/icons/sort.svg (../common/icons/sort.svg)
+ skin/classic/calendar/shared/icons/status.svg (../common/icons/status.svg)
+ skin/classic/calendar/shared/icons/synchronize.svg (../common/icons/synchronize.svg)
+ skin/classic/calendar/shared/icons/task-tab.svg (../common/icons/task-tab.svg)
+ skin/classic/calendar/shared/icons/task.svg (../common/icons/task.svg)
+ skin/classic/calendar/shared/icons/tentative.svg (../common/icons/tentative.svg)
+ skin/classic/calendar/shared/icons/timezones.svg (../common/icons/timezones.svg)
+ skin/classic/calendar/shared/icons/today.svg (../common/icons/today.svg)
+ skin/classic/calendar/shared/icons/warn.svg (../common/icons/warn.svg)
+ skin/classic/calendar/shared/imip.css (../common/imip.css)
+ skin/classic/calendar/shared/attendee-icons.png (../common/images/attendee-icons.png)
+ skin/classic/calendar/shared/event-continue.svg (../common/images/event-continue.svg)
+ skin/classic/calendar/shared/event-end.svg (../common/images/event-end.svg)
+ skin/classic/calendar/shared/event-grippy.png (../common/images/event-grippy.png)
+ skin/classic/calendar/shared/event-start.svg (../common/images/event-start.svg)
+ skin/classic/calendar/shared/publishDialog.css (../common/publishDialog.css)
+ skin/classic/calendar/shared/timezone_map.png (../common/images/timezone_map.png)
+ skin/classic/calendar/shared/timezones.png (../common/images/timezones.png)
+ skin/classic/calendar/shared/todayButton-arrow.svg (../common/images/todayButton-arrow.svg)
+ skin/classic/calendar/shared/todo-complete.svg (../common/images/todo-complete.svg)
+ skin/classic/calendar/shared/todo.svg (../common/images/todo.svg)
+ skin/classic/calendar/shared/today-pane.css (../common/today-pane.css)
+ skin/classic/calendar/shared/view-cycler.svg (../common/view-cycler.svg)
+ skin/classic/calendar/shared/widgets/calendar-widgets.css (../common/widgets/calendar-widgets.css)
+ skin/classic/calendar/shared/widgets/calendar-invitation-panel.css (../common/widgets/calendar-invitation-panel.css)
+ skin/classic/calendar/shared/widgets/calendar-minidate.css (../common/widgets/calendar-minidate.css)
+ skin/classic/calendar/shared/widgets/drag-center.svg (../common/widgets/images/drag-center.svg)
+ skin/classic/calendar/shared/widgets/nav-arrow.svg (../common/widgets/images/nav-arrow.svg)
+ skin/classic/calendar/shared/widgets/nav-today.svg (../common/widgets/images/nav-today.svg)
+ skin/classic/calendar/shared/widgets/minimonth.css (../common/widgets/minimonth.css)
diff --git a/comm/calendar/base/themes/common/publishDialog.css b/comm/calendar/base/themes/common/publishDialog.css
new file mode 100644
index 0000000000..cf59fe22e3
--- /dev/null
+++ b/comm/calendar/base/themes/common/publishDialog.css
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+html|label {
+ white-space: nowrap;
+ margin-right: 0.5em;
+}
+
+#publish-progressmeter {
+ width: -moz-available;
+ appearance: none;
+ height: 4px;
+ margin: 10px 4px;
+ background-color: hsla(0, 0%, 60%, 0.2);
+ border-style: none;
+ border-radius: 2px;
+}
+
+#publish-progressmeter::-moz-progress-bar {
+ appearance: none;
+ background-color: var(--primary);
+ border-radius: 2px;
+}
+
+#publish-progressmeter:indeterminate::-moz-progress-bar {
+ /* Make a white reflecting animation.
+ Create a gradient with 2 identical pattern, and enlarge the size to 200%.
+ This allows us to animate background-position with percentage. */
+ background-image: linear-gradient(90deg, transparent 0%,
+ rgba(255, 255, 255, 0.5) 25%,
+ transparent 50%,
+ rgba(255, 255, 255, 0.5) 75%,
+ transparent 100%);
+ background-size: 200% 100%;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #publish-progressmeter:indeterminate::-moz-progress-bar {
+ animation: progressSlideX 1.5s linear infinite;
+ }
+
+ @keyframes progressSlideX {
+ 0% {
+ background-position: 0 0;
+ }
+ 100% {
+ background-position: -100% 0;
+ }
+ }
+}
diff --git a/comm/calendar/base/themes/common/today-pane.css b/comm/calendar/base/themes/common/today-pane.css
new file mode 100644
index 0000000000..16509e6d5a
--- /dev/null
+++ b/comm/calendar/base/themes/common/today-pane.css
@@ -0,0 +1,433 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root[lwt-tree-brighttext] #today-pane-panel {
+ --toolbarbutton-hover-background: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .25));
+ --toolbarbutton-hover-bordercolor: var(--lwt-toolbarbutton-hover-background, rgba(255, 255, 255, .5));
+ --toolbarbutton-active-background: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4));
+ --toolbarbutton-active-bordercolor: var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .7));
+ --toolbarbutton-active-boxshadow: 0 0 0 1px var(--lwt-toolbarbutton-active-background, rgba(255, 255, 255, .4)) inset;
+}
+
+.today-subpane {
+ border-bottom-color: var(--splitter-color);
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+ padding: 0;
+}
+
+#buttonspacer {
+ width: 5px;
+}
+
+#today-pane-panel {
+ background-color: var(--layout-background-0);
+}
+
+#today-pane-panel:-moz-lwtheme {
+ background-color: var(--toolbar-bgcolor);
+ color: var(--toolbar-color);
+}
+
+#today-pane-panel > * {
+ color: var(--layout-color-0);
+}
+
+#today-pane-panel > .sidebar-header {
+ appearance: none;
+ background-color: var(--layout-background-0);
+ border-bottom: 1px solid var(--splitter-color);
+}
+
+:root[lwt-tree] #today-pane-panel > .sidebar-header {
+ background-color: transparent;
+}
+
+#mini-day-image {
+ background-color: hsla(0, 0%, 50%, 0.1);
+}
+
+:root[lwt-tree] #mini-day-image {
+ background-color: transparent;
+}
+
+.today-pane-cycler {
+ appearance: none;
+ border-radius: var(--button-border-radius);
+ padding-left: 5px;
+ padding-right: 5px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ list-style-image: var(--icon-nav-right);
+}
+
+.today-pane-cycler:hover {
+ background-color: hsla(0, 0%, 0%, 0.1);
+ color: inherit;
+}
+
+.today-pane-cycler:hover:active {
+ background-color: hsla(0, 0%, 0%, 0.2);
+ color: inherit;
+}
+
+@media (prefers-color-scheme: dark) {
+ .today-pane-cycler:hover {
+ background-color: hsla(0, 0%, 100%, 0.1);
+ }
+
+ .today-pane-cycler:hover:active {
+ background-color: hsla(0, 0%, 100%, 0.2);
+ }
+}
+
+.today-pane-cycler[dir="prev"]:-moz-locale-dir(ltr) > .toolbarbutton-icon,
+.today-pane-cycler[dir="next"]:-moz-locale-dir(rtl) > .toolbarbutton-icon {
+ transform: scaleX(-1);
+}
+
+#today-closer {
+ margin-inline-end: 3px;
+}
+
+#today-pane-panel:-moz-lwtheme > vbox {
+ text-shadow: none;
+ background-color: var(--layout-background-0);
+}
+
+:root[lwt-tree] #today-pane-panel > vbox {
+ background-color: var(--lwt-accent-color);
+ color: inherit;
+}
+
+:root[lwt-tree-brighttext] #today-pane-panel > vbox {
+ background-image: linear-gradient(rgba(255, 255, 255, 0.05),
+ rgba(255, 255, 255, 0.05));
+}
+
+:root[lwt-tree] #agenda-panel > modebox {
+ background-color: var(--sidebar-background-color);
+}
+
+#today-minimonth-box {
+ background-color: var(--layout-background-1);
+}
+
+:root[lwt-tree] #today-minimonth-box {
+ background-color: var(--sidebar-background-color);
+}
+
+#weekdayNameLabel {
+ font-family: Trebuchet MS, Lucida Grande, Arial, Helvetica, sans-serif;
+ padding-top: 4px;
+ font-weight: bold;
+ font-size: 18px;
+}
+
+.monthlabel {
+ margin-inline-end: 0;
+}
+
+.dateValue {
+ font-family: Arial, Helvetica, Trebuchet MS, Lucida Grande, sans-serif;
+ margin-top: initial;
+ margin-bottom: initial;
+ font-size: 36px;
+ font-weight: bold;
+ width: 1em;
+ text-align: center;
+}
+
+#dragCenter-image-container {
+ pointer-events: none;
+}
+
+.miniday-nav-buttons {
+ margin-top: 2px;
+ min-width: 19px;
+ -moz-user-focus: normal;
+ -moz-context-properties: stroke, fill-opacity;
+ list-style-image: var(--icon-nav-right-sm);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+.miniday-nav-buttons,
+#miniday-dropdown-button {
+ appearance: none;
+ -moz-user-focus: normal;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+}
+
+.miniday-nav-buttons:not([disabled="true"]):hover,
+#miniday-dropdown-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ color: inherit;
+ outline: none;
+}
+
+.miniday-nav-buttons:not([disabled="true"]):hover:active,
+#miniday-dropdown-button:not([disabled="true"]):hover:active {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+#previous-day-button:-moz-locale-dir(ltr),
+#next-day-button:-moz-locale-dir(rtl) {
+ list-style-image: var(--icon-nav-left-sm);
+}
+
+.miniday-nav-buttons:focus-visible:not(:hover),
+#miniday-dropdown-button:focus-visible:not(:hover) {
+ outline: 2px solid var(--focus-outline-color);
+ outline-offset: -2px;
+}
+
+#today-button {
+ list-style-image: var(--icon-nav-today);
+}
+
+.miniday-nav-buttons[disabled] {
+ opacity: .3;
+}
+
+.miniday-nav-buttons > .toolbarbutton-icon {
+ margin: 1px;
+}
+
+#miniday-dropdown-button {
+ max-width: 18px;
+ margin: 2px;
+ -moz-user-focus: normal;
+}
+
+#miniday-dropdown-button > .toolbarbutton-icon,
+#miniday-dropdown-button > .toolbarbutton-text,
+.miniday-nav-buttons > .toolbarbutton-text {
+ display: none;
+}
+
+#miniday-dropdown-button > .toolbarbutton-menu-dropmarker {
+ padding-inline-start: 0;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+#miniday-dropdown-button > .toolbarbutton-menu-dropmarker::part(icon) {
+ width: 12px;
+ height: 12px;
+}
+
+#agenda-toolbar {
+ border: none;
+ padding: 4px 1px;
+}
+
+#todaypane-new-event-button {
+ appearance: none;
+ -moz-user-focus: normal;
+ border: 1px solid transparent;
+ border-radius: var(--button-border-radius);
+ margin: 2px 3px 1px;
+ list-style-image: var(--icon-new-event);
+ -moz-context-properties: fill, stroke, fill-opacity;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ fill-opacity: var(--toolbarbutton-icon-fill-opacity);
+}
+
+#todaypane-new-event-button:not([disabled="true"]):hover {
+ background: var(--toolbarbutton-hover-background);
+ border-color: var(--toolbarbutton-hover-bordercolor);
+ box-shadow: var(--toolbarbutton-hover-boxshadow);
+ color: inherit;
+ outline: none;
+}
+
+#todaypane-new-event-button:not([disabled="true"]):hover:active {
+ background: var(--toolbarbutton-active-background);
+ border-color: var(--toolbarbutton-active-bordercolor);
+ box-shadow: var(--toolbarbutton-active-boxshadow);
+ transition-duration: 10ms;
+}
+
+#todaypane-new-event-button:focus-visible:not(:hover) {
+ outline: 2px solid var(--focus-outline-color);
+ outline-offset: var(--focus-outline-offset);
+}
+
+#todaypane-new-event-button > .toolbarbutton-text {
+ padding-inline-start: 5px;
+}
+
+#today-pane-splitter {
+ border-bottom: 1px solid var(--splitter-color);
+ /* splitter grip area */
+ height: 5px;
+ /* make only the splitter border visible */
+ margin-top: -5px;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+}
+
+#todo-tab-panel {
+ height: 40%;
+ min-height: 160px;
+}
+
+#today-pane-splitter[hidden="true"] + #todo-tab-panel {
+ height: 100%;
+}
+
+#show-completed-checkbox-box {
+ padding-top: 3px;
+ padding-inline-start: 5px;
+}
+
+#agenda-container {
+ flex: 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 7em; /* Show at least the #agenda-toolbar and a part of the events. */
+ height: 0; /* Allow shrinking with flexbox emulation. */
+}
+
+#agenda {
+ flex: 1;
+ overflow: auto;
+ height: 0;
+ margin: 0;
+ padding: 3px 6px;
+ list-style: none;
+ background-color: var(--layout-background-1);
+ border-top: 1px solid var(--splitter-color);
+ --selected-background: var(--selected-item-color);
+ --selected-foreground: var(--selected-item-text-color);
+}
+
+:root[lwt-tree] #agenda {
+ --selected-background: var(--sidebar-highlight-background-color, hsla(0, 0%, 80%, .3));
+ --selected-foreground: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+:root[lwt-tree-brighttext] #agenda {
+ --selected-background: var(--sidebar-highlight-background-color, rgba(249, 249, 250, .1));
+}
+
+.agenda-date-header,
+.agenda-listitem-details {
+ padding: 6px;
+}
+
+:root[uidensity="compact"] :is(.agenda-date-header, .agenda-listitem-details) {
+ padding: 3px;
+}
+
+:root[uidensity="touch"] :is(.agenda-date-header, .agenda-listitem-details) {
+ padding: 12px;
+}
+
+.agenda-date-header {
+ margin-block-start: 9px;
+ margin-inline: -6px;
+ padding-inline: 6px !important;
+ background-color: var(--button-background-color);
+ font-weight: bold;
+}
+
+.agenda-listitem:first-child .agenda-date-header {
+ margin-block-start: -3px;
+}
+
+.agenda-listitem-details {
+ display: flex;
+ align-items: baseline;
+ border-radius: 3px;
+}
+
+.agenda-listitem-all-day .agenda-listitem-details {
+ margin-block: 1px;
+ padding-inline: 8px;
+ background-color: var(--item-backcolor);
+ color: var(--item-forecolor);
+}
+
+.agenda-listitem-past .agenda-listitem-details {
+ opacity: 0.5;
+}
+
+#agenda:focus .agenda-listitem.selected .agenda-listitem-details {
+ background-color: var(--selected-background);
+ color: var(--selected-foreground);
+ opacity: unset; /* Overrides .agenda-listitem-past */
+}
+
+.agenda-listitem-all-day :is(.agenda-listitem-calendar, .agenda-listitem-time) {
+ display: none;
+}
+
+.agenda-listitem-calendar {
+ width: 10px;
+ height: 10px;
+ margin-inline-end: 6px;
+ display: inline-block;
+ border-radius: 5px;
+ background-color: var(--item-backcolor);
+}
+
+.agenda-listitem-details-inner {
+ flex: 1;
+}
+
+.agenda-listitem-time {
+ margin-inline-end: 3px;
+ font-weight: 600;
+}
+
+.agenda-listitem-time:empty {
+ display: none;
+}
+
+.agenda-listitem-relative {
+ font-size: 0.85em;
+ white-space: nowrap;
+}
+
+.agenda-listitem:not(.selected, .agenda-listitem-now) .agenda-listitem-relative {
+ opacity: 0.75;
+}
+
+.agenda-listitem-relative:empty {
+ display: none;
+}
+
+.agenda-listitem-now .agenda-listitem-relative {
+ padding: 1px 4px;
+ border-radius: 12px;
+ background-color: var(--viewTodayLabelBackground);
+ color: var(--viewTodayLabelColor);
+ font-weight: bold;
+}
+
+#agenda:focus .agenda-listitem-now.selected .agenda-listitem-relative {
+ background-color: var(--selected-foreground);
+ color: var(--selected-background);
+}
+
+.agenda-listitem-overlap {
+ margin-inline-start: 6px;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+.agenda-listitem-overlap:not([src]) {
+ display: none;
+}
diff --git a/comm/calendar/base/themes/common/view-cycler.svg b/comm/calendar/base/themes/common/view-cycler.svg
new file mode 100644
index 0000000000..847378327c
--- /dev/null
+++ b/comm/calendar/base/themes/common/view-cycler.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M6 14a1 1 0 0 1-.707-1.707L9.586 8 5.293 3.707a1 1 0 0 1 1.414-1.414l5 5a1 1 0 0 1 0 1.414l-5 5A1 1 0 0 1 6 14z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/widgets/calendar-invitation-panel.css b/comm/calendar/base/themes/common/widgets/calendar-invitation-panel.css
new file mode 100644
index 0000000000..dde91c2061
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/calendar-invitation-panel.css
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+@import url("chrome://global/skin/in-content/common.css");
+
+:host(calendar-invitation-panel) {
+ background-color: Field;
+ width: auto;
+ border: 1px solid var(--splitter-color);
+}
+
+:host(calendar-invitation-panel) .notificationbox-stack {
+ width: 100%;
+ padding: 10px 20px;
+}
+
+:host(calendar-invitation-panel) .notificationbox-stack > notification-message {
+ margin: 0;
+}
+
+.calendar-invitation-panel-wrapper {
+ display: flex;
+ padding: 10px 20px;
+}
+
+.calendar-invitation-panel-details {
+ padding: 10px 20px;
+ display: flex;
+ flex-direction: column;
+}
+
+.calendar-invitation-panel-title {
+ margin-bottom: 5px;
+ display: flex;
+ justify-content: center;
+}
+
+.calendar-invitation-panel-title > h1 {
+ font-size: 1.4rem;
+ font-weight: 800;
+ margin: 0;
+}
+
+.calendar-invitation-panel-response-buttons {
+ margin-inline-start: auto;
+}
+
+.calendar-invitation-panel-response-buttons button {
+ min-height: 2.5em;
+ font-size: .8em;
+}
+
+.calendar-invitation-panel-details-footer:not([hidden]) {
+ flex-grow: 2;
+ display: flex;
+ align-items: baseline;
+ padding: 10px 20px;
+}
+
+.calendar-invitation-panel-props {
+ margin: 1.5em 0px;
+}
+
+.calendar-invitation-panel-props > dt,
+.calendar-invitation-panel-props > dd {
+ display: inline-block;
+}
+
+.calendar-invitation-panel-props th, .calendar-invitation-panel-props td {
+ vertical-align: top;
+ padding: 3px 0;
+}
+
+.calendar-invitation-panel-props th {
+ text-align: end;
+ padding-inline-end: 1em;
+}
+
+.calendar-invitation-panel-props td.content {
+ overflow-y: auto;
+ max-width: 28em;
+ word-break: break-word;
+}
+
+calendar-invitation-interval {
+ display: inline-block;
+}
+
+.calendar-invitation-panel-partstat-breakdown:before {
+ content: "("
+}
+
+.calendar-invitation-panel-partstat-breakdown:after {
+ content: ")"
+}
+
+.calendar-invitation-panel-partstat-summary +
+.calendar-invitation-panel-partstat-summary {
+ margin-inline-start: 0.5em;
+}
+
+.calendar-invitation-panel-partstat-summary-total {
+ margin-inline-end: 0.5em;
+}
+
+.calendar-invitation-panel-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.calendar-invitation-panel-list li {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+
+.calendar-invitation-panel-list li img {
+ width: 16px;
+ height: 16px;
+}
+
+.calendar-invitation-panel-list li span.removed {
+ text-decoration: line-through;
+}
+
+calendar-invitation-change-indicator:not([hidden]) {
+ border-radius: var(--button-border-radius);
+ margin-inline-end: 3px;
+ margin-block: 2px;
+ padding-inline: 7px;
+ background-color: rgba(0, 0, 0, 0.1);
+ box-shadow: inset 0 0 0 1px transparent;
+ display: inline-block;
+}
diff --git a/comm/calendar/base/themes/common/widgets/calendar-minidate.css b/comm/calendar/base/themes/common/widgets/calendar-minidate.css
new file mode 100644
index 0000000000..50a9eac4cd
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/calendar-minidate.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+@import url("chrome://global/skin/in-content/common.css");
+
+:host(calendar-minidate) {
+ display: block;
+ border: 1px solid var(--splitter-color);
+ align-self: center;
+}
+
+.calendar-minidate-header {
+ padding: 1px 30px;
+ text-align: center;
+ font-weight: 500;
+ font-size: 1.5rem;
+ background-color: var(--viewTodayLabelBackground);
+ color: var(--viewTodayLabelColor);
+}
+
+.calendar-minidate-body {
+ padding: 1px 30px;
+ text-align: center;
+ background-color: Field;
+}
+
+.calendar-minidate-day {
+ font-weight: 700;
+ font-size: 2rem;
+ display: block;
+ color: var(--viewMonthDayBoxLabelColor);
+}
+
+.calendar-minidate-year {
+ font-size: 1.2rem;
+}
diff --git a/comm/calendar/base/themes/common/widgets/calendar-widgets.css b/comm/calendar/base/themes/common/widgets/calendar-widgets.css
new file mode 100644
index 0000000000..4f9c85bdc0
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/calendar-widgets.css
@@ -0,0 +1,388 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root {
+ --calendar-list-header-padding: 9px 12px;
+ --calendar-list-item-padding: 3px;
+ --calendar-list-item-margin: 3px;
+ --calendar-list-item-dot-size: 9px;
+ --calendar-list-item-dot-gap: 6px;
+ --calendar-panel-spacer: 3px;
+ --calendar-side-panel-minimonth-margin: 0 calc(2 * var(--calendar-panel-spacer));
+}
+
+:root[uidensity="compact"] {
+ --calendar-list-header-padding: 6px 9px;
+ --calendar-list-item-padding: 1px;
+ --calendar-list-item-margin: 1px;
+ --calendar-list-item-dot-size: 6px;
+ --calendar-list-item-dot-gap: 3px;
+ --calendar-side-panel-minimonth-margin: var(--calendar-panel-spacer);
+}
+
+:root[uidensity="touch"] {
+ --calendar-list-header-padding: 9px 15px;
+ --calendar-list-item-padding: 6px;
+ --calendar-list-item-margin: 6px;
+ --calendar-list-item-dot-size: 9px;
+ --calendar-list-item-dot-gap: 9px;
+ --calendar-side-panel-minimonth-margin: 0 calc(3 * var(--calendar-panel-spacer));
+}
+
+/* Calendar Items */
+
+.calendar-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ min-height: auto;
+ min-width: auto;
+ margin: 0;
+ background-color: transparent;
+ border-color: transparent;
+ border-radius: 0;
+ padding: var(--calendar-list-header-padding);
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.calendar-list-header > * {
+ pointer-events: none;
+}
+
+.calendar-list-header:hover {
+ border-color: transparent !important;
+}
+
+#calendarListHeader:hover {
+ background-color: var(--layout-background-3);
+}
+
+#calendarListHeader[checked="false"] > img {
+ transform: rotate(-90deg);
+}
+
+.calendar-list-create {
+ list-style-image: var(--icon-add);
+ margin-block: 0;
+ margin-inline: 3px;
+ padding: 3px !important;
+ border: none !important;
+ min-width: auto;
+}
+
+.calendar-list-create .toolbarbutton-text:not([value]) {
+ display: none;
+}
+
+checkbox.treenode-checkbox {
+ margin-inline: 6px;
+ margin-block: 0;
+ font-weight: bold;
+}
+
+checkbox.treenode-checkbox.agenda-checkbox {
+ padding-block: 6px;
+}
+
+checkbox.treenode-checkbox > .checkbox-check {
+ appearance: none;
+ align-items: center;
+ border: none;
+ width: 10px;
+ height: 10px;
+ color: inherit;
+ background-image: var(--icon-nav-down-sm);
+ background-size: contain;
+ background-color: transparent !important;
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+}
+
+checkbox.treenode-checkbox:not([checked="true"]) > .checkbox-check {
+ transform: rotate(-90deg);
+}
+
+checkbox.treenode-checkbox:not([checked="true"]):-moz-locale-dir(rtl) > .checkbox-check {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ checkbox.treenode-checkbox > .checkbox-check {
+ transition: transform 200ms ease;
+ }
+ #calendarListHeader > img {
+ transition: transform 150ms ease;
+ }
+ #calendar-list > li,
+ #calendar-list > li > :is(.calendar-displayed,.calendar-more-button) {
+ transition: opacity 250ms ease;
+ }
+ #calendar-list > li {
+ transition: background-color 250ms ease, color 250ms ease;
+ }
+}
+
+.checkbox-label-box {
+ margin-inline-start: 4px;
+}
+
+.checkbox-icon {
+ margin-inline-end: 2px;
+}
+
+.checkbox-label {
+ margin: 0 !important;
+}
+
+checkbox.treenode-checkbox > .checkbox-label-center-box > .checkbox-label-box > .checkbox-label {
+ font-weight: bold;
+ border-bottom: 1px solid -moz-Dialog;
+}
+
+.selected-text {
+ font-weight: bold;
+}
+
+.selected-text:not([selected="true"]),
+.unselected-text[selected="true"] {
+ visibility: hidden;
+}
+
+.categories-textbox .textbox-search-icon {
+ list-style-image: none;
+ cursor:default;
+}
+
+.categories-textbox {
+ appearance: auto;
+ -moz-default-appearance: textfield;
+}
+
+/*
+ * Note that #calendar-list is used for 2 separate lists that look similar,
+ * but are otherwise independent:
+ *
+ * - calendar-providerUninstall-dialog.xhtml
+ * - calendar-tab-panels.inc.xhtml
+ *
+ * Please be careful when changing the following CSS.
+ */
+
+#calendar-list-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ justify-content: space-between;
+}
+
+#calendar-list-inner-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ overflow-x: hidden;
+}
+
+#calendar-list-inner-pane > #calendar-list {
+ overflow: auto;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ flex: 1 1 0;
+ min-width: 150px;
+}
+
+#calendar-list > li {
+ display: flex;
+ padding-block: var(--calendar-list-item-padding);
+ padding-inline: 14px var(--calendar-list-item-dot-gap);
+ align-items: center;
+ margin-block: var(--calendar-list-item-margin);
+ margin-inline: 9px;
+ border-radius: 3px;
+ gap: var(--calendar-list-item-dot-gap);
+}
+
+#calendar-list > li.selected {
+ background-color: -moz-cellhighlight;
+ color: -moz-cellhighlighttext;
+}
+
+#calendar-list:focus-within > li.selected {
+ background-color: var(--selected-item-color);
+ color: var(--selected-item-text-color);
+}
+
+#calendar-list > li:not(.selected):hover {
+ background-color: color-mix(in srgb, var(--selected-item-color) 10%, transparent);
+}
+
+#calendar-list > li.dragging {
+ opacity: 0.75;
+}
+
+:root[lwt-tree] #calendar-list > li.selected {
+ border-color: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.3));
+ background: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.3));
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+:root[lwt-tree] #calendar-list:focus > li.selected {
+ border-color: var(--sidebar-highlight-background-color, hsla(0,0%,80%,.6));
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+:root[lwt-tree-brighttext] #calendar-list > li.selected {
+ border-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.1));
+ background: var(--sidebar-highlight-background-color, rgba(249,249,250,.1));
+}
+
+:root[lwt-tree-brighttext] #calendar-list:focus > li.selected {
+ border-color: var(--sidebar-highlight-background-color, rgba(249,249,250,.3));
+ color: var(--sidebar-highlight-text-color, var(--sidebar-text-color));
+}
+
+#calendar-list > li > button.calendar-enable-button {
+ color: #fff !important;
+ background: #6b80a4;
+ padding: 1px 4px;
+ font-size: 0.75em;
+ text-transform: uppercase;
+ font-weight: 700;
+ border-radius: 12px;
+ margin: 0;
+ min-width: auto;
+ min-height: auto;
+}
+
+#calendar-list > li > button.calendar-enable-button:hover,
+#calendar-list > li > button.calendar-enable-button:focus,
+#calendar-list > li > button.calendar-enable-button:active {
+ background: #2c4c82;
+}
+
+#calendar-list > li > :is(.calendar-displayed,.calendar-more-button) {
+ appearance: none;
+ color: inherit;
+ background-color: transparent !important;
+ border-style: none;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+ margin: 0px;
+ min-width: 16px;
+ min-height: 16px;
+ width: 16px;
+ height: 16px;
+}
+
+#calendar-list > li > .calendar-displayed {
+ background-image: var(--icon-hidden);
+ opacity: 0.7;
+}
+
+#calendar-list > li > .calendar-displayed:checked {
+ background-image: var(--icon-eye);
+ opacity: 0;
+}
+
+#calendar-list > li > .calendar-more-button {
+ --button-padding: 0;
+ opacity: 0;
+ margin-block: 0;
+ background-image: var(--icon-more);
+}
+
+#calendar-list > li > .calendar-displayed:is(:hover, :focus),
+#calendar-list > li > .calendar-more-button:is(:hover, :focus) {
+ opacity: 0.9 !important;
+}
+
+#calendar-list > li:hover > .calendar-displayed:checked,
+#calendar-list > li:hover > .calendar-more-button {
+ opacity: 0.7;
+}
+
+#calendar-list > :is(li, richlistitem) > .calendar-color {
+ width: var(--calendar-list-item-dot-size);
+ height: var(--calendar-list-item-dot-size);
+ border-radius: 5px;
+ box-sizing: border-box;
+ flex-shrink: 0;
+}
+
+#calendar-list > li > .calendar-name {
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+#calendar-list > li[calendar-disabled]:not(.selected) > .calendar-name {
+ color: #808080;
+}
+
+#calendar-list > li > .calendar-list-icon {
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill, stroke;
+ fill: color-mix(in srgb, currentColor 20%, transparent);
+ stroke: currentColor;
+}
+
+#calendar-list > li > .calendar-list-icon:not([src]) {
+ visibility: hidden;
+}
+
+#calendar-list > li:not([calendar-muted]) > .calendar-mute-status {
+ display: none;
+}
+
+#calendar-list > richlistitem {
+ align-items: center;
+}
+
+#sideButtonsBottom {
+ display: inline-flex;
+ justify-content: space-between;
+}
+
+#newCalendarSidebarButton {
+ background-image: var(--icon-calendar);
+}
+
+#refreshCalendar {
+ background-image: var(--icon-sync);
+}
+
+#sidePanelNewEvent,
+#sidePanelNewTask {
+ background-image: var(--icon-add);
+}
+
+:root[uidensity="touch"] #sidePanelNewEvent,
+:root[uidensity="touch"] #sidePanelNewTask {
+ --icon-size: 20px;
+ background-image: var(--icon-add-md);
+}
+
+/* Calendar Side Panel */
+
+#primaryButtonSidePanel {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+:root[uidensity="compact"] #primaryButtonSidePanel {
+ margin-top: var(--calendar-panel-spacer);
+}
+
+#calMinimonthBox {
+ margin: var(--calendar-side-panel-minimonth-margin);
+}
diff --git a/comm/calendar/base/themes/common/widgets/images/drag-center.svg b/comm/calendar/base/themes/common/widgets/images/drag-center.svg
new file mode 100644
index 0000000000..acb6deb807
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/images/drag-center.svg
@@ -0,0 +1,9 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="36" height="15" stroke="#fff" fill="none">
+ <path stroke-width="3" stroke-opacity=".8" d="M31.5 3v9M27 7.5h9M0 7.5h8"/>
+ <path stroke="#aaa" d="M1 7.5h6M28 7.5h7M31.5 4v7"/>
+ <circle r="6.5" cy="7.5" cx="18" stroke-width="2" stroke-opacity=".8"/>
+ <circle r="5.5" cy="7.5" cx="18" stroke="#4c4c4c" stroke-width="1.5" stroke-opacity=".5"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/widgets/images/nav-arrow.svg b/comm/calendar/base/themes/common/widgets/images/nav-arrow.svg
new file mode 100644
index 0000000000..db02016c56
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/images/nav-arrow.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
+ <path fill="context-fill" d="M9 6a1 1 0 0 0-.293-.707l-3-3a1 1 0 0 0-1.414 1.414L6.586 6 4.293 8.293a1 1 0 0 0 1.414 1.414l3-3A1 1 0 0 0 9 6z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/widgets/images/nav-today.svg b/comm/calendar/base/themes/common/widgets/images/nav-today.svg
new file mode 100644
index 0000000000..d9b2fb6083
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/images/nav-today.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12">
+ <circle cx="6" cy="6" r="4" style="fill:none;stroke:context-fill;stroke-width:2.25;" />
+</svg>
diff --git a/comm/calendar/base/themes/common/widgets/images/view-navigation.svg b/comm/calendar/base/themes/common/widgets/images/view-navigation.svg
new file mode 100644
index 0000000000..847378327c
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/images/view-navigation.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <path fill="context-fill" d="M6 14a1 1 0 0 1-.707-1.707L9.586 8 5.293 3.707a1 1 0 0 1 1.414-1.414l5 5a1 1 0 0 1 0 1.414l-5 5A1 1 0 0 1 6 14z"/>
+</svg>
diff --git a/comm/calendar/base/themes/common/widgets/minimonth.css b/comm/calendar/base/themes/common/widgets/minimonth.css
new file mode 100644
index 0000000000..28c26e4b6f
--- /dev/null
+++ b/comm/calendar/base/themes/common/widgets/minimonth.css
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+calendar-minimonth {
+ --mmMainColor: -moz-DialogText;
+ --mmMainBackground: var(--color-gray-05);
+ --mmMainBorderColor: var(--color-gray-20);
+ --mmMainBorderRadius: 6px;
+ --mmHighlightColor: var(--selected-item-text-color);
+ --mmHighlightBackground: var(--selected-item-color);
+ --mmHighlightBorderColor: var(--selected-item-color);
+ --mmBoxBackground: var(--color-gray-20);
+ --mmBoxBorderColor: var(--color-gray-20);
+ --mmBoxPadding: 0;
+ --mmBoxItemColor: var(--color-gray-30);
+ --mmBoxItemBorderColor: transparent;
+ --mmBoxItemPadding: 2px;
+ --mmDayColor: var(--color-gray-70);
+ --mmDayBackground: transparent;
+ --mmDayBorderColor: transparent;
+ --mmDayOtherColor: var(--color-gray-40);
+ --mmDayWeekColor: var(--color-ink-40);
+ --mmDayOtherBackground: transparent;
+ --mmDayOtherBorderColor: transparent;
+ --mmDayTodayColor: var(--color-gray-90);
+ --mmDayTodayBackground: color-mix(in srgb, var(--color-ink-40) 20%, transparent);
+ --mmDayTodayBorderColor: transparent;
+ --mmDaySelectedColor: var(--color-blue-60);
+ --mmDaySelectedBackground: var(--color-blue-10);
+ --mmDaySelectedBorderColor: var(--color-blue-50);
+ --mmDaySelectedTodayBackground: var(--color-blue-10);
+ --mmDaySelectedTodayBorderColor: var(--color-blue-50);
+ --mmDayBorderRadius: 3px;
+ --mmDayPadding: 4px;
+ --mmDayBusyColor: var(--color-blue-50);
+ --mmDayBusyIndicatorSize: 4px;
+}
+
+/* This includes the light theme not inherited by the system */
+:root[lwt-tree]:not([lwt-tree-brighttext]) calendar-minimonth {
+ --mmMainColor: var(--sidebar-text-color, FieldText);
+ --mmMainBackground: transparent;
+ --mmHighlightColor: var(--sidebar-highlight-text-color, var(--selected-item-text-color));
+ --mmHighlightBackground: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --mmHighlightBorderColor: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --mmBoxBackground: var(--color-gray-20);
+ --mmBoxBorderColor: var(--color-gray-30);
+ --mmBoxItemColor: var(--color-gray-30);
+ --mmBoxItemBorderColor: transparent;
+ --mmDayTodayColor: var(--color-gray-90);
+ --mmDayTodayBorderColor: transparent;
+ --mmDayColor: var(--color-gray-70);
+ --mmDayBorderColor: transparent;
+ --mmDayOtherColor: var(--color-gray-40);
+ --mmDayOtherBackground: transparent;
+ --mmDayOtherBorderColor: transparent;
+}
+
+@media (prefers-color-scheme: dark) {
+ calendar-minimonth {
+ --mmMainColor: var(--sidebar-text-color, FieldText);
+ --mmMainBackground: var(--color-gray-70);
+ --mmMainBorderColor: var(--color-gray-50);
+ --mmHighlightColor: var(--sidebar-highlight-text-color, var(--selected-item-text-color));
+ --mmHighlightBackground: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --mmHighlightBorderColor: var(--sidebar-highlight-background-color, var(--selected-item-color));
+ --mmBoxBackground: var(--color-gray-50);
+ --mmBoxBorderColor: var(--color-gray-40);
+ --mmBoxItemColor: var(--color-gray-40);
+ --mmBoxItemBorderColor: transparent;
+ --mmDayTodayColor: var(--color-ink-10);
+ --mmDayTodayBorderColor: transparent;
+ --mmDayColor: var(--color-gray-10);
+ --mmDayBackground: transparent;
+ --mmDayBorderColor: transparent;
+ --mmDayOtherColor: var(--color-gray-50);
+ --mmDayOtherBackground: transparent;
+ --mmDayOtherBorderColor: transparent;
+ --mmDaySelectedColor: var(--color-ink-10);
+ --mmDaySelectedBackground: color-mix(in srgb, var(--color-blue-40) 20%, transparent);
+ --mmDaySelectedBorderColor: var(--color-blue-50);
+ --mmDaySelectedTodayBackground: color-mix(in srgb, var(--color-blue-40) 30%, transparent);
+ --mmDaySelectedTodayBorderColor: var(--color-blue-50);
+ --mmDayWeekColor: var(--color-ink-40);
+ }
+}
+
+@media (prefers-contrast) {
+ calendar-minimonth:not(:-moz-lwtheme) {
+ --mmMainBackground: transparent;
+ --mmBoxBackground: ButtonFace;
+ --mmBoxBorderColor: var(--color-gray-30);
+ --mmBoxItemColor: var(--color-gray-40);
+ --mmBoxItemBorderColor: var(--mmBoxBorderColor);
+ --mmDayColor: WindowText;
+ --mmDayOtherColor: GrayText;
+ --mmDayWeekColor: var(--selected-item-color);
+ --mmDayOtherBackground: transparent;
+ --mmDayTodayColor: -moz-DialogText;
+ --mmDayTodayBackground: Field;
+ --mmDayTodayBorderColor: var(--selected-item-color);
+ --mmDaySelectedColor: var(--selected-item-text-color);
+ --mmDaySelectedBackground: var(--selected-item-color);
+ --mmDaySelectedBorderColor: ButtonFace;
+ --mmDaySelectedTodayBackground: var(--selected-item-color);
+ --mmDaySelectedTodayBorderColor: ButtonFace;
+ }
+}
+
+:root[uidensity="compact"] calendar-minimonth {
+ --mmDayPadding: 2px;
+}
+
+:root[uidensity="touch"] calendar-minimonth {
+ --mmDayPadding: 8px;
+ --mmBoxPadding: 1px;
+ --mmBoxItemPadding: 4px;
+}
+
+.datepicker-menulist > menupopup::part(content) {
+ --panel-padding: 3px;
+}
+
+calendar-minimonth {
+ background-color: var(--mmMainBackground);
+ border-width: 0;
+ color: var(--mmMainColor);
+ padding: 1px;
+ min-width: 175px;
+}
+
+calendar-minimonth:not([readonly="true"]) .minimonth-readonly-header,
+calendar-minimonth[readonly="true"] .minimonth-header {
+ display: none;
+}
+
+calendar-minimonth[readonly="true"] .minimonth-readonly-header {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 2px;
+ border-bottom: 1px solid var(--mmBoxBackground);
+ font-size: 1.1rem;
+}
+
+.minimonth-month-box {
+ font-weight: bold;
+ margin-bottom: var(--mmBoxPadding);
+ padding: 0;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.minimonth-month-name {
+ display: inline-block;
+ font-weight: bold;
+ padding: var(--mmBoxPadding);
+ width: 7ch;
+ min-width: unset;
+ text-align: center;
+}
+
+.minimonth-year-name {
+ display: inline-block;
+ font-weight: bold;
+ width: 6ch;
+ min-width: unset;
+ padding: var(--mmBoxPadding);
+ text-align: center;
+}
+
+.minimonth-year-name[type="number"] {
+ text-align: center;
+}
+
+.minimonth-nav-section {
+ display: inline-flex;
+ align-items: center;
+ color: inherit;
+ background-color: var(--mmBoxBackground);
+ border: 1px solid var(--mmBoxItemBorderColor);
+ border-radius: var(--button-border-radius);
+ margin: var(--mmBoxItemPadding);
+}
+
+.minimonth-header button,
+.minimonth-header-btn {
+ background: inherit;
+ border-color: transparent;
+ border-radius: 2px;
+ stroke: var(--mmMainColor);
+ width: auto;
+ min-width: unset;
+ height: 100%;
+ min-height: 0;
+ margin: var(--mmBoxPadding);
+ padding: var(--mmBoxPadding);
+ vertical-align: middle;
+}
+
+.button.minimonth-nav-btn.today-button {
+ background-image: var(--icon-calendar-today);
+}
+
+.button.minimonth-nav-btn[dir="1"] {
+ background-image: var(--icon-nav-right);
+}
+
+.button.minimonth-nav-btn[dir="-1"] {
+ background-image: var(--icon-nav-left);
+}
+
+@media (prefers-color-scheme: dark) {
+ .button.minimonth-nav-btn:active,
+ .button.minimonth-nav-btn:hover,
+ .button.minimonth-nav-btn:focus,
+ .button.minimonth-nav-btn:enabled:is(:hover, :focus-visible, :active) {
+ background-color: var(--color-gray-60);
+ }
+}
+
+@media (prefers-contrast) {
+ .button.minimonth-nav-btn:not(:-moz-lwtheme):active,
+ .button.minimonth-nav-btn:not(:-moz-lwtheme):hover,
+ .button.minimonth-nav-btn:not(:-moz-lwtheme):focus,
+ .button.minimonth-nav-btn:not(:-moz-lwtheme):enabled:is(:hover, :focus-visible, :active) {
+ background-color: var(--mmDaySelectedBackground);
+ color: var(--mmDaySelectedColor);
+ stroke: var(--mmDaySelectedColor);
+ border-color: transparent;
+ }
+}
+
+.minimonth-nav-btn:dir(rtl)[dir="-1"] ,
+.minimonth-nav-btn:dir(rtl)[dir="1"] {
+ transform: scaleX(-1);
+}
+
+.minimonth-nav-item {
+ display: inline-block;
+ border-inline: 1px solid var(--mmBoxItemColor);
+ margin: 0;
+}
+
+.minimonth-nav-item input {
+ color: inherit;
+ background: inherit;
+ border: none;
+ margin: 0;
+}
+
+.minimonth-cal-box {
+ border-spacing: 0;
+ padding: 4px;
+}
+
+.minimonth-cal-box th, .minimonth-cal-box td {
+ width: 12.5%; /* 100% / 8 columns */
+}
+
+.minimonth-row-header {
+ text-align: center;
+}
+
+.minimonth-day {
+ color: var(--mmDayColor);
+ text-align: center;
+ border: 1px solid var(--mmDayBorderColor);
+ border-radius: var(--mmDayBorderRadius);
+ background-color: var(--mmDayBackground);
+ min-height: 16px;
+ padding: var(--mmDayPadding);
+}
+
+.minimonth-row-header-week {
+ color: var(--mmDayOtherColor);
+ text-align: center;
+}
+
+.minimonth-week {
+ color: var(--mmDayWeekColor);
+ text-align: center;
+ border: 1px solid var(--mmDayBorderColor);
+ background-color: var(--mmMainBackground);
+ min-height: 16px;
+}
+
+.minimonth-day[othermonth="true"] {
+ color: var(--mmDayOtherColor);
+ background-color: var(--mmDayOtherBackground);
+ border: 1px solid var(--mmDayOtherBorderColor);
+}
+
+.minimonth-day[today="true"] {
+ background-color: var(--mmDayTodayBackground);
+ border: 1px solid var(--mmDayTodayBorderColor);
+ color: var(--mmDayTodayColor);
+ font-weight: 800;
+}
+
+.minimonth-day[selected="true"] {
+ background-color: var(--mmDaySelectedBackground);
+ color: var(--mmDaySelectedColor);
+ border: 1px solid var(--mmDaySelectedBorderColor);
+}
+
+#repeat-until-datepicker .minimonth-day[extra="true"],
+#repeat-until-date .minimonth-day[extra="true"] {
+ border: 1px solid var(--mmDayOtherColor);
+}
+
+#repeat-until-datepicker .minimonth-day:hover[extra="true"],
+#repeat-until-date .minimonth-day:hover[extra="true"] {
+ border: 1px solid var(--mmHighlightBorderColor);
+}
+
+.minimonth-day[selected="true"][today="true"] {
+ background-color: var(--mmDaySelectedTodayBackground);
+ border: 1px solid var(--mmDaySelectedTodayBorderColor);
+}
+
+.minimonth-day,
+.minimonth-week {
+ vertical-align: top;
+ padding: var(--mmDayPadding);
+}
+
+.minimonth-day::after {
+ content: "";
+ display: block;
+ height: var(--mmDayBusyIndicatorSize);
+ width: var(--mmDayBusyIndicatorSize);
+ margin-block-start: 2px;
+ margin-inline: auto;
+ border-radius: 50%;
+ background-color: transparent;
+}
+
+.minimonth-day[busy]::after {
+ background-color: var(--mmDayBusyColor);
+}
+
+.minimonth-day:hover[interactive] {
+ cursor: pointer;
+ border: 1px solid var(--mmDaySelectedTodayBorderColor);
+ outline: none;
+}
+
+.minimonth-day:hover[interactive][selected="true"] {
+ border-color: var(--mmHighlightBorderColor);
+}
+
+.minimonth-day:active[interactive] {
+ background-color: var(--mmHighlightBackground);
+ color: var(--mmHighlightColor);
+}
+
+.minimonth-list {
+ padding-inline-start: 1em;
+ padding-inline-end: 1em;
+}
+
+.minimonth-list[current="true"] {
+ font-weight: bold;
+}
+
+.minimonth-list:hover {
+ background-color: var(--mmHighlightBackground);
+ color: var(--mmHighlightColor);
+ cursor: pointer;
+}
+
+calendar-minimonth :focus-visible:not(:hover) {
+ outline: 2px solid var(--focus-outline-color);
+ outline-offset: -2px;
+}
+
+/* Minimonth border */
+
+#minimonth-pane calendar-minimonth,
+#recurrencePreview calendar-minimonth {
+ border: 1px solid var(--mmMainBorderColor);
+ border-radius: var(--mmMainBorderRadius);
+}
diff --git a/comm/calendar/base/themes/linux/calendar-daypicker.css b/comm/calendar/base/themes/linux/calendar-daypicker.css
new file mode 100644
index 0000000000..a53eb8c612
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar-daypicker.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-daypicker.css);
+
+button.calendar-daypicker {
+ border-top: 1px solid ThreeDShadow;
+ border-left: 1px solid ThreeDShadow;
+}
+
+hbox:last-child > button.calendar-daypicker {
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+button.calendar-daypicker:last-child {
+ border-right: 1px solid ThreeDShadow;
+}
diff --git a/comm/calendar/base/themes/linux/calendar-task-tree.css b/comm/calendar/base/themes/linux/calendar-task-tree.css
new file mode 100644
index 0000000000..5164712b4d
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar-task-tree.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-tree.css);
+
+.calendar-task-tree-col-priority > .treecol-icon {
+ padding-inline-end: 4px;
+}
+
+:root[lwt-tree] .calendar-task-tree-col-priority > .treecol-icon {
+ padding-inline-end: 1px;
+}
diff --git a/comm/calendar/base/themes/linux/calendar-task-view.css b/comm/calendar/base/themes/linux/calendar-task-view.css
new file mode 100644
index 0000000000..77da1e560d
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar-task-view.css
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-view.css);
+
+#calendar-task-view-splitter {
+ appearance: none;
+ border-bottom: 1px solid var(--splitter-color);
+ /* splitter grip area */
+ height: 5px;
+ /* make only the splitter border visible */
+ margin-top: -5px;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+}
+
+#task-addition-box {
+ height: 37px;
+}
+
+#calendar-task-details-container {
+ padding-top: 1px;
+}
+
+#task-text-filter-field {
+ margin: 5px;
+}
+
+/* ::::: task actions toolbar ::::: */
+
+#calendar-add-task-button[disabled="true"] {
+ fill-opacity: 0.4;
+}
diff --git a/comm/calendar/base/themes/linux/calendar-unifinder.css b/comm/calendar/base/themes/linux/calendar-unifinder.css
new file mode 100644
index 0000000000..722027e897
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar-unifinder.css
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-unifinder.css);
+
+/* restyle splitter-border to match Thunderbird's layout */
+#calendar-view-splitter {
+ appearance: none;
+ border-bottom: 1px solid var(--splitter-color);
+ /* splitter grip area */
+ height: 5px;
+ /* make only the splitter border visible */
+ margin-top: -5px;
+ /* because of the negative margin needed to make the splitter visible */
+ position: relative;
+ z-index: 10;
+}
+
+#bottom-events-box {
+ border-left: 1px solid ThreeDShadow;
+}
+
+/* added for new id ..... search box ..... */
+#unifinder-searchBox {
+ background-color: transparent;
+ border-bottom: 1px solid ThreeDShadow;
+ height: 37px;
+}
+
+.unifinder-closebutton {
+ margin-inline-end: 2px;
+}
diff --git a/comm/calendar/base/themes/linux/calendar-views.css b/comm/calendar/base/themes/linux/calendar-views.css
new file mode 100644
index 0000000000..a5af2bf37c
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar-views.css
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-views.css);
+
+.navigation-inner-box {
+ padding-top: 1px;
+}
diff --git a/comm/calendar/base/themes/linux/calendar.css b/comm/calendar/base/themes/linux/calendar.css
new file mode 100644
index 0000000000..6cb211f27c
--- /dev/null
+++ b/comm/calendar/base/themes/linux/calendar.css
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar.css);
+
+#calsidebar_splitter,
+#today-splitter {
+ appearance: none;
+}
+
+#calsidebar_splitter {
+ border-inline-start: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-end: -5px;
+}
+
+#today-splitter {
+ border-inline-start: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-end: -5px;
+}
+
+/* Calendar list rules */
+#calendar-panel {
+ padding-bottom: 5px;
+}
+
+/* Today pane button in status bar */
+#calendar-status-todaypane-button,
+#calendar-status-todaypane-button[checked="true"] {
+ padding: 0 2px 1px !important;
+}
+
+#calendar-status-todaypane-button[hideLabel] > stack {
+ margin-inline-start: 5px;
+}
+
+#calendar-status-todaypane-button > stack > .toolbarbutton-day-text {
+ margin-top: 4px;
+}
+
+/* shift the today pane button label up by one pixel to center it */
+#calendar-status-todaypane-button > .toolbarbutton-text {
+ margin: 0 0 1px !important;
+}
diff --git a/comm/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css b/comm/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css
new file mode 100644
index 0000000000..435d348aab
--- /dev/null
+++ b/comm/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-alarm-dialog.css);
diff --git a/comm/calendar/base/themes/linux/dialogs/calendar-event-dialog.css b/comm/calendar/base/themes/linux/dialogs/calendar-event-dialog.css
new file mode 100644
index 0000000000..372b2b5f7b
--- /dev/null
+++ b/comm/calendar/base/themes/linux/dialogs/calendar-event-dialog.css
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*--------------------------------------------------------------------
+ * Event dialog keep duration button
+ *-------------------------------------------------------------------*/
+
+#keepduration-button {
+ min-width: 18px;
+}
+
+#timezone-endtime {
+ margin-inline-start: 16px;
+}
+
+
+#todo-entrydate menulist[is="menulist-editable"][editable="true"],
+#todo-duedate menulist[is="menulist-editable"][editable="true"] {
+ margin-inline-end: 2.4em;
+}
+
+#todo-entrydate::part(dropmarker),
+#todo-duedate::part(dropmarker) {
+ display: block !important;
+}
+
+#percent-complete-textbox {
+ padding: 3px;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ appearance: none !important;
+ margin-inline-start: 3px;
+}
+
+#paragraphPopup > menuitem > .menu-text {
+ margin-inline-start: 6px !important;
+}
+
+#smileyPopup > menuitem > .menu-text {
+ margin-inline-start: 1px !important;
+}
diff --git a/comm/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css b/comm/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css
new file mode 100644
index 0000000000..8e1c074f73
--- /dev/null
+++ b/comm/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-invitations-dialog.css);
diff --git a/comm/calendar/base/themes/linux/imip.css b/comm/calendar/base/themes/linux/imip.css
new file mode 100644
index 0000000000..2cf4d4e7ca
--- /dev/null
+++ b/comm/calendar/base/themes/linux/imip.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/imip.css);
diff --git a/comm/calendar/base/themes/linux/jar.mn b/comm/calendar/base/themes/linux/jar.mn
new file mode 100644
index 0000000000..91df811f85
--- /dev/null
+++ b/comm/calendar/base/themes/linux/jar.mn
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar.jar:
+% skin calendar classic/1.0 %skin/classic/calendar/
+#include ../common/jar.inc.mn
+ skin/classic/calendar/calendar.css (calendar.css)
+ skin/classic/calendar/calendar-daypicker.css (calendar-daypicker.css)
+ skin/classic/calendar/calendar-task-tree.css (calendar-task-tree.css)
+ skin/classic/calendar/calendar-task-view.css (calendar-task-view.css)
+ skin/classic/calendar/calendar-unifinder.css (calendar-unifinder.css)
+ skin/classic/calendar/calendar-views.css (calendar-views.css)
+ skin/classic/calendar/calendar-alarm-dialog.css (dialogs/calendar-alarm-dialog.css)
+ skin/classic/calendar/calendar-event-dialog.css (dialogs/calendar-event-dialog.css)
+ skin/classic/calendar/calendar-invitations-dialog.css (dialogs/calendar-invitations-dialog.css)
+ skin/classic/calendar/imip.css (imip.css)
+ skin/classic/calendar/today-pane.css (today-pane.css)
+ skin/classic/calendar/widgets/calendar-widgets.css (widgets/calendar-widgets.css)
diff --git a/comm/calendar/base/themes/linux/moz.build b/comm/calendar/base/themes/linux/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/calendar/base/themes/linux/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/calendar/base/themes/linux/today-pane.css b/comm/calendar/base/themes/linux/today-pane.css
new file mode 100644
index 0000000000..9eff0fe34f
--- /dev/null
+++ b/comm/calendar/base/themes/linux/today-pane.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/today-pane.css);
+
+#today-pane-splitter {
+ appearance: none;
+}
+
+#today-pane-panel > .sidebar-header {
+ height: 33px;
+}
+
+.miniday-nav-buttons {
+ max-width: 19px;
+}
+
+#today-button {
+ max-width: none;
+}
+
+#miniday-dropdown-button > .toolbarbutton-menu-dropmarker {
+ appearance: none;
+}
+
+#todaypane-new-event-button[disabled="true"] > .toolbarbutton-icon {
+ opacity: 0.4;
+}
diff --git a/comm/calendar/base/themes/linux/widgets/calendar-widgets.css b/comm/calendar/base/themes/linux/widgets/calendar-widgets.css
new file mode 100644
index 0000000000..a1be903664
--- /dev/null
+++ b/comm/calendar/base/themes/linux/widgets/calendar-widgets.css
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/widgets/calendar-widgets.css);
+
+#task-tree-filtergroup {
+ padding-inline-start: 12px;
+}
+
+#calendar-list-pane #calendar-list richlistitem {
+ padding-inline-start: 14px;
+ padding-block: 2px;
+}
+
+.toolbarbutton-icon-begin {
+ margin-inline-end: 5px;
+}
+
+.toolbarbutton-icon-end {
+ margin-inline-start: 5px;
+}
diff --git a/comm/calendar/base/themes/moz.build b/comm/calendar/base/themes/moz.build
new file mode 100644
index 0000000000..6815736e8c
--- /dev/null
+++ b/comm/calendar/base/themes/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ DIRS += ["linux"]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ DIRS += ["osx"]
+else:
+ DIRS += ["windows"]
diff --git a/comm/calendar/base/themes/osx/calendar-daypicker.css b/comm/calendar/base/themes/osx/calendar-daypicker.css
new file mode 100644
index 0000000000..bfc44d0da5
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar-daypicker.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-daypicker.css);
+
+button.calendar-daypicker {
+ border-top: 1px solid #808080;
+ border-left: 1px solid #808080;
+}
+
+hbox:last-child > button.calendar-daypicker {
+ border-bottom: 1px solid #808080;
+}
+
+button.calendar-daypicker:last-child {
+ border-right: 1px solid #808080;
+}
diff --git a/comm/calendar/base/themes/osx/calendar-task-tree.css b/comm/calendar/base/themes/osx/calendar-task-tree.css
new file mode 100644
index 0000000000..7daffbd19e
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar-task-tree.css
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-tree.css);
+
+.calendar-task-tree-col-priority > .treecol-icon {
+ padding-inline-end: 1px;
+}
diff --git a/comm/calendar/base/themes/osx/calendar-task-view.css b/comm/calendar/base/themes/osx/calendar-task-view.css
new file mode 100644
index 0000000000..46a838c074
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar-task-view.css
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-view.css);
+
+#calendar-task-details-container {
+ background-color: ButtonFace;
+ padding-top: 2px;
+}
+
+#task-addition-box {
+ border-bottom-color: #bebebe
+}
+
+/* Hide the magnifying glass icon */
+#view-task-edit-field,
+#unifinder-task-edit-field {
+ margin-block: 0;
+ background-image: none;
+ padding-inline-start: 4px !important;
+}
+
+@media not (prefers-color-scheme: dark) {
+ #task-addition-box:-moz-window-inactive {
+ border-bottom-color: hsl(0, 0%, 85%);
+ background: -moz-mac-chrome-inactive;
+ }
+
+ #calendar-task-details-container:-moz-window-inactive {
+ background-color: -moz-mac-chrome-inactive;
+ }
+}
+
+#calendar-add-task-button[disabled="true"] {
+ fill-opacity: .5;
+}
+
+#calendar-add-task-button > .toolbarbutton-text {
+ margin-inline-start: 0;
+}
diff --git a/comm/calendar/base/themes/osx/calendar-unifinder.css b/comm/calendar/base/themes/osx/calendar-unifinder.css
new file mode 100644
index 0000000000..d0832ed7e3
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar-unifinder.css
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-unifinder.css);
+
+/* added for new id ..... search box ..... */
+#unifinder-searchBox {
+ border-bottom: 1px solid #bebebe;
+ height: 30px;
+}
+
+.unifinder-closebutton > .toolbarbutton-text {
+ display: none;
+}
+
+@media not (prefers-color-scheme: dark) {
+ #unifinder-searchBox:-moz-window-inactive {
+ border-bottom-color: hsl(0, 0%, 85%);
+ background-color: -moz-mac-chrome-inactive;
+ }
+}
diff --git a/comm/calendar/base/themes/osx/calendar-views.css b/comm/calendar/base/themes/osx/calendar-views.css
new file mode 100644
index 0000000000..5b55b38ca7
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar-views.css
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-views.css);
+
+calendar-event-column {
+ -moz-user-focus: normal;
+}
diff --git a/comm/calendar/base/themes/osx/calendar.css b/comm/calendar/base/themes/osx/calendar.css
new file mode 100644
index 0000000000..aa0b98a3e0
--- /dev/null
+++ b/comm/calendar/base/themes/osx/calendar.css
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar.css);
+
+#calendarContent:-moz-lwtheme {
+ text-shadow: none;
+}
+
+/* Calendar list rules */
+#calendar-panel {
+ padding-bottom: 5px;
+}
+
+/* iMIP notification bar */
+#imip-bar > image {
+ margin-inline-end: 8px;
+}
+
+#calsidebar_splitter {
+ border-inline-end: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-start: -2px;
+}
+
+/* Calendar sidebar background in calendar and task mode. */
+#calSidebar {
+ border-inline-end: 1px solid #8B8B8B;
+ margin-inline-end: -3px !important;
+}
+
+/* Today pane button in status bar */
+#calendar-status-todaypane-button,
+#calendar-status-todaypane-button[checked="true"] {
+ padding: 0 2px !important;
+}
+
+#calendar-status-todaypane-button > stack > .toolbarbutton-day-text {
+ margin-top: 5px;
+ margin-inline-end: 0;
+}
+
+/* shift the today pane button label up by one pixel to center it */
+#calendar-status-todaypane-button > .toolbarbutton-text {
+ margin: 0 6px 1px !important;
+}
+
+.calendar-splitter[orient="vertical"] {
+ border-bottom: 1px solid var(--splitter-color);
+ min-height: 0;
+ height: 5px;
+ background-color: transparent;
+ margin-top: -5px;
+ position: relative;
+ z-index: 10;
+}
+
+.calendar-sidebar-splitter {
+ background-image: none;
+ min-width: 3px;
+ width: 3px;
+}
+
+#today-splitter {
+ border-inline-start: 1px solid var(--splitter-color);
+ margin-inline-end: -4px;
+ position: relative;
+}
diff --git a/comm/calendar/base/themes/osx/dialogs/calendar-alarm-dialog.css b/comm/calendar/base/themes/osx/dialogs/calendar-alarm-dialog.css
new file mode 100644
index 0000000000..af2c826272
--- /dev/null
+++ b/comm/calendar/base/themes/osx/dialogs/calendar-alarm-dialog.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-alarm-dialog.css);
+
+.snooze-value-textbox {
+ padding-inline-end: 0;
+}
+
+.button-menu-dropmarker {
+ appearance: none;
+ list-style-image: var(--icon-nav-down-sm);
+ -moz-context-properties: stroke;
+ stroke: currentColor;
+ width: 12px;
+ height: 12px;
+}
diff --git a/comm/calendar/base/themes/osx/dialogs/calendar-event-dialog.css b/comm/calendar/base/themes/osx/dialogs/calendar-event-dialog.css
new file mode 100644
index 0000000000..72cf9507a9
--- /dev/null
+++ b/comm/calendar/base/themes/osx/dialogs/calendar-event-dialog.css
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*--------------------------------------------------------------------
+ * Event dialog keep duration button
+ *-------------------------------------------------------------------*/
+
+#timezone-endtime {
+ margin-inline-start: 15px;
+}
+
+hbox > #link-image-top {
+ margin-block: 1.2em -1.2em;
+}
+
+vbox > #link-image-bottom {
+ margin-block: -1.2em 1.2em;
+}
+
+#percent-complete-textbox {
+ padding: 2px;
+}
+
+.formatting-button > .toolbarbutton-menu-dropmarker {
+ margin-inline-start: 3px;
+}
diff --git a/comm/calendar/base/themes/osx/dialogs/calendar-invitations-dialog.css b/comm/calendar/base/themes/osx/dialogs/calendar-invitations-dialog.css
new file mode 100644
index 0000000000..8e1c074f73
--- /dev/null
+++ b/comm/calendar/base/themes/osx/dialogs/calendar-invitations-dialog.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-invitations-dialog.css);
diff --git a/comm/calendar/base/themes/osx/imip.css b/comm/calendar/base/themes/osx/imip.css
new file mode 100644
index 0000000000..73aadf2ac1
--- /dev/null
+++ b/comm/calendar/base/themes/osx/imip.css
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/imip.css);
+
+.invitation-table {
+ margin-top: 1em;
+ margin-bottom: 1em;
+}
diff --git a/comm/calendar/base/themes/osx/jar.mn b/comm/calendar/base/themes/osx/jar.mn
new file mode 100644
index 0000000000..2def3c648a
--- /dev/null
+++ b/comm/calendar/base/themes/osx/jar.mn
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar.jar:
+% skin calendar classic/1.0 %skin/classic/calendar/ os=Darwin
+#include ../common/jar.inc.mn
+ skin/classic/calendar/calendar.css (calendar.css)
+ skin/classic/calendar/calendar-daypicker.css (calendar-daypicker.css)
+ skin/classic/calendar/calendar-task-tree.css (calendar-task-tree.css)
+ skin/classic/calendar/calendar-task-view.css (calendar-task-view.css)
+ skin/classic/calendar/calendar-unifinder.css (calendar-unifinder.css)
+ skin/classic/calendar/calendar-views.css (calendar-views.css)
+ skin/classic/calendar/calendar-alarm-dialog.css (dialogs/calendar-alarm-dialog.css)
+ skin/classic/calendar/calendar-event-dialog.css (dialogs/calendar-event-dialog.css)
+ skin/classic/calendar/calendar-invitations-dialog.css (dialogs/calendar-invitations-dialog.css)
+ skin/classic/calendar/imip.css (imip.css)
+ skin/classic/calendar/today-pane.css (today-pane.css)
+ skin/classic/calendar/widgets/calendar-widgets.css (widgets/calendar-widgets.css)
diff --git a/comm/calendar/base/themes/osx/moz.build b/comm/calendar/base/themes/osx/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/calendar/base/themes/osx/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/calendar/base/themes/osx/today-pane.css b/comm/calendar/base/themes/osx/today-pane.css
new file mode 100644
index 0000000000..79c710f5e6
--- /dev/null
+++ b/comm/calendar/base/themes/osx/today-pane.css
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/today-pane.css);
+
+#today-pane-panel > .sidebar-header {
+ height: 32px;
+}
+
+#today-pane-panel:-moz-lwtheme > .sidebar-header {
+ text-shadow: none;
+}
+
+.today-closebutton > .toolbarbutton-text {
+ display: none;
+}
+
+.monthlabel {
+ margin-bottom: 1px;
+}
+
+#today-pane-panel:not(:-moz-lwtheme):-moz-window-inactive {
+ background-color: hsl(0, 0%, 97%);
+}
+
+@media not (prefers-color-scheme: dark) {
+ #mini-day-image:-moz-window-inactive {
+ background-color: -moz-mac-chrome-inactive;
+ }
+}
+
+:root[lwt-tree] #mini-day-image:-moz-window-inactive {
+ background-color: transparent;
+}
+
+#todaypane-new-event-button[disabled="true"] > .toolbarbutton-icon {
+ opacity: .5;
+}
+
+#todaypane-new-event-button > .toolbarbutton-text {
+ margin-inline-start: 0;
+}
+
+#today-pane-splitter {
+ min-height: 5px;
+}
+
+#miniday-dropdown-button > .toolbarbutton-menu-dropmarker {
+ appearance: none;
+}
diff --git a/comm/calendar/base/themes/osx/widgets/calendar-widgets.css b/comm/calendar/base/themes/osx/widgets/calendar-widgets.css
new file mode 100644
index 0000000000..2151f1fa5d
--- /dev/null
+++ b/comm/calendar/base/themes/osx/widgets/calendar-widgets.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/widgets/calendar-widgets.css);
+
+#task-tree-filtergroup {
+ padding-inline-start: 18px;
+}
+
+#calendar-list-pane #calendar-list richlistitem {
+ padding-inline-start: 18px;
+ padding-block: 2px;
+}
+
+#calendar-list .calendar-color {
+ /* Shift the color box closer to the name label */
+ margin-inline-start: 1px;
+}
diff --git a/comm/calendar/base/themes/windows/calendar-daypicker.css b/comm/calendar/base/themes/windows/calendar-daypicker.css
new file mode 100644
index 0000000000..a53eb8c612
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar-daypicker.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-daypicker.css);
+
+button.calendar-daypicker {
+ border-top: 1px solid ThreeDShadow;
+ border-left: 1px solid ThreeDShadow;
+}
+
+hbox:last-child > button.calendar-daypicker {
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+button.calendar-daypicker:last-child {
+ border-right: 1px solid ThreeDShadow;
+}
diff --git a/comm/calendar/base/themes/windows/calendar-task-tree.css b/comm/calendar/base/themes/windows/calendar-task-tree.css
new file mode 100644
index 0000000000..dd23a40fb5
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar-task-tree.css
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-tree.css);
+
+.calendar-task-tree-col-priority > .treecol-icon {
+ padding-inline-end: 1px;
+}
+
+/* Use on Win7 and up default theme a dark text color when selected focus */
+@media (-moz-windows-default-theme) {
+ .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ border-color: green !important;
+ }
+
+ :root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ border-color: #00bd00 !important;
+ }
+
+ .calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress, selected, focus),
+ :root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress, selected, focus) {
+ color: white !important;
+ }
+
+ .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ border-color: red !important;
+ }
+
+ :root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ border-color: #ff7a7a !important;
+ }
+
+ .calendar-task-tree > treechildren::-moz-tree-image(overdue, selected, focus),
+ .calendar-task-tree > treechildren::-moz-tree-cell-text(overdue, selected, focus),
+ :root[lwt-tree-brighttext] .calendar-task-tree > treechildren::-moz-tree-cell-text(overdue, selected, focus) {
+ color: white !important;
+ }
+
+ @media (-moz-platform: windows-win7) {
+ .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ background: linear-gradient(rgba(0, 128, 0, .28), rgba(0, 128, 0, .5));
+ }
+
+ .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ background: linear-gradient(rgba(255, 0, 0, .28), rgba(255, 0, 0, .5));
+ }
+ }
+
+ @media (-moz-platform: windows-win8),
+ (-moz-platform: windows-win10) {
+ .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) {
+ background: linear-gradient(rgba(0, 128, 0, .5), rgba(0, 128, 0, .5));
+ }
+
+ .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) {
+ background: linear-gradient(rgba(255, 0, 0, .5), rgba(255, 0, 0, .5));
+ }
+ }
+}
diff --git a/comm/calendar/base/themes/windows/calendar-task-view.css b/comm/calendar/base/themes/windows/calendar-task-view.css
new file mode 100644
index 0000000000..b1ee37702c
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar-task-view.css
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-task-view.css);
+
+#calendar-task-details-container {
+ padding-top: 0;
+}
+
+#task-text-filter-field {
+ margin-block: 5px;
+}
+
+#task-text-filter-field .textbox-search-icons {
+ margin-bottom: -1px;
+}
+
+/* ::::: task actions toolbar ::::: */
+
+#calendar-add-task-button[disabled="true"] {
+ fill-opacity: 0.4;
+}
+
+#view-task-edit-field,
+#task-text-filter-field {
+ width: 15em;
+}
+
+#calendar-task-box #calendar-task-view-splitter {
+ border-width: 0;
+ border-bottom: 1px solid var(--splitter-color);
+ min-height: 0;
+ height: 5px;
+ background-color: transparent;
+ margin-top: -5px;
+ position: relative;
+ z-index: 10;
+}
+
+#task-addition-box {
+ height: 35px;
+}
+
+@media not (prefers-color-scheme: dark) {
+ @media (-moz-windows-default-theme) {
+ #task-addition-box {
+ background-color: #f8f8f8;
+ }
+ }
+}
+
+@media (-moz-platform: windows-win7) {
+ #view-task-edit-field,
+ #task-text-filter-field {
+ margin-block: 4px;
+ }
+}
diff --git a/comm/calendar/base/themes/windows/calendar-unifinder.css b/comm/calendar/base/themes/windows/calendar-unifinder.css
new file mode 100644
index 0000000000..06f75998cd
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar-unifinder.css
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-unifinder.css);
+
+#bottom-events-box {
+ border-inline-start: 1px solid ThreeDShadow;
+}
+
+#unifinder-searchBox {
+ height: 35px;
+ border-bottom: 1px solid ThreeDShadow;
+}
+
+.unifinder-closebutton {
+ margin-inline-end: 2px;
+}
+
+@media not (prefers-color-scheme: dark) {
+ @media (-moz-windows-default-theme) {
+ #unifinder-searchBox {
+ background-color: #f8f8f8;
+ }
+ }
+}
diff --git a/comm/calendar/base/themes/windows/calendar-views.css b/comm/calendar/base/themes/windows/calendar-views.css
new file mode 100644
index 0000000000..7e6eb9eefc
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar-views.css
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar-views.css);
+
+#calendar-view-box #calendar-view-splitter {
+ border: none;
+ border-bottom: 1px solid var(--splitter-color);
+ min-height: 0;
+ height: 5px;
+ background-color: transparent;
+ margin-top: -5px;
+ position: relative;
+ z-index: 10;
+}
diff --git a/comm/calendar/base/themes/windows/calendar.css b/comm/calendar/base/themes/windows/calendar.css
new file mode 100644
index 0000000000..ffccb9513e
--- /dev/null
+++ b/comm/calendar/base/themes/windows/calendar.css
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/calendar.css);
+
+#calsidebar_splitter {
+ border-inline-start: 1px solid var(--splitter-color);
+ /* make only the splitter border visible */
+ margin-inline-end: -5px;
+}
+
+/* Calendar list rules */
+#calendar-panel {
+ padding-bottom: 5px;
+}
+
+/* Today pane button in status bar */
+#calendar-status-todaypane-button,
+#calendar-status-todaypane-button[checked="true"] {
+ padding: 0 2px !important;
+}
+
+#calendar-status-todaypane-button[hideLabel] > stack {
+ margin-top: -2px;
+ margin-inline-start: 5px;
+}
+
+#calendar-status-todaypane-button > stack > .toolbarbutton-day-text {
+ margin-top: 5px;
+}
+
+/* shift the today pane button label up by one pixel to center it */
+#calendar-status-todaypane-button > .toolbarbutton-text {
+ margin: 0 0 1px !important;
+}
+
+#calsidebar_splitter,
+#today-splitter {
+ min-width: 0;
+ background-color: transparent;
+}
+
+#today-splitter {
+ border-inline-end: 1px solid var(--splitter-color);
+ margin-inline-start: -5px;
+ position: relative;
+}
+
+#today-splitter.calendar-sidebar-splitter:-moz-lwtheme {
+ background-image: none;
+}
diff --git a/comm/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css b/comm/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css
new file mode 100644
index 0000000000..fd43d36911
--- /dev/null
+++ b/comm/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-alarm-dialog.css);
+
+.snooze-value-textbox {
+ padding: 0;
+}
+
+@media not (-moz-windows-non-native-menus) {
+ menupopup[is="calendar-snooze-popup"] {
+ color-scheme: unset !important;
+ }
+}
+
+menupopup[is="calendar-snooze-popup"] > menuitem {
+ padding: 2px 5px;
+}
diff --git a/comm/calendar/base/themes/windows/dialogs/calendar-event-dialog.css b/comm/calendar/base/themes/windows/dialogs/calendar-event-dialog.css
new file mode 100644
index 0000000000..3d9e3b16d4
--- /dev/null
+++ b/comm/calendar/base/themes/windows/dialogs/calendar-event-dialog.css
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#item-calendar > menupopup > menuitem .menu-iconic-left,
+#calendar-ics-file-dialog-calendar-menu > menupopup > menuitem .menu-iconic-left {
+ margin-inline-end: 6px;
+}
+
+#item-categories > menupopup > menuitem .menu-iconic-icon {
+ display: inline;
+}
+
+/*--------------------------------------------------------------------
+ * Event dialog keep duration button
+ *-------------------------------------------------------------------*/
+
+#keepduration-button {
+ min-width: 18px;
+}
+
+#timezone-endtime {
+ margin-inline-start: 16px;
+}
+
+#percent-complete-textbox {
+ padding: 2px;
+}
+
+@media not (-moz-windows-non-native-menus) {
+ #smileyPopup > menuitem > .menu-text,
+ #paragraphPopup > menuitem > .menu-text {
+ margin-inline-start: -1.45em !important;
+ }
+}
+
+@media (-moz-windows-non-native-menus) {
+ #smileyPopup > menuitem,
+ #paragraphPopup > menuitem {
+ padding-inline-start: 14px;
+ }
+}
diff --git a/comm/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css b/comm/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css
new file mode 100644
index 0000000000..8e1c074f73
--- /dev/null
+++ b/comm/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/dialogs/calendar-invitations-dialog.css);
diff --git a/comm/calendar/base/themes/windows/imip.css b/comm/calendar/base/themes/windows/imip.css
new file mode 100644
index 0000000000..2cf4d4e7ca
--- /dev/null
+++ b/comm/calendar/base/themes/windows/imip.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/imip.css);
diff --git a/comm/calendar/base/themes/windows/jar.mn b/comm/calendar/base/themes/windows/jar.mn
new file mode 100644
index 0000000000..24fddf02fb
--- /dev/null
+++ b/comm/calendar/base/themes/windows/jar.mn
@@ -0,0 +1,19 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar.jar:
+% skin calendar classic/1.0 %skin/classic/calendar/ os=WINNT
+#include ../common/jar.inc.mn
+ skin/classic/calendar/calendar.css (calendar.css)
+ skin/classic/calendar/calendar-daypicker.css (calendar-daypicker.css)
+ skin/classic/calendar/calendar-task-tree.css (calendar-task-tree.css)
+ skin/classic/calendar/calendar-task-view.css (calendar-task-view.css)
+ skin/classic/calendar/calendar-unifinder.css (calendar-unifinder.css)
+ skin/classic/calendar/calendar-views.css (calendar-views.css)
+ skin/classic/calendar/calendar-alarm-dialog.css (dialogs/calendar-alarm-dialog.css)
+ skin/classic/calendar/calendar-event-dialog.css (dialogs/calendar-event-dialog.css)
+ skin/classic/calendar/calendar-invitations-dialog.css (dialogs/calendar-invitations-dialog.css)
+ skin/classic/calendar/imip.css (imip.css)
+ skin/classic/calendar/today-pane.css (today-pane.css)
+ skin/classic/calendar/widgets/calendar-widgets.css (widgets/calendar-widgets.css)
diff --git a/comm/calendar/base/themes/windows/moz.build b/comm/calendar/base/themes/windows/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/calendar/base/themes/windows/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/calendar/base/themes/windows/today-pane.css b/comm/calendar/base/themes/windows/today-pane.css
new file mode 100644
index 0000000000..8473cd1b15
--- /dev/null
+++ b/comm/calendar/base/themes/windows/today-pane.css
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/today-pane.css);
+
+#today-pane-splitter {
+ border-top-width: 0;
+ min-height: 0;
+ background-color: transparent;
+}
+
+@media (-moz-platform: windows-win8) and (-moz-windows-default-theme),
+ (-moz-platform: windows-win10) and (-moz-windows-default-theme) {
+ #today-pane-panel:not(:-moz-lwtheme) {
+ --chrome-content-separator-color: #c2c2c2;
+ }
+}
+
+#today-pane-panel > .sidebar-header {
+ height: 35px;
+}
+
+@media (-moz-platform: windows-win7) {
+ #today-pane-panel > .sidebar-header {
+ box-shadow: 0 1px 0 rgba(253, 253, 253, 0.45) inset;
+ }
+}
+
+#today-none-box {
+ border-top: 1px solid ThreeDShadow;
+}
+
+#todaypane-new-event-button[disabled="true"] > .toolbarbutton-icon {
+ opacity: 0.4;
+}
+
+@media (-moz-windows-default-theme) {
+ .sidebar-header > spacer {
+ min-height: 25px;
+ }
+}
+
+@media all and (-moz-windows-compositor) {
+ @media not all and (-moz-platform: windows-win10) {
+ #messengerWindow[sizemode=normal] #today-pane-panel {
+ border-inline-end: 1px solid hsla(240, 5%, 5%, .3);
+ border-bottom: 1px solid hsla(240, 5%, 5%, .3);
+ background-clip: padding-box;
+ }
+ }
+
+ .today-pane-cycler {
+ margin-top: -1px;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme #agenda {
+ list-style: none;
+ --selected-background: var(--dark-lwt-highlight-color);
+ --selected-foreground: var(--lwt-text-color);
+ }
+}
diff --git a/comm/calendar/base/themes/windows/widgets/calendar-widgets.css b/comm/calendar/base/themes/windows/widgets/calendar-widgets.css
new file mode 100644
index 0000000000..b2cce88ebc
--- /dev/null
+++ b/comm/calendar/base/themes/windows/widgets/calendar-widgets.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url(chrome://calendar/skin/shared/widgets/calendar-widgets.css);
+
+#task-tree-filtergroup {
+ padding-inline-start: 12px;
+}
+
+#calendar-list-pane #calendar-list richlistitem {
+ padding-inline-start: 14px;
+ padding-block: 2px;
+}
+
+.toolbarbutton-icon-begin {
+ margin-inline-end: 5px;
+}
+
+.toolbarbutton-icon-end {
+ margin-inline-start: 5px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:-moz-lwtheme #calendar-list > li.selected {
+ background-color: var(--dark-lwt-highlight-color);
+ color: var(--lwt-text-color);
+ }
+}
diff --git a/comm/calendar/extract/CalExtractParser.jsm b/comm/calendar/extract/CalExtractParser.jsm
new file mode 100644
index 0000000000..355fa3a6da
--- /dev/null
+++ b/comm/calendar/extract/CalExtractParser.jsm
@@ -0,0 +1,545 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "CalExtractToken",
+ "CalExtractParseNode",
+ "CalExtractParser",
+ "extendParseRule",
+ "prepareArguments",
+];
+
+/**
+ * CalExtractOptions holds configuration options used by CalExtractParser.
+ *
+ * @typedef {object} CalExtractOptions
+ * @property {RegExp} sentenceBoundary - A pattern used to split text at the
+ * sentence boundary. This should capture
+ * the boundary only and not any other
+ * part of the sentence. Use lookaheads
+ * if needed.
+ */
+
+/**
+ * @type {CalExtractOptions}
+ */
+const defaultOptions = {
+ sentenceBoundary: /(?<=\w)[.!?]+\s(?=[A-Z0-9])|[.!?]$/,
+};
+
+const FLAG_OPTIONAL = 1;
+const FLAG_MULTIPLE = 2;
+const FLAG_NONEMPTY = 4;
+
+const flagBits = new Map([
+ ["?", FLAG_OPTIONAL],
+ ["+", FLAG_MULTIPLE | FLAG_NONEMPTY],
+ ["*", FLAG_MULTIPLE | FLAG_OPTIONAL],
+]);
+
+/**
+ * CalExtractToken represents a lexical unit of valid text. These are produced
+ * during the tokenisation stage of CalExtractParser by matching regular
+ * expressions against a text sequence.
+ */
+class CalExtractToken {
+ /**
+ * Identifies the token. Should be in uppercase with no spaces for consistency.
+ *
+ * @type {string}
+ */
+ type = "";
+
+ /**
+ * The text captured by this token.
+ *
+ * @type {string[]}
+ */
+ text = [];
+
+ /**
+ * Indicates which sentence in the source text the token was found.
+ *
+ * @type {number}
+ */
+ sentence = -1;
+
+ /**
+ * Indicates the position with the sentence the token occurs.
+ *
+ * @type {number}
+ */
+ position = -1;
+
+ /**
+ * @param {string} type
+ * @param {string[]} text
+ * @param {number} sentence
+ * @param {number} position
+ */
+ constructor(type, text, sentence, position) {
+ this.type = type;
+ this.text = text;
+ this.sentence = sentence;
+ this.position = position;
+ }
+}
+
+/**
+ * Function used to produce a value when a CalExtractParseRule is matched.
+ *
+ * @callback CallExtractParseRuleAction
+ * @param {any[]} args - An array containing all the values produced from each
+ * pattern in the rule when they are matched or the
+ * CalExtractToken when lexical tokens are used instead.
+ */
+
+/**
+ * CalExtractParseRule specifies a named pattern that is looked for when parsing
+ * the tokenized source text. Patterns are a sequence of one or more CalExtactToken
+ * types or CalExtractParseRule names. Each pattern specified can optionally
+ * have one (and only one) of the following flags:
+ *
+ * 1) "?" - Optional flag, indicates a pattern may be skipped if not matched.
+ * 2) "*" - Multiple flag, indicates a pattern may match 0 or more times.
+ * 3) "+" - Non-empty multiple flag, indicates a pattern may match 1 or more times.
+ *
+ * Flags must be specified as the last character of the pattern name, example:
+ * ["subject", "text?", "MEET", "text*", "time+"]
+ *
+ * @typedef {object} CalExtractParseRule
+ *
+ * @property {string} name - The name of the rule that can
+ * be used in other patterns.
+ * Should be lowercase for
+ * consistency.
+ * @property {string[]} patterns - The pattern that will be
+ * searched for on the tokenized
+ * string. Can contain flags.
+ *
+ * @property {CalExtractParseRuleAction} action - Produces the result of the
+ * rule being satisfied.
+ */
+
+/**
+ * CalExtractExtParseRule is derived from a CalExtractParseRule to include
+ * additional information needed during parsing.
+ *
+ * @typedef {CalExtractParseRule} CalExtractExtParseRule
+ *
+ * @property {string[]} patterns - The patterns here are stripped of
+ * any flags.
+ * @property {number[]} flags - An array containing the flags
+ * specified for each patterns element.
+ * @property {CalExtractParseNode} graph - A graph used to determine what parse
+ * rule can be applied for an encountered
+ * production.
+ */
+
+/**
+ * CalExtractParseNode is used to represent the patterns of a CalExtractParseRule
+ * as a graph. This graph is traversed during stack reduction until one of the
+ * following end conditions are met:
+ *
+ * 1) There are no more descendant nodes.
+ * 2) The only descendant node is the node itself (cyclic).
+ * 3) All of the descendant nodes are optional, there are no more tokens to
+ * shift and we have traversed the entire stack.
+ */
+class CalExtractParseNode {
+ /**
+ * @type {string}
+ */
+ symbol = null;
+
+ /**
+ * @type {number}
+ */
+ flags = null;
+
+ /**
+ * Contains each possible descendant node of this node.
+ *
+ * @type {CalExtractParseNode[]}
+ */
+ descendants = null;
+
+ static FLAG_OPTIONAL = FLAG_OPTIONAL;
+ static FLAG_MULTIPLE = FLAG_MULTIPLE;
+ static FLAG_NONEMPTY = FLAG_NONEMPTY;
+
+ /**
+ * @param {string} symbol - The pattern this node represents.
+ * @param {number} flags - The computed flags assigned to the pattern.
+ * @param {CalExtractParseNode[]} descendants - Descendant nodes of this node.
+ */
+ constructor(symbol, flags, descendants = []) {
+ this.symbol = symbol;
+ this.flags = flags;
+ this.descendants = descendants;
+ }
+
+ /**
+ * Indicates this is the last node in its graph. This will always be false
+ * for cyclic nodes.
+ */
+ get isEnd() {
+ return !this.descendants.length;
+ }
+
+ /**
+ * Appends a new descendant to this node.
+ *
+ * @param {CalExtractParseNode} node - The node to append.
+ *
+ * @returns {CalExtractParseNode} The appended node.
+ */
+ append(node) {
+ this.descendants.push(node);
+ return node;
+ }
+
+ /**
+ * Provides the descendant CalExtractParseNode of this one given its symbol
+ * name. The result depends on the following rules:
+ * 1) If this node has a descendant that matches the name, return that node.
+ * 2) If the node does not have a matching descendant but has descendants
+ * with the optional flag set, delegate to those nodes. This implements
+ * the "?" and optional aspect of "*".
+ * 3) If none of the above produce a node, null is returned which means this
+ * graph cannot be traversed any further.
+ *
+ * @returns {CalExtractParseNode|null}
+ */
+ getDescendant(name) {
+ // It is important the direct descendants are checked first.
+ let node = this.descendants.find(node => node.symbol == name);
+ if (node) {
+ return node;
+ }
+
+ // Now try any optional descendants.
+ for (let node of this.descendants) {
+ let hit = node.isOptional() && node != this && node.getDescendant(name);
+ if (hit) {
+ return hit;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Indicates this node can terminate the graph if so desired. This is acceptable
+ * if all of the descendants of this node are optional and there is nothing
+ * more to match on the stack.
+ *
+ * @returns {boolean}
+ */
+ canEnd() {
+ return this.descendants.filter(desc => desc != this).every(desc => desc.isOptional());
+ }
+
+ /**
+ * Indicates whether this node has the optional flag set.
+ *
+ * @returns {boolean}
+ */
+ isOptional() {
+ return Boolean(this.flags & FLAG_OPTIONAL);
+ }
+
+ /**
+ * Indicates whether this node is cyclic. A cyclic node is one whose only
+ * descendants is itself thus creating a loop. This occurs one a multiple
+ * flagged node is in the tail position of the graph.
+ */
+ isCyclic() {
+ return !this.isEnd && this.descendants.every(desc => desc == this);
+ }
+}
+
+/**
+ * CalExtractParser provides an API for detecting interesting information within
+ * a text sequence that can be used for event detection and creation. It is a
+ * naive implementation of a shift-reduce parser, naive in the sense that not
+ * too much attention has been paid to optimisation or semantics.
+ *
+ * This parser works by first splitting the source string into sentences, then
+ * tokenizing each using the token rules specified. The boundary for splitting
+ * into sentences can be specified in the options object.
+ *
+ * After tokenisation, the parser uses the parse rules to shift/reduce each
+ * sentence into a final result. The first parse rule is treated as the intended
+ * rule to reduce the tokens of each sentence to. If all of the tokens have been
+ * processed and the result is not the first rule, parsing is considered to have
+ * failed and null is returned for that sentence. For this reason, it is a good
+ * idea to specify parse rules that are robust but not too specific in their
+ * patterns.
+ */
+class CalExtractParser {
+ /**
+ * @type {[RegExp,string?][]}
+ */
+ tokenRules = [];
+
+ /**
+ * @type {CalExtractParseRule[]}
+ */
+ parseRules = [];
+
+ /**
+ * @type {CalExtractOptions}
+ */
+ options = null;
+
+ /**
+ * Use the static createInstance() method instead of this constructor directly.
+ *
+ * @param {[RegExp, string?][]} tokenRules
+ * @param {CalExtractExtParseRule[]} parseRules
+ * @param {CalExtractOptions} [options] - Configuration object.
+ *
+ * @private
+ */
+ constructor(tokenRules, parseRules, options = defaultOptions) {
+ this.tokenRules = tokenRules;
+ this.parseRules = parseRules;
+ this.options = options;
+ }
+
+ /**
+ * This method creates a new CalExtractParser instance using the simpler
+ * CalExtractParseRule interface instead of the extended one. It takes care
+ * of creating a graph for each rule and normalizing pattern names that may
+ * be using flags.
+ *
+ * @param {[RegExp, string?][]} tokenRules - A list of rules to apply during
+ * tokenisation. The first element of each rule is a regular expression used
+ * to detect lexical tokens and the second element is the type to assign to
+ * the token. Order matters slightly here, in general, more complex but specific
+ * rules should appear before simpler, more general ones.
+ *
+ * When specifying token rules, they should be anchored to the start of the
+ * string via "^" or tokenize() will produce unexpected results. Some regular
+ * expressions should also include a word boundary to prevent matching within
+ * a large string, example: "at" in "attachment". If a string is to be matched,
+ * but no token is desired you can omit the token type from the rule and it
+ * will be omitted completely.
+ *
+ * @param {CalExtractParseRule[]} parseRules - A list of CalExtractParseRules
+ * that will be extended then used during parsing. Multiple parse rules can
+ * share the same name and will all be considered the same when matching patterns.
+ * Use this to specify variations of the same rule.
+ *
+ * @param {CalExtractOptions} [options] - Configuration object.
+ */
+ static createInstance(tokenRules, parseRules, options = defaultOptions) {
+ return new CalExtractParser(tokenRules, parseRules.map(extendParseRule), options);
+ }
+
+ /**
+ * Tokenizes a string to make it easier to match against the parse rule
+ * patterns. If text is encountered that cannot be tokenized, the result for
+ * that sentence is null.
+ *
+ * @param {string} str - The string to tokenize.
+ *
+ * @returns {CalExtractToken[][]} For each sentence encountered, a list of
+ * CalExtractTokens.
+ */
+ tokenize(str) {
+ let allTokens = [];
+ let sentences = str.split(this.options.sentenceBoundary).filter(Boolean);
+
+ for (let i = 0; i < sentences.length; i++) {
+ let sentence = sentences[i];
+ let pos = 0;
+ let tokens = [];
+ let buffer = "";
+
+ let matched;
+ while (pos < sentence.length) {
+ buffer = sentence.substr(pos);
+ for (let [pattern, type] of this.tokenRules) {
+ matched = pattern.exec(buffer);
+ if (matched) {
+ if (type) {
+ tokens.push(new CalExtractToken(type, matched[0], i, pos));
+ }
+ pos += matched[0].length;
+ break;
+ }
+ }
+
+ if (!matched) {
+ // No rules for the encountered text, bail out.
+ tokens = null;
+ break;
+ }
+ }
+ allTokens.push(tokens);
+ }
+ return allTokens;
+ }
+
+ /**
+ * Parses a string into an array of values representing the final result of
+ * parsing each sentence encountered. The elements of the resulting array
+ * are either the result of applying the action of the first (top) parse rule
+ * or null if we could not successfully parse the sentence.
+ *
+ * @param {string} str
+ *
+ * @returns {any[]}
+ */
+ parse(str) {
+ return this.tokenize(str).map(tokens => {
+ if (!this.parseRules.length || !tokens) {
+ return null;
+ }
+
+ let lookahead = null;
+ let stack = [];
+ while (true) {
+ if (tokens.length) {
+ let next = tokens.shift();
+ stack.push([next.type, next]);
+ lookahead = tokens[0] ? tokens[0].type : null;
+ while (this.reduceStack(stack, lookahead)) {
+ continue;
+ }
+ } else {
+ // Attempt to reduce anything still on the stack now that the
+ // tokens have all been pushed.
+ while (this.reduceStack(stack, lookahead)) {
+ continue;
+ }
+ break;
+ }
+ }
+ return stack.length == 1 && stack[0][0] == this.parseRules[0].name ? stack[0][1] : null;
+ });
+ }
+
+ /**
+ * Attempts to reduce the given stack exactly once using the internal parsing
+ * rules. If successful, the stack will be modified to contain the matched
+ * rule at the location it was found. This methods modifies the stack given.
+ *
+ * @returns {boolean} - True if the stack was reduced false if otherwise.
+ */
+ reduceStack(stack, lookahead) {
+ for (let i = 0; i < stack.length; i++) {
+ for (let rule of this.parseRules) {
+ let node = rule.graph;
+ let n = i;
+ let matchCount = 0;
+ while (n < stack.length && (node = node.getDescendant(stack[n][0]))) {
+ matchCount++;
+ if (
+ node.isEnd ||
+ (n == stack.length - 1 && !lookahead && (node.isCyclic() || node.canEnd()))
+ ) {
+ let result = [rule.name, null];
+ let matched = stack.splice(i, matchCount, result);
+ result[1] = rule.action(prepareArguments(rule, matched));
+ return true;
+ }
+ n++;
+ }
+ }
+ }
+ return false;
+ }
+}
+
+/**
+ * Converts a CalExtractParseRule to a CalExtractExtParseRule.
+ *
+ * @param {CalExtractParseRule} rule
+ *
+ * @returns {CalExtractExtParseRule}
+ */
+function extendParseRule(rule) {
+ let { name, action } = rule;
+ let flags = [];
+ let patterns = [];
+ let start = new CalExtractParseNode(null, null);
+ let graph = start;
+
+ for (let pattern of rule.patterns) {
+ let patternFlag = pattern[pattern.length - 1];
+ let bits = 0;
+
+ // Compute the flag value.
+ for (let [flag, value] of flagBits) {
+ if (patternFlag == flag) {
+ bits = bits | value;
+ }
+ }
+
+ // Removes the flag from patterns that have them.
+ pattern = bits ? pattern.substring(0, pattern.length - 1) : pattern;
+ patterns.push(pattern);
+ graph = graph.append(new CalExtractParseNode(pattern, bits));
+
+ // Create a loop node if this flag is set.
+ if (bits & FLAG_MULTIPLE) {
+ graph.append(graph);
+ }
+
+ flags.push(bits);
+ }
+
+ return {
+ name,
+ action,
+ patterns,
+ flags,
+ graph: start,
+ };
+}
+
+/**
+ * Normalizes the matched arguments to be passed to an CalExtractParseRuleAction
+ * by ensuring the number is the same as the patterns for the action. This takes
+ * care of converting multi matches into an array and providing "null" when
+ * an optional pattern is unmatched.
+ *
+ * @param {CalExtractExtRule} rule - The rule the action belongs to.
+ * @param {string[]} matched - An sub-array of the stack containing what
+ * was actually matched. This array will be
+ * modified to match the full rule (inclusive
+ * of optional patterns).
+ *
+ *
+ * @returns {Array} Arguments for a CalExtractParseRuleAction.
+ */
+function prepareArguments(rule, matched) {
+ return rule.patterns.map((pattern, index) => {
+ if (rule.flags[index] & FLAG_MULTIPLE) {
+ let c = index;
+ let arrayArg = [];
+
+ while (c < matched.length && matched[c][0] == pattern) {
+ arrayArg.push(matched[c][1]);
+ c++;
+ }
+ if (!arrayArg.length) {
+ // This rule was not matched, make a blank space for it.
+ matched.splice(index, 0, null);
+ } else {
+ // Move all the matches into a single element so we match the pattern.
+ matched.splice(index, arrayArg.length, arrayArg);
+ }
+ return arrayArg;
+ } else if (matched[index] && matched[index][0] == pattern) {
+ return matched[index][1];
+ }
+
+ // The pattern was unmatched, it should be optional.
+ matched.splice(index, 0, null);
+ return null;
+ });
+}
diff --git a/comm/calendar/extract/CalExtractParserService.jsm b/comm/calendar/extract/CalExtractParserService.jsm
new file mode 100644
index 0000000000..aa1ca38e1f
--- /dev/null
+++ b/comm/calendar/extract/CalExtractParserService.jsm
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalExtractParserService"];
+
+const { CalExtractParser } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+const defaultRules = [
+ [
+ // Start clean up patterns.
+
+ // remove last line preceding quoted message and first line of the quote
+ [/^(\r?\n[^>].*\r?\n>+.*$)/, ""],
+
+ // remove the rest of quoted content
+ [/^(>+.*$)/, ""],
+
+ // urls often contain dates dates that can confuse extraction
+ [/^(https?:\/\/[^\s]+\s)/, ""],
+ [/^(www\.[^\s]+\s)/, ""],
+
+ // remove phone numbers
+ [/^(\d-\d\d\d-\d\d\d-\d\d\d\d)/, ""],
+
+ // remove standard signature
+ [/^(\r?\n-- \r?\n[\S\s]+$)/, ""],
+
+ // XXX remove timezone info, for now
+ [/^(gmt[+-]\d{2}:\d{2})/i, ""],
+
+ // End clean up patterns.
+
+ [/^meet\b/i, "MEET"],
+ [/^(we will|we'll|we)\b/i, "WE"],
+
+ // Meridiem
+ [/^(a[.]?m[.]?)/i, "AM"],
+ [/^(p[.]?m[.]?)/i, "PM"],
+
+ [/^(hours|hour|hrs|hr)\b/i, "HOURS"],
+ [/^(minutes|min|mins)\b/i, "MINUTES"],
+ [/^(days|day)\b/i, "DAYS"],
+
+ // Words commonly used when specifying begin/end time or duration.
+ [/^at\b/i, "AT"],
+ [/^until\b/i, "UNTIL"],
+ [/^for\b/i, "FOR"],
+
+ // Units of time
+ [/^(((0|1)?[0-9])|(2[0-4]))\b/, "HOUR_VALUE"],
+
+ [/^\d+\b/, "NUMBER"],
+
+ // Any text we don't know the meaning of.
+ [/^\S+/, "TEXT"],
+
+ // Whitespace
+ [/^\s+/, ""],
+ ],
+ [
+ {
+ name: "event-guess",
+ patterns: ["subject", "meet", "start-time", "text*", "end-time"],
+ action: ([, , startTime, , endTime]) => ({
+ type: "event-guess",
+ startTime,
+ endTime,
+ priority: 0,
+ }),
+ },
+ {
+ name: "event-guess",
+ patterns: ["subject", "meet", "start-time", "text*", "duration-time"],
+ action: ([, , startTime, , endTime]) => ({
+ type: "event-guess",
+ startTime,
+ endTime,
+ priority: 0,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject: subject.text,
+ }),
+ },
+ {
+ name: "start-time",
+ patterns: ["start-time-prefix", "meridiem-time"],
+ action: ([, time]) => time,
+ },
+ {
+ name: "start-time-prefix",
+ patterns: ["AT"],
+ action: ([prefix]) => prefix.text,
+ },
+ {
+ name: "end-time",
+ patterns: ["end-time-prefix", "meridiem-time"],
+ action: ([, time]) => time,
+ },
+ {
+ name: "end-time-prefix",
+ patterns: ["UNTIL"],
+ action: ([prefix]) => prefix.text,
+ },
+ {
+ name: "meridiem-time",
+ patterns: ["HOUR_VALUE", "meridiem"],
+ action: ([hour, meridiem]) => ({
+ type: "meridiem-time",
+ hour: Number(hour.text),
+ meridiem,
+ }),
+ },
+ {
+ name: "meridiem",
+ patterns: ["AM"],
+ action: () => "am",
+ },
+ {
+ name: "meridiem",
+ patterns: ["PM"],
+ action: () => "pm",
+ },
+
+ {
+ name: "duration-time",
+ patterns: ["duration-prefix", "duration"],
+ action: ([, duration]) => ({
+ type: "duration-time",
+ duration,
+ }),
+ },
+ {
+ name: "duration-prefix",
+ patterns: ["FOR"],
+ action: ([prefix]) => prefix.text,
+ },
+ {
+ name: "duration",
+ patterns: ["NUMBER", "MINUTES"],
+ action: ([value]) => Number(value.text),
+ },
+ {
+ name: "duration",
+ patterns: ["NUMBER", "HOURS"],
+ action: ([value]) => Number(value.text) * 60,
+ },
+ {
+ name: "duration",
+ patterns: ["NUMBER", "DAYS"],
+ action: ([value]) => Number(value.text) * 60 * 24,
+ },
+ {
+ name: "meet",
+ patterns: ["MEET"],
+ action: () => "meet",
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([text]) => text,
+ },
+ ],
+];
+
+/**
+ * CalExtractParserServiceContext represents the context parsing and extraction
+ * takes place in. It holds values used in various calculations. For example,
+ * the current date.
+ *
+ * @typedef {object} CalExtractParserServiceContext
+ * @property {Date} now - The Date to use when calculating start and relative
+ * times.
+ */
+
+/**
+ * CalExtractParserService provides a frontend to the CalExtractService.
+ * It holds lexical and parse rules for multiple locales (or any string
+ * identifier) that can be used on demand when parsing text.
+ */
+class CalExtractParserService {
+ rules = new Map([["en-US", defaultRules]]);
+
+ /**
+ * Parses and extract the most relevant event creation data based on the
+ * rules of the locale given.
+ *
+ * @param {string} source
+ * @param {CalExtractParserServiceContext} context
+ * @param {string} locale
+ */
+ extract(source, ctx = { now: new Date() }, locale = "en-US") {
+ let rules = this.rules.get(locale);
+ if (!rules) {
+ return null;
+ }
+
+ let [lex, parse] = rules;
+ let parser = CalExtractParser.createInstance(lex, parse);
+ let result = parser.parse(source).sort((a, b) => a - b)[0];
+ return result && convertDurationToEndTime(populateTimes(result, ctx.now));
+ }
+}
+
+/**
+ * Populates the missing values of the startTime and endTime.
+ *
+ * @param {object?} guess - The result of CalExtractParserService.extract().
+ * @param {Date} now - A Date object representing the contextual date and
+ * time.
+ *
+ * @returns {object} The result with the populated startTime and endTime.
+ */
+function populateTimes(guess, now) {
+ return populateTime(populateTime(guess, now, "startTime"), now, "endTime");
+}
+
+/**
+ * Populates the missing values of the specified time property based on the Date
+ * provided.
+ *
+ * @param {object?} guess
+ * @param {Date} now
+ * @param {string} prop
+ *
+ * @returns {object}
+ */
+function populateTime(guess, now, prop) {
+ let time = guess[prop];
+
+ if (!time) {
+ return guess;
+ }
+ if (time.hour && time.meridiem) {
+ time.hour = normalizeHour(time.hour, time.meridiem);
+ }
+
+ time.year = time.year || now.getFullYear();
+ time.month = time.month || now.getMonth() + 1;
+ time.day = time.day || now.getDay();
+ time.hour = time.hour || now.getHours();
+ time.minute = time.minute || now.getMinutes();
+ return guess;
+}
+
+/**
+ * Coverts an hour using the Meridiem to a 24 hour value.
+ *
+ * @param {number} hour - The hour value.
+ * @param {string} meridiem - "am" or "pm"
+ *
+ * @returns {number}
+ */
+function normalizeHour(hour, meridiem) {
+ if (meridiem == "am" && hour == 12) {
+ return hour - 12;
+ } else if (meridiem == "pm" && hour != 12) {
+ return hour + 12;
+ }
+
+ let dayStart = Services.prefs.getIntPref("calendar.view.daystarthour", 6);
+ if (hour < dayStart && hour <= 11) {
+ return hour + 12;
+ }
+
+ return hour;
+}
+
+/**
+ * Takes care of converting an end duration to an actual time relative to the
+ * start time detected (if any).
+ *
+ * @param {object} guess - Results from CalExtractParserService#extract()
+ *
+ * @returns {object} The result with the endTime property expanded.
+ */
+function convertDurationToEndTime(guess) {
+ if (guess.startTime && guess.endTime && guess.endTime.type == "duration-time") {
+ let startTime = guess.startTime;
+ let duration = guess.endTime.duration;
+ if (duration != 0) {
+ let startDate = new Date(startTime.year, startTime.month - 1, startTime.day);
+ if ("hour" in startTime) {
+ startDate.setHours(startTime.hour);
+ startDate.setMinutes(startTime.minute);
+ }
+
+ let endDate = new Date(startDate.getTime() + duration * 60 * 1000);
+ let endTime = { type: "date-time" };
+ endTime.year = endDate.getFullYear();
+ endTime.month = endDate.getMonth() + 1;
+ endTime.day = endDate.getDate();
+ if (endDate.getHours() != 0 || endDate.getMinutes() != 0) {
+ endTime.hour = endDate.getHours();
+ endTime.minute = endDate.getMinutes();
+ }
+ guess.endTime = endTime;
+ }
+ }
+ return guess;
+}
diff --git a/comm/calendar/extract/moz.build b/comm/calendar/extract/moz.build
new file mode 100644
index 0000000000..8f5514a49a
--- /dev/null
+++ b/comm/calendar/extract/moz.build
@@ -0,0 +1,9 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES.calendar.extract += [
+ "CalExtractParser.jsm",
+ "CalExtractParserService.jsm",
+]
diff --git a/comm/calendar/import-export/CalHtmlExport.jsm b/comm/calendar/import-export/CalHtmlExport.jsm
new file mode 100644
index 0000000000..82aeba59a7
--- /dev/null
+++ b/comm/calendar/import-export/CalHtmlExport.jsm
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalHtmlExporter"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * HTML Export Plugin
+ */
+function CalHtmlExporter() {
+ this.wrappedJSObject = this;
+}
+
+CalHtmlExporter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIExporter"]),
+ classID: Components.ID("{72d9ab35-9b1b-442a-8cd0-ae49f00b159b}"),
+
+ getFileTypes() {
+ let wildmat = "*.html; *.htm";
+ let label = cal.l10n.getCalString("filterHtml", [wildmat]);
+ return [
+ {
+ QueryInterface: ChromeUtils.generateQI(["calIFileType"]),
+ defaultExtension: "html",
+ extensionFilter: wildmat,
+ description: label,
+ },
+ ];
+ },
+
+ exportToStream(aStream, aItems, aTitle) {
+ let document = cal.xml.parseFile("chrome://calendar/content/printing/calHtmlExport.html");
+ let itemContainer = document.getElementById("item-container");
+ document.getElementById("title").textContent = aTitle || cal.l10n.getCalString("HTMLTitle");
+
+ // Sort aItems
+ aItems.sort((a, b) => {
+ let start_a = a[cal.dtz.startDateProp(a)];
+ if (!start_a) {
+ return -1;
+ }
+ let start_b = b[cal.dtz.startDateProp(b)];
+ if (!start_b) {
+ return 1;
+ }
+ return start_a.compare(start_b);
+ });
+
+ for (let item of aItems) {
+ let itemNode = document.getElementById("item-template").cloneNode(true);
+ itemNode.removeAttribute("id");
+
+ let setupTextRow = function (classKey, propValue, prefixKey) {
+ if (propValue) {
+ let prefix = cal.l10n.getCalString(prefixKey);
+ itemNode.querySelector("." + classKey + "key").textContent = prefix;
+ itemNode.querySelector("." + classKey).textContent = propValue;
+ } else {
+ let row = itemNode.querySelector("." + classKey + "row");
+ if (
+ row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE
+ ) {
+ row.nextSibling.remove();
+ }
+ row.remove();
+ }
+ };
+
+ let startDate = item[cal.dtz.startDateProp(item)];
+ let endDate = item[cal.dtz.endDateProp(item)];
+ if (startDate || endDate) {
+ // This is a task with a start or due date, format accordingly
+ let prefixWhen = cal.l10n.getCalString("htmlPrefixWhen");
+ itemNode.querySelector(".intervalkey").textContent = prefixWhen;
+
+ let startNode = itemNode.querySelector(".dtstart");
+ let dateString = cal.dtz.formatter.formatItemInterval(item);
+ startNode.setAttribute("title", startDate ? startDate.icalString : "none");
+ startNode.textContent = dateString;
+ } else {
+ let row = itemNode.querySelector(".intervalrow");
+ row.remove();
+ if (
+ row.nextSibling &&
+ (row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE)
+ ) {
+ row.nextSibling.remove();
+ }
+ }
+
+ let itemTitle = item.isCompleted
+ ? cal.l10n.getCalString("htmlTaskCompleted", [item.title])
+ : item.title;
+ setupTextRow("summary", itemTitle, "htmlPrefixTitle");
+
+ setupTextRow("location", item.getProperty("LOCATION"), "htmlPrefixLocation");
+ setupTextRow("description", item.getProperty("DESCRIPTION"), "htmlPrefixDescription");
+
+ itemContainer.appendChild(itemNode);
+ }
+
+ let templates = document.getElementById("templates");
+ templates.remove();
+
+ // Convert the javascript string to an array of bytes, using the utf8 encoder
+ let convStream = Cc["@mozilla.org/intl/converter-output-stream;1"].createInstance(
+ Ci.nsIConverterOutputStream
+ );
+ convStream.init(aStream, "UTF-8");
+ convStream.writeString(cal.xml.serializeDOM(document));
+ },
+};
diff --git a/comm/calendar/import-export/CalIcsImportExport.jsm b/comm/calendar/import-export/CalIcsImportExport.jsm
new file mode 100644
index 0000000000..30b5373c3c
--- /dev/null
+++ b/comm/calendar/import-export/CalIcsImportExport.jsm
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalIcsImporter", "CalIcsExporter"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+// Shared functions
+function getIcsFileTypes() {
+ return [
+ {
+ QueryInterface: ChromeUtils.generateQI(["calIFileType"]),
+ defaultExtension: "ics",
+ extensionFilter: "*.ics",
+ description: cal.l10n.getCalString("filterIcs", ["*.ics"]),
+ },
+ ];
+}
+
+function CalIcsImporter() {
+ this.wrappedJSObject = this;
+}
+
+CalIcsImporter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIImporter"]),
+ classID: Components.ID("{1e3e33dc-445a-49de-b2b6-15b2a050bb9d}"),
+
+ getFileTypes: getIcsFileTypes,
+
+ importFromStream(aStream) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseFromStream(aStream);
+ return parser.getItems();
+ },
+};
+
+function CalIcsExporter() {
+ this.wrappedJSObject = this;
+}
+
+CalIcsExporter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIExporter"]),
+ classID: Components.ID("{a6a524ce-adff-4a0f-bb7d-d1aaad4adc60}"),
+
+ getFileTypes: getIcsFileTypes,
+
+ exportToStream(aStream, aItems, aTitle) {
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems(aItems);
+ serializer.serializeToStream(aStream);
+ },
+};
diff --git a/comm/calendar/import-export/calHtmlExport.html b/comm/calendar/import-export/calHtmlExport.html
new file mode 100644
index 0000000000..920b3b412d
--- /dev/null
+++ b/comm/calendar/import-export/calHtmlExport.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title id="title"></title>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <style type="text/css" id="sheet">
+ .vevent {
+ border: 1px solid black;
+ padding: 0;
+ margin-bottom: 10px;
+ }
+
+ .key {
+ font-style: italic;
+ margin-left: 3px;
+ }
+
+ .value {
+ margin-left: 20px;
+ }
+
+ abbr {
+ border: none;
+ }
+
+ .summarykey {
+ display: none;
+ }
+
+ .summary {
+ font-weight: bold;
+ margin: 0;
+ padding: 3px;
+ }
+
+ .description {
+ white-space: pre-wrap;
+ }
+ </style>
+ </head>
+ <body id="item-container">
+ <!-- Note on the use of the summarykey class:
+ This node is hidden by default for better readability. If you would
+ like to show the key, remove the display style above. -->
+ <div id="templates">
+ <div class="vevent" id="item-template">
+ <div class="row summaryrow">
+ <div class="key summarykey"></div>
+ <div class="value summary"></div>
+ </div>
+ <div class="row intervalrow">
+ <div class="key intervalkey"></div>
+ <div class="value">
+ <abbr class="dtstart"></abbr>
+ </div>
+ </div>
+ <div class="row locationrow">
+ <div class="key locationkey"></div>
+ <div class="value location"></div>
+ </div>
+ <div class="row descriptionrow">
+ <div class="key descriptionkey"></div>
+ <div class="value description"></div>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/comm/calendar/import-export/components.conf b/comm/calendar/import-export/components.conf
new file mode 100644
index 0000000000..f336c30bc0
--- /dev/null
+++ b/comm/calendar/import-export/components.conf
@@ -0,0 +1,29 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{1e3e33dc-445a-49de-b2b6-15b2a050bb9d}',
+ 'contract_ids': ['@mozilla.org/calendar/import;1?type=ics'],
+ 'jsm': 'resource:///modules/CalIcsImportExport.jsm',
+ 'constructor': 'CalIcsImporter',
+ 'categories': {'cal-importers': 'cal-ics-importer'},
+ },
+ {
+ 'cid': '{a6a524ce-adff-4a0f-bb7d-d1aaad4adc60}',
+ 'contract_ids': ['@mozilla.org/calendar/export;1?type=ics'],
+ 'jsm': 'resource:///modules/CalIcsImportExport.jsm',
+ 'constructor': 'CalIcsExporter',
+ 'categories': {'cal-exporters': 'cal-ics-exporter'},
+ },
+ {
+ 'cid': '{72d9ab35-9b1b-442a-8cd0-ae49f00b159b}',
+ 'contract_ids': ['@mozilla.org/calendar/export;1?type=htmllist'],
+ 'jsm': 'resource:///modules/CalHtmlExport.jsm',
+ 'constructor': 'CalHtmlExporter',
+ 'categories': {'cal-exporters': 'cal-html-list-exporter'},
+ },
+]
diff --git a/comm/calendar/import-export/jar.mn b/comm/calendar/import-export/jar.mn
new file mode 100644
index 0000000000..453701b6b0
--- /dev/null
+++ b/comm/calendar/import-export/jar.mn
@@ -0,0 +1,7 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar.jar:
+ content/printing/calHtmlExport.html (calHtmlExport.html)
diff --git a/comm/calendar/import-export/moz.build b/comm/calendar/import-export/moz.build
new file mode 100644
index 0000000000..f716fbc0b4
--- /dev/null
+++ b/comm/calendar/import-export/moz.build
@@ -0,0 +1,18 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXTRA_JS_MODULES += [
+ "CalHtmlExport.jsm",
+ "CalIcsImportExport.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Import and Export")
diff --git a/comm/calendar/itip/CalItipEmailTransport.jsm b/comm/calendar/itip/CalItipEmailTransport.jsm
new file mode 100644
index 0000000000..9cbad0d0cf
--- /dev/null
+++ b/comm/calendar/itip/CalItipEmailTransport.jsm
@@ -0,0 +1,439 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalItipEmailTransport", "CalItipDefaultEmailTransport"];
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * CalItipEmailTransport is used to send iTIP messages via email. Outside
+ * callers should use the static `createInstance()` method instead of this
+ * constructor directly.
+ */
+class CalItipEmailTransport {
+ wrappedJSObject = this;
+ QueryInterface = ChromeUtils.generateQI(["calIItipTransport"]);
+ classID = Components.ID("{d4d7b59e-c9e0-4a7a-b5e8-5958f85515f0}");
+
+ mSenderAddress = null;
+
+ constructor(defaultAccount, defaultIdentity) {
+ this.mDefaultAccount = defaultAccount;
+ this.mDefaultIdentity = defaultIdentity;
+ }
+
+ get scheme() {
+ return "mailto";
+ }
+
+ get type() {
+ return "email";
+ }
+
+ get senderAddress() {
+ return this.mSenderAddress;
+ }
+
+ set senderAddress(aValue) {
+ this.mSenderAddress = aValue;
+ }
+
+ /**
+ * Creates a new calIItipTransport instance configured with the default
+ * account and identity if available. If not available or an error occurs, an
+ * instance that cannot send any items out is returned.
+ */
+ static createInstance() {
+ try {
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ let defaultIdentity = defaultAccount ? defaultAccount.defaultIdentity : null;
+
+ if (!defaultIdentity) {
+ // If there isn't a default identity (i.e Local Folders is your
+ // default identity, then go ahead and use the first available
+ // identity.
+ let allIdentities = MailServices.accounts.allIdentities;
+ if (allIdentities.length > 0) {
+ defaultIdentity = allIdentities[0];
+ }
+ }
+
+ if (defaultAccount && defaultIdentity) {
+ return new CalItipEmailTransport(defaultAccount, defaultIdentity);
+ }
+ } catch (ex) {
+ // Fall through to below.
+ }
+
+ cal.LOG("CalITipEmailTransport.createInstance: No XPCOM Mail available.");
+ return new CalItipNoEmailTransport();
+ }
+
+ _prepareItems(aItipItem, aFromAttendee) {
+ let item = aItipItem.getItemList()[0];
+
+ // Get ourselves some default text - when we handle organizer properly
+ // We'll need a way to configure the Common Name attribute and we should
+ // use it here rather than the email address
+
+ let summary = item.getProperty("SUMMARY") || "";
+ let subject = "";
+ let body = "";
+ switch (aItipItem.responseMethod) {
+ case "REQUEST": {
+ let usePrefixes = Services.prefs.getBoolPref(
+ "calendar.itip.useInvitationSubjectPrefixes",
+ true
+ );
+ if (usePrefixes) {
+ let seq = item.getProperty("SEQUENCE");
+ let subjectKey = seq && seq > 0 ? "itipRequestUpdatedSubject2" : "itipRequestSubject2";
+ subject = cal.l10n.getLtnString(subjectKey, [summary]);
+ } else {
+ subject = summary;
+ }
+ body = cal.l10n.getLtnString("itipRequestBody", [
+ item.organizer ? item.organizer.toString() : "",
+ summary,
+ ]);
+ break;
+ }
+ case "CANCEL": {
+ subject = cal.l10n.getLtnString("itipCancelSubject2", [summary]);
+ body = cal.l10n.getLtnString("itipCancelBody", [
+ item.organizer ? item.organizer.toString() : "",
+ summary,
+ ]);
+ break;
+ }
+ case "DECLINECOUNTER": {
+ subject = cal.l10n.getLtnString("itipDeclineCounterSubject", [summary]);
+ body = cal.l10n.getLtnString("itipDeclineCounterBody", [
+ item.organizer ? item.organizer.toString() : "",
+ summary,
+ ]);
+ break;
+ }
+ case "REPLY": {
+ // Get my participation status
+ if (!aFromAttendee && aItipItem.identity) {
+ aFromAttendee = item.getAttendeeById(cal.email.prependMailTo(aItipItem.identity));
+ }
+ if (!aFromAttendee) {
+ // should not happen anymore
+ return false;
+ }
+
+ // work around BUG 351589, the below just removes RSVP:
+ aItipItem.setAttendeeStatus(aFromAttendee.id, aFromAttendee.participationStatus);
+ let myPartStat = aFromAttendee.participationStatus;
+ let name = aFromAttendee.toString();
+
+ // Generate proper body from my participation status
+ let subjectKey, bodyKey;
+ switch (myPartStat) {
+ case "ACCEPTED":
+ subjectKey = "itipReplySubjectAccept2";
+ bodyKey = "itipReplyBodyAccept";
+ break;
+ case "TENTATIVE":
+ subjectKey = "itipReplySubjectTentative2";
+ bodyKey = "itipReplyBodyAccept";
+ break;
+ case "DECLINED":
+ subjectKey = "itipReplySubjectDecline2";
+ bodyKey = "itipReplyBodyDecline";
+ break;
+ default:
+ subjectKey = "itipReplySubject2";
+ bodyKey = "itipReplyBodyAccept";
+ break;
+ }
+ subject = cal.l10n.getLtnString(subjectKey, [summary]);
+ body = cal.l10n.getLtnString(bodyKey, [name]);
+ break;
+ }
+ }
+
+ return {
+ subject,
+ body,
+ };
+ }
+
+ _sendXpcomMail(aToList, aSubject, aBody, aItipItem) {
+ let { identity, account } = this.getIdentityAndAccount(aItipItem);
+
+ switch (aItipItem.autoResponse) {
+ case Ci.calIItipItem.USER: {
+ cal.LOG("sendXpcomMail: Found USER autoResponse type.");
+ // We still need this as a last resort if a user just deletes or
+ // drags an invitation related event
+ let parent = Services.wm.getMostRecentWindow(null);
+ if (parent.closed) {
+ parent = cal.window.getCalendarWindow();
+ }
+ let cancelled = Services.prompt.confirmEx(
+ parent,
+ cal.l10n.getLtnString("imipSendMail.title"),
+ cal.l10n.getLtnString("imipSendMail.text"),
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ {}
+ );
+ if (cancelled) {
+ cal.LOG("sendXpcomMail: Sending of invitation email aborted by user!");
+ break;
+ } // else go on with auto sending for now
+ }
+ // falls through intended
+ case Ci.calIItipItem.AUTO: {
+ // don't show log message in case of falling through
+ if (aItipItem.autoResponse == Ci.calIItipItem.AUTO) {
+ cal.LOG("sendXpcomMail: Found AUTO autoResponse type.");
+ }
+ let cbEmail = function (aVal, aInd, aArr) {
+ let email = cal.email.getAttendeeEmail(aVal, true);
+ if (!email.length) {
+ cal.LOG("sendXpcomMail: Invalid recipient for email transport: " + aVal.toString());
+ }
+ return email;
+ };
+ let toMap = aToList.map(cbEmail).filter(value => value.length);
+ if (toMap.length < aToList.length) {
+ // at least one invalid recipient, so we skip sending for this message
+ return false;
+ }
+ let toList = toMap.join(", ");
+ let composeUtils = Cc["@mozilla.org/messengercompose/computils;1"].createInstance(
+ Ci.nsIMsgCompUtils
+ );
+ let messageId = composeUtils.msgGenerateMessageId(identity, null);
+ let mailFile = this._createTempImipFile(
+ toList,
+ aSubject,
+ aBody,
+ aItipItem,
+ identity,
+ messageId
+ );
+ if (mailFile) {
+ // compose fields for message: from/to etc need to be specified both here and in the file
+ let composeFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ composeFields.to = toList;
+ let mailfrom = identity.fullName.length
+ ? identity.fullName + " <" + identity.email + ">"
+ : identity.email;
+ composeFields.from =
+ cal.email.validateRecipientList(mailfrom) == mailfrom ? mailfrom : identity.email;
+ composeFields.replyTo = identity.replyTo;
+ composeFields.organization = identity.organization;
+ composeFields.messageId = messageId;
+ let validRecipients;
+ if (identity.doCc) {
+ validRecipients = cal.email.validateRecipientList(identity.doCcList);
+ if (validRecipients != "") {
+ // eslint-disable-next-line id-length
+ composeFields.cc = validRecipients;
+ }
+ }
+ if (identity.doBcc) {
+ validRecipients = cal.email.validateRecipientList(identity.doBccList);
+ if (validRecipients != "") {
+ composeFields.bcc = validRecipients;
+ }
+ }
+
+ // xxx todo: add send/progress UI, maybe recycle
+ // "@mozilla.org/messengercompose/composesendlistener;1"
+ // and/or "chrome://messenger/content/messengercompose/sendProgress.xhtml"
+ // i.e. bug 432662
+ this.getMsgSend().sendMessageFile(
+ identity,
+ account.key,
+ composeFields,
+ mailFile,
+ true, // deleteSendFileOnCompletion
+ false, // digest_p
+ Services.io.offline ? Ci.nsIMsgSend.nsMsgQueueForLater : Ci.nsIMsgSend.nsMsgDeliverNow,
+ null, // nsIMsgDBHdr msgToReplace
+ null, // nsIMsgSendListener aListener
+ null, // nsIMsgStatusFeedback aStatusFeedback
+ ""
+ ); // password
+ return true;
+ }
+ break;
+ }
+ case Ci.calIItipItem.NONE: {
+ // we shouldn't get here, as we stopped processing in this case
+ // earlier in checkAndSend in calItipUtils.jsm
+ cal.LOG("sendXpcomMail: Found NONE autoResponse type.");
+ break;
+ }
+ default: {
+ // Also of this case should have been taken care at the same place
+ throw new Error("sendXpcomMail: Unknown autoResponse type: " + aItipItem.autoResponse);
+ }
+ }
+ return false;
+ }
+
+ _createTempImipFile(aToList, aSubject, aBody, aItipItem, aIdentity, aMessageId) {
+ try {
+ let itemList = aItipItem.getItemList();
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems(itemList);
+ let methodProp = cal.icsService.createIcalProperty("METHOD");
+ methodProp.value = aItipItem.responseMethod;
+ serializer.addProperty(methodProp);
+ let calText = serializer.serializeToString();
+ let utf8CalText = cal.invitation.encodeUTF8(calText);
+
+ // Home-grown mail composition; I'd love to use nsIMimeEmitter, but it's not clear to me whether
+ // it can cope with nested attachments,
+ // like multipart/alternative with enclosed text/calendar and text/plain.
+ let mailText = cal.invitation.getHeaderSection(aMessageId, aIdentity, aToList, aSubject);
+ mailText +=
+ 'Content-type: multipart/mixed; boundary="Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)"\r\n' +
+ "\r\n\r\n" +
+ "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)\r\n" +
+ "Content-type: multipart/alternative;\r\n" +
+ ' boundary="Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)"\r\n' +
+ "\r\n\r\n" +
+ "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)\r\n" +
+ "Content-type: text/plain; charset=UTF-8\r\n" +
+ "Content-transfer-encoding: 8BIT\r\n" +
+ "\r\n" +
+ cal.invitation.encodeUTF8(aBody) +
+ "\r\n\r\n\r\n" +
+ "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)\r\n" +
+ "Content-type: text/calendar; method=" +
+ aItipItem.responseMethod +
+ "; charset=UTF-8\r\n" +
+ "Content-transfer-encoding: 8BIT\r\n" +
+ "\r\n" +
+ utf8CalText +
+ "\r\n\r\n" +
+ "--Boundary_(ID_ryU4ZdJoASiZ+Jo21dCbwA)--\r\n" +
+ "\r\n" +
+ "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)\r\n" +
+ "Content-type: application/ics; name=invite.ics\r\n" +
+ "Content-transfer-encoding: 8BIT\r\n" +
+ "Content-disposition: attachment; filename=invite.ics\r\n" +
+ "\r\n" +
+ utf8CalText +
+ "\r\n\r\n" +
+ "--Boundary_(ID_qyG4ZdjoAsiZ+Jo19dCbWQ)--\r\n";
+ cal.LOG("mail text:\n" + mailText);
+
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("itipTemp");
+ tempFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ let outputStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ // Let's write the file - constants from file-utils.js
+ const MODE_WRONLY = 0x02;
+ const MODE_CREATE = 0x08;
+ const MODE_TRUNCATE = 0x20;
+ outputStream.init(
+ tempFile,
+ MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE,
+ parseInt("0600", 8),
+ 0
+ );
+ outputStream.write(mailText, mailText.length);
+ outputStream.close();
+
+ cal.LOG("_createTempImipFile path: " + tempFile.path);
+ return tempFile;
+ } catch (exc) {
+ cal.ASSERT(false, exc);
+ return null;
+ }
+ }
+
+ /**
+ * Provides a new nsIMsgSend instance to use when sending the message. This
+ * method can be overridden in child classes for testing or other purposes.
+ */
+ getMsgSend() {
+ return Cc["@mozilla.org/messengercompose/send;1"].createInstance(Ci.nsIMsgSend);
+ }
+
+ /**
+ * Provides the identity and account to use when sending iTIP emails. By
+ * default prefers whatever the item's calendar is configured to use or the
+ * default configuration when not set. This method can be overridden to change
+ * that behaviour.
+ *
+ * @param {calIItipItem} aItipItem
+ * @returns {object} - An object containing a property for the identity and
+ * one for the account.
+ */
+ getIdentityAndAccount(aItipItem) {
+ let identity;
+ let account;
+ if (aItipItem.targetCalendar) {
+ identity = aItipItem.targetCalendar.getProperty("imip.identity");
+ if (identity) {
+ identity = identity.QueryInterface(Ci.nsIMsgIdentity);
+ account = aItipItem.targetCalendar
+ .getProperty("imip.account")
+ .QueryInterface(Ci.nsIMsgAccount);
+ } else {
+ cal.WARN("No email identity configured for calendar " + aItipItem.targetCalendar.name);
+ }
+ }
+ if (!identity) {
+ // use some default identity/account:
+ identity = this.mDefaultIdentity;
+ account = this.mDefaultAccount;
+ }
+ return { identity, account };
+ }
+
+ sendItems(aRecipients, aItipItem, aFromAttendee) {
+ cal.LOG("sendItems: Preparing to send an invitation email...");
+ let items = this._prepareItems(aItipItem, aFromAttendee);
+ if (items === false) {
+ return false;
+ }
+
+ return this._sendXpcomMail(aRecipients, items.subject, items.body, aItipItem);
+ }
+}
+
+/**
+ * CalItipNoEmailTransport is a transport used in place of CalItipEmaiTransport
+ * when we are unable to send messages due to missing configuration.
+ */
+class CalItipNoEmailTransport extends CalItipEmailTransport {
+ wrappedJSObject = this;
+ QueryInterface = ChromeUtils.generateQI(["calIItipTransport"]);
+
+ sendItems(aRecipients, aItipItem, aFromAttendee) {
+ return false;
+ }
+}
+
+/**
+ * CalItipDefaultEmailTransport always uses the identity and account provided
+ * as default instead of the one configured for the calendar.
+ */
+class CalItipDefaultEmailTransport extends CalItipEmailTransport {
+ getIdentityAndAccount() {
+ return { identity: this.mDefaultIdentity, account: this.mDefaultAccount };
+ }
+}
diff --git a/comm/calendar/itip/CalItipMessageSender.jsm b/comm/calendar/itip/CalItipMessageSender.jsm
new file mode 100644
index 0000000000..373f9b728c
--- /dev/null
+++ b/comm/calendar/itip/CalItipMessageSender.jsm
@@ -0,0 +1,433 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalItipMessageSender"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalItipOutgoingMessage } = ChromeUtils.import(
+ "resource:///modules/CalItipOutgoingMessage.jsm"
+);
+
+/**
+ * CalItipMessageSender is responsible for sending out the appropriate iTIP
+ * messages when changes have been made to an invitation event.
+ */
+class CalItipMessageSender {
+ /**
+ * A list of CalItipOutgoingMessages to send out.
+ */
+ pendingMessages = [];
+
+ /**
+ * @param {?calIItemBase} originalItem - The original invitation item before
+ * it is modified.
+ *
+ * @param {?calIAttendee} invitedAttendee - For incoming invitations, this is
+ * the attendee that was invited (corresponding to an installed identity).
+ * For outgoing invitations, this should be `null`.
+ */
+ constructor(originalItem, invitedAttendee) {
+ this.originalItem = originalItem;
+ this.invitedAttendee = invitedAttendee;
+ }
+
+ /**
+ * Provides the count of CalItipOutgoingMessages ready to be sent.
+ */
+ get pendingMessageCount() {
+ return this.pendingMessages.length;
+ }
+
+ /**
+ * Builds a list of iTIP messages to be sent as a result of operations on a
+ * calendar item, based on the current user's role and any modifications to
+ * the item.
+ *
+ * This method should be called before send().
+ *
+ * @param {number} opType - Type of operation - (e.g. ADD, MODIFY or DELETE)
+ * @param {calIItemBase} item - The updated item.
+ * @param {?object} extResponse - An object to provide additional
+ * parameters for sending itip messages as response mode, comments or a
+ * subset of recipients.
+ * @param {number} extResponse.responseMode - Response mode as defined for
+ * autoResponse of calIItipItem.
+ *
+ * The default mode is USER (which will trigger displaying the previously
+ * known popup to ask the user whether to send)
+ *
+ * @returns {number} - The number of messages to be sent.
+ */
+ buildOutgoingMessages(opType, item, extResponse = null) {
+ let { originalItem, invitedAttendee } = this;
+
+ // balance out parts of the modification vs delete confusion, deletion of occurrences
+ // are notified as parent modifications and modifications of occurrences are notified
+ // as mixed new-occurrence, old-parent (IIRC).
+ if (originalItem && item.recurrenceInfo) {
+ if (originalItem.recurrenceId && !item.recurrenceId) {
+ // sanity check: assure item doesn't refer to the master
+ item = item.recurrenceInfo.getOccurrenceFor(originalItem.recurrenceId);
+ if (!item) {
+ return this.pendingMessageCount;
+ }
+ // Use the calIAttendee instance from the occurrence in case there is a
+ // difference in participationStatus between it and the parent.
+ if (invitedAttendee) {
+ invitedAttendee = item.getAttendeeById(invitedAttendee.id);
+ }
+ }
+
+ if (originalItem.recurrenceInfo && item.recurrenceInfo) {
+ // check whether the two differ only in EXDATEs
+ let clonedItem = item.clone();
+ let exdates = [];
+ for (let ritem of clonedItem.recurrenceInfo.getRecurrenceItems()) {
+ let wrappedRItem = cal.wrapInstance(ritem, Ci.calIRecurrenceDate);
+ if (
+ ritem.isNegative &&
+ wrappedRItem &&
+ !originalItem.recurrenceInfo.getRecurrenceItems().some(recitem => {
+ let wrappedR = cal.wrapInstance(recitem, Ci.calIRecurrenceDate);
+ return (
+ recitem.isNegative && wrappedR && wrappedR.date.compare(wrappedRItem.date) == 0
+ );
+ })
+ ) {
+ exdates.push(wrappedRItem);
+ }
+ }
+ if (exdates.length > 0) {
+ // check whether really only EXDATEs have been added:
+ let recInfo = clonedItem.recurrenceInfo;
+ exdates.forEach(recInfo.deleteRecurrenceItem, recInfo);
+ if (cal.item.compareContent(clonedItem, originalItem)) {
+ // transition into "delete occurrence(s)"
+ // xxx todo: support multiple
+ item = originalItem.recurrenceInfo.getOccurrenceFor(exdates[0].date);
+ originalItem = null;
+ opType = Ci.calIOperationListener.DELETE;
+ }
+ }
+ }
+ }
+
+ // for backward compatibility, we assume USER mode if not set otherwise
+ let autoResponse = { mode: Ci.calIItipItem.USER };
+ if (extResponse && extResponse.hasOwnProperty("responseMode")) {
+ switch (extResponse.responseMode) {
+ case Ci.calIItipItem.AUTO:
+ case Ci.calIItipItem.NONE:
+ case Ci.calIItipItem.USER:
+ autoResponse.mode = extResponse.responseMode;
+ break;
+ default:
+ cal.ERROR(
+ "cal.itip.checkAndSend(): Invalid value " +
+ extResponse.responseMode +
+ " provided for responseMode attribute in argument extResponse." +
+ " Falling back to USER mode.\r\n" +
+ cal.STACK(20)
+ );
+ }
+ } else if ((originalItem && originalItem.getAttendees().length) || item.getAttendees().length) {
+ // let's log something useful to notify addon developers or find any
+ // missing pieces in the conversions if the current or original item
+ // has attendees - the latter is to prevent logging if creating events
+ // by click and slide in day or week views
+ cal.LOG(
+ "cal.itip.checkAndSend: no response mode provided, " +
+ "falling back to USER mode.\r\n" +
+ cal.STACK(20)
+ );
+ }
+ if (autoResponse.mode == Ci.calIItipItem.NONE) {
+ // we stop here and don't send anything if the user opted out before
+ return this.pendingMessageCount;
+ }
+
+ // If an "invited attendee" (i.e., the current user) is present, we assume
+ // that this is an incoming invite and that we should send only a REPLY if
+ // needed.
+ if (invitedAttendee) {
+ /* We check if the attendee id matches one of of the
+ * userAddresses. If they aren't equal, it means that
+ * someone is accepting invitations on behalf of an other user. */
+ if (item.calendar.aclEntry) {
+ let userAddresses = item.calendar.aclEntry.getUserAddresses();
+ if (
+ userAddresses.length > 0 &&
+ !cal.email.attendeeMatchesAddresses(invitedAttendee, userAddresses)
+ ) {
+ invitedAttendee = invitedAttendee.clone();
+ invitedAttendee.setProperty("SENT-BY", cal.email.prependMailTo(userAddresses[0]));
+ }
+ }
+
+ if (item.organizer) {
+ let origInvitedAttendee = originalItem && originalItem.getAttendeeById(invitedAttendee.id);
+
+ if (opType == Ci.calIOperationListener.DELETE) {
+ // in case the attendee has just deleted the item, we want to send out a DECLINED REPLY:
+ origInvitedAttendee = invitedAttendee;
+ invitedAttendee = invitedAttendee.clone();
+ invitedAttendee.participationStatus = "DECLINED";
+ }
+
+ // We want to send a REPLY send if:
+ // - there has been a PARTSTAT change
+ // - in case of an organizer SEQUENCE bump we'd go and reconfirm our PARTSTAT
+ if (
+ !origInvitedAttendee ||
+ origInvitedAttendee.participationStatus != invitedAttendee.participationStatus ||
+ (originalItem && cal.itip.getSequence(item) != cal.itip.getSequence(originalItem))
+ ) {
+ item = item.clone();
+ item.removeAllAttendees();
+ item.addAttendee(invitedAttendee);
+ // we remove X-MS-OLK-SENDER to avoid confusing Outlook 2007+ (w/o Exchange)
+ // about the notification sender (see bug 603933)
+ item.deleteProperty("X-MS-OLK-SENDER");
+
+ // Do not send the X-MOZ-INVITED-ATTENDEE property.
+ item.deleteProperty("X-MOZ-INVITED-ATTENDEE");
+
+ // if the event was delegated to the replying attendee, we may also notify also
+ // the delegator due to chapter 3.2.2.3. of RfC 5546
+ let replyTo = [];
+ let delegatorIds = invitedAttendee.getProperty("DELEGATED-FROM");
+ if (
+ delegatorIds &&
+ Services.prefs.getBoolPref("calendar.itip.notifyDelegatorOnReply", false)
+ ) {
+ let getDelegator = function (aDelegatorId) {
+ let delegator = originalItem.getAttendeeById(aDelegatorId);
+ if (delegator) {
+ replyTo.push(delegator);
+ }
+ };
+ // Our backends currently do not support multi-value params. libical just
+ // swallows any value but the first, while ical.js fails to parse the item
+ // at all. Single values are handled properly by both backends though.
+ // Once bug 1206502 lands, ical.js will handle multi-value params, but
+ // we end up in different return types of getProperty. A native exposure of
+ // DELEGATED-FROM and DELEGATED-TO in calIAttendee may change this.
+ if (Array.isArray(delegatorIds)) {
+ for (let delegatorId of delegatorIds) {
+ getDelegator(delegatorId);
+ }
+ } else if (typeof delegatorIds == "string") {
+ getDelegator(delegatorIds);
+ }
+ }
+ replyTo.push(item.organizer);
+ this.pendingMessages.push(
+ new CalItipOutgoingMessage("REPLY", replyTo, item, invitedAttendee, autoResponse)
+ );
+ }
+ }
+ return this.pendingMessageCount;
+ }
+
+ if (item.getProperty("X-MOZ-SEND-INVITATIONS") != "TRUE") {
+ // Only send invitations/cancellations
+ // if the user checked the checkbox
+ this.pendingMessages = [];
+ return this.pendingMessageCount;
+ }
+
+ // special handling for invitation with event status cancelled
+ if (item.getAttendees().length > 0 && item.getProperty("STATUS") == "CANCELLED") {
+ if (cal.itip.getSequence(item) > 0) {
+ // make sure we send a cancellation and not an request
+ opType = Ci.calIOperationListener.DELETE;
+ } else {
+ // don't send an invitation, if the event was newly created and has status cancelled
+ this.pendingMessages = [];
+ return this.pendingMessageCount;
+ }
+ }
+
+ if (opType == Ci.calIOperationListener.DELETE) {
+ let attendees = this.#filterOwnerFromAttendees(item.getAttendees(), item.calendar);
+ this.pendingMessages.push(
+ new CalItipOutgoingMessage("CANCEL", attendees, item, null, autoResponse)
+ );
+ return this.pendingMessageCount;
+ } // else ADD, MODIFY:
+
+ let originalAtt = originalItem ? originalItem.getAttendees() : [];
+ let itemAtt = item.getAttendees();
+ let canceledAttendees = [];
+ let addedAttendees = [];
+
+ if (itemAtt.length > 0 || originalAtt.length > 0) {
+ let attMap = {};
+ for (let att of originalAtt) {
+ attMap[att.id.toLowerCase()] = att;
+ }
+
+ for (let att of itemAtt) {
+ if (att.id.toLowerCase() in attMap) {
+ // Attendee was in original item.
+ delete attMap[att.id.toLowerCase()];
+ } else {
+ // Attendee only in new item
+ addedAttendees.push(att);
+ }
+ }
+
+ for (let id in attMap) {
+ let cancAtt = attMap[id];
+ canceledAttendees.push(cancAtt);
+ }
+ }
+
+ // Check to see if some part of the item was updated, if so, re-send REQUEST
+ if (!originalItem || cal.itip.compare(item, originalItem) > 0) {
+ // REQUEST
+ // check whether it's a simple UPDATE (no SEQUENCE change) or real (RE)REQUEST,
+ // in case of time or location/description change.
+ let isMinorUpdate =
+ originalItem && cal.itip.getSequence(item) == cal.itip.getSequence(originalItem);
+
+ if (
+ !isMinorUpdate ||
+ !cal.item.compareContent(stripUserData(item), stripUserData(originalItem))
+ ) {
+ let requestItem = item.clone();
+ if (!requestItem.organizer) {
+ requestItem.organizer = cal.itip.createOrganizer(requestItem.calendar);
+ }
+
+ // Fix up our attendees for invitations using some good defaults
+ let recipients = [];
+ let reqItemAtt = requestItem.getAttendees();
+ if (!isMinorUpdate) {
+ requestItem.removeAllAttendees();
+ }
+ for (let attendee of reqItemAtt) {
+ if (!isMinorUpdate) {
+ attendee = attendee.clone();
+ if (!attendee.role) {
+ attendee.role = "REQ-PARTICIPANT";
+ }
+ attendee.participationStatus = "NEEDS-ACTION";
+ attendee.rsvp = "TRUE";
+ requestItem.addAttendee(attendee);
+ }
+
+ recipients.push(attendee);
+ }
+
+ // if send out should be limited to newly added attendees and no major
+ // props (attendee is not such) have changed, only the respective attendee
+ // is added to the recipient list while the attendee information in the
+ // ical is left to enable the new attendee to see who else is attending
+ // the event (if not prevented otherwise)
+ if (
+ isMinorUpdate &&
+ addedAttendees.length > 0 &&
+ Services.prefs.getBoolPref("calendar.itip.updateInvitationForNewAttendeesOnly", false)
+ ) {
+ recipients = addedAttendees;
+ }
+
+ // Since this is a REQUEST, it is being sent from the event creator to
+ // attendees. We do not need to send a message to the creator, even
+ // though they may also be an attendee.
+ recipients = this.#filterOwnerFromAttendees(recipients, item.calendar);
+
+ if (recipients.length > 0) {
+ this.pendingMessages.push(
+ new CalItipOutgoingMessage("REQUEST", recipients, requestItem, null, autoResponse)
+ );
+ }
+ }
+ }
+
+ // Cancel the event for all canceled attendees
+ if (canceledAttendees.length > 0) {
+ let cancelItem = originalItem.clone();
+ cancelItem.removeAllAttendees();
+ for (let att of canceledAttendees) {
+ cancelItem.addAttendee(att);
+ }
+ canceledAttendees = this.#filterOwnerFromAttendees(canceledAttendees, cancelItem.calendar);
+ this.pendingMessages.push(
+ new CalItipOutgoingMessage("CANCEL", canceledAttendees, cancelItem, null, autoResponse)
+ );
+ }
+ return this.pendingMessageCount;
+ }
+
+ /**
+ * Sends the iTIP message using the item's calendar transport. This method
+ * should be called after buildOutgoingMessages().
+ *
+ * @param {calIItipTransport} [transport] - An optional transport to use
+ * instead of the one provided by the item's calendar.
+ *
+ * @returns {boolean} - True, if the message could be sent.
+ */
+ send(transport) {
+ return this.pendingMessages.every(msg => msg.send(transport));
+ }
+
+ /**
+ * Filter out calendar owner from a list of event attendees to prevent the
+ * owner from receiving messages about changes they have made.
+ *
+ * @param {calIAttendee[]} attendees - The attendees.
+ * @param {calICalendar} calendar - The calendar the event belongs to.
+ * @returns {calIAttendee[]} the attendees with calendar owner removed.
+ */
+ #filterOwnerFromAttendees(attendees, calendar) {
+ const calendarEmail = cal.provider.getEmailIdentityOfCalendar(calendar)?.email;
+ return attendees.filter(attendee => cal.email.removeMailTo(attendee.id) != calendarEmail);
+ }
+}
+
+/**
+ * Strips user specific data, e.g. categories and alarm settings and returns the stripped item.
+ *
+ * @param {calIItemBase} item_ - The item to strip data from
+ * @returns {calIItemBase} - The stripped item
+ */
+function stripUserData(item_) {
+ let item = item_.clone();
+ let stamp = item.stampTime;
+ let lastModified = item.lastModifiedTime;
+ item.clearAlarms();
+ item.alarmLastAck = null;
+ item.setCategories([]);
+ item.deleteProperty("RECEIVED-SEQUENCE");
+ item.deleteProperty("RECEIVED-DTSTAMP");
+ for (let [name] of item.properties) {
+ let pname = name;
+ if (pname.substr(0, "X-MOZ-".length) == "X-MOZ-") {
+ item.deleteProperty(name);
+ }
+ }
+ item.getAttendees().forEach(att => {
+ att.deleteProperty("RECEIVED-SEQUENCE");
+ att.deleteProperty("RECEIVED-DTSTAMP");
+ });
+
+ // according to RfC 6638, the following items must not be exposed in client side
+ // scheduling messages, so let's remove it if present
+ let removeSchedulingParams = aCalUser => {
+ aCalUser.deleteProperty("SCHEDULE-AGENT");
+ aCalUser.deleteProperty("SCHEDULE-FORCE-SEND");
+ aCalUser.deleteProperty("SCHEDULE-STATUS");
+ };
+ item.getAttendees().forEach(removeSchedulingParams);
+ if (item.organizer) {
+ removeSchedulingParams(item.organizer);
+ }
+
+ item.setProperty("DTSTAMP", stamp);
+ item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item
+ return item;
+}
diff --git a/comm/calendar/itip/CalItipOutgoingMessage.jsm b/comm/calendar/itip/CalItipOutgoingMessage.jsm
new file mode 100644
index 0000000000..f85374c436
--- /dev/null
+++ b/comm/calendar/itip/CalItipOutgoingMessage.jsm
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalItipOutgoingMessage"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * CalItipOutgoingMessage contains information needed for sending an outgoing
+ * iTIP message via a calIItipTransport instance.
+ */
+class CalItipOutgoingMessage {
+ /**
+ * @param {string} method - The iTIP request method.
+ * @param {calIAttendee[]} recipients - A list of attendees who will receive
+ * the message.
+ * @param {calIEvent} item - The item the message relates to.
+ * @param {?calIAttendee} sender - The attendee the message comes from for
+ * replies.
+ * @param {?object} autoResponse - The inout object whether the transport
+ * should ask before sending
+ */
+ constructor(method, recipients, item, sender, autoResponse) {
+ this.method = method;
+ this.recipients = recipients;
+ this.item = item;
+ this.sender = sender;
+ this.autoResponse = autoResponse;
+ }
+
+ /**
+ * Sends the iTIP message using the item's calendar transport.
+ *
+ * @param {calIItipTransport} transport - The transport to use when sending.
+ *
+ * @returns {boolean} - True, if the message could be sent
+ */
+ send(transport) {
+ if (this.item.calendar && this.item.calendar.supportsScheduling) {
+ let calendar = this.item.calendar.getSchedulingSupport();
+ if (calendar.canNotify(this.method, this.item)) {
+ // provider will handle that, so we return - we leave it also to the provider to
+ // deal with user canceled notifications (if possible), so set the return value
+ // to true as false would prevent any further notification within this cycle
+ return true;
+ }
+ }
+
+ if (this.recipients.length == 0 || !transport) {
+ return false;
+ }
+
+ let { method, sender, autoResponse } = this;
+ let _sendItem = function (aSendToList, aSendItem) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ itipItem.init(cal.item.serialize(aSendItem));
+ itipItem.responseMethod = method;
+ itipItem.targetCalendar = aSendItem.calendar;
+ itipItem.autoResponse = autoResponse.mode;
+ // we switch to AUTO for each subsequent call of _sendItem()
+ autoResponse.mode = Ci.calIItipItem.AUTO;
+ // XXX I don't know whether the below is used at all, since we don't use the itip processor
+ itipItem.isSend = true;
+
+ return transport.sendItems(aSendToList, itipItem, sender);
+ };
+
+ // split up transport, if attendee undisclosure is requested
+ // and this is a message send by the organizer
+ if (
+ this.item.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED") == "TRUE" &&
+ this.method != "REPLY" &&
+ this.method != "REFRESH" &&
+ this.method != "COUNTER"
+ ) {
+ for (let recipient of this.recipients) {
+ // create a list with a single recipient
+ let sendToList = [recipient];
+ // remove other recipients from vevent attendee list
+ let sendItem = this.item.clone();
+ sendItem.removeAllAttendees();
+ sendItem.addAttendee(recipient);
+ // send message
+ if (!_sendItem(sendToList, sendItem)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return _sendItem(this.recipients, this.item);
+ }
+}
diff --git a/comm/calendar/itip/CalItipProtocolHandler.jsm b/comm/calendar/itip/CalItipProtocolHandler.jsm
new file mode 100644
index 0000000000..eb57b8d5db
--- /dev/null
+++ b/comm/calendar/itip/CalItipProtocolHandler.jsm
@@ -0,0 +1,118 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["ItipChannel", "ItipProtocolHandler", "ItipContentHandler"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function ItipChannel(URI, aLoadInfo) {
+ this.wrappedJSObject = this;
+ this.URI = this.originalURI = URI;
+ this.loadInfo = aLoadInfo;
+}
+ItipChannel.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIChannel", "nsIRequest"]),
+ classID: Components.ID("{643e0328-36f6-411d-a107-16238dff9cd7}"),
+
+ contentType: "application/x-itip-internal",
+ loadAttributes: null,
+ contentLength: 0,
+ owner: null,
+ loadGroup: null,
+ notificationCallbacks: null,
+ securityInfo: null,
+
+ open() {
+ throw Components.Exception(
+ `${this.constructor.name}.open not implemented`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+ asyncOpen(observer) {
+ observer.onStartRequest(this, null);
+ },
+ asyncRead(listener, ctxt) {
+ return listener.onStartRequest(this, ctxt);
+ },
+ isPending() {
+ return true;
+ },
+ status: Cr.NS_OK,
+ cancel(status) {
+ this.status = status;
+ },
+ suspend() {
+ throw Components.Exception(
+ `${this.constructor.name}.suspend not implemented`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+ resume() {
+ throw Components.Exception(
+ `${this.constructor.name}.resume not implemented`,
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+};
+
+/**
+ * @implements {nsIProtocolHandler}
+ */
+function ItipProtocolHandler() {
+ this.wrappedJSObject = this;
+}
+ItipProtocolHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIProtocolHandler"]),
+ classID: Components.ID("{6e957006-b4ce-11d9-b053-001124736B74}"),
+
+ allowPort: () => false,
+ isSecure: false,
+ newChannel(URI, aLoadInfo) {
+ dump("Creating new ItipChannel for " + URI + "\n");
+ return new ItipChannel(URI, aLoadInfo);
+ },
+};
+
+function ItipContentHandler() {
+ this.wrappedJSObject = this;
+}
+ItipContentHandler.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIContentHandler"]),
+ classID: Components.ID("{47c31f2b-b4de-11d9-bfe6-001124736B74}"),
+
+ handleContent(contentType, windowTarget, request) {
+ let channel = request.QueryInterface(Ci.nsIChannel);
+ let uri = channel.URI.spec;
+ if (!uri.startsWith("moz-cal-handle-itip:")) {
+ throw Components.Exception(`Unexpected iTIP uri: ${uri}`, Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+ let paramString = uri.substring("moz-cal-handle-itip:///".length);
+ let paramArray = paramString.split("&");
+ let paramBlock = {};
+ paramArray.forEach(value => {
+ let parts = value.split("=");
+ paramBlock[parts[0]] = unescape(unescape(parts[1]));
+ });
+ // dump("content-handler: have params " + paramBlock.toSource() + "\n");
+ let event = new lazy.CalEvent(paramBlock.data);
+ dump(
+ "Processing iTIP event '" +
+ event.title +
+ "' from " +
+ event.organizer.id +
+ " (" +
+ event.id +
+ ")\n"
+ );
+ let cals = cal.manager.getCalendars();
+ cals[0].addItem(event);
+ },
+};
diff --git a/comm/calendar/itip/components.conf b/comm/calendar/itip/components.conf
new file mode 100644
index 0000000000..ee03b0db0e
--- /dev/null
+++ b/comm/calendar/itip/components.conf
@@ -0,0 +1,39 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{d4d7b59e-c9e0-4a7a-b5e8-5958f85515f0}',
+ 'contract_ids': ['@mozilla.org/calendar/itip-transport;1?type=email'],
+ 'jsm': 'resource:///modules/CalItipEmailTransport.jsm',
+ 'constructor': 'CalItipEmailTransport',
+ },
+ {
+ 'cid': '{643e0328-36f6-411d-a107-16238dff9cd7}',
+ 'contract_ids': ['@mozilla.org/calendar/itip-channel;1'],
+ 'jsm': 'resource:///modules/CalItipProtocolHandler.jsm',
+ 'constructor': 'ItipChannel',
+ },
+ {
+ 'cid': '{6e957006-b4ce-11d9-b053-001124736B74}',
+ 'contract_ids': ['@mozilla.org/network/protocol;1?name=moz-cal-handle-itip'],
+ 'jsm': 'resource:///modules/CalItipProtocolHandler.jsm',
+ 'constructor': 'ItipProtocolHandler',
+ 'protocol_config': {
+ 'scheme': 'moz-cal-handle-itip',
+ 'flags': [
+ 'URI_NORELATIVE',
+ 'URI_DANGEROUS_TO_LOAD',
+ ],
+ },
+ },
+ {
+ 'cid': '{47c31f2b-b4de-11d9-bfe6-001124736B74}',
+ 'contract_ids': ['@mozilla.org/uriloader/content-handler;1?type=application/x-itip-internal'],
+ 'jsm': 'resource:///modules/CalItipEmailTransport.jsm',
+ 'constructor': 'ItipContentHandler',
+ },
+]
diff --git a/comm/calendar/itip/moz.build b/comm/calendar/itip/moz.build
new file mode 100644
index 0000000000..1a08449743
--- /dev/null
+++ b/comm/calendar/itip/moz.build
@@ -0,0 +1,18 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "CalItipEmailTransport.jsm",
+ "CalItipMessageSender.jsm",
+ "CalItipOutgoingMessage.jsm",
+ "CalItipProtocolHandler.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "E-mail based Scheduling (iTIP/iMIP)")
diff --git a/comm/calendar/locales/Makefile.in b/comm/calendar/locales/Makefile.in
new file mode 100644
index 0000000000..9ea0282c11
--- /dev/null
+++ b/comm/calendar/locales/Makefile.in
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+LOCALE_TOPDIR=$(commtopsrcdir)
+LOCALE_RELATIVEDIR=calendar/locales
diff --git a/comm/calendar/locales/all-locales b/comm/calendar/locales/all-locales
new file mode 100644
index 0000000000..67743c1baa
--- /dev/null
+++ b/comm/calendar/locales/all-locales
@@ -0,0 +1,66 @@
+af
+ar
+ast
+be
+bg
+br
+ca
+cak
+cs
+cy
+da
+de
+dsb
+el
+en-CA
+en-GB
+es-AR
+es-ES
+et
+eu
+fi
+fr
+fy-NL
+ga-IE
+gd
+gl
+he
+hr
+hsb
+hu
+hy-AM
+id
+is
+it
+ja
+ja-JP-mac
+ka
+kab
+kk
+ko
+lt
+lv
+mk
+ms
+nb-NO
+nl
+nn-NO
+pa-IN
+pl
+pt-BR
+pt-PT
+rm
+ro
+ru
+sk
+sl
+sq
+sr
+sv-SE
+th
+tr
+uk
+uz
+vi
+zh-CN
+zh-TW
diff --git a/comm/calendar/locales/en-US/README.txt b/comm/calendar/locales/en-US/README.txt
new file mode 100644
index 0000000000..8886c8d072
--- /dev/null
+++ b/comm/calendar/locales/en-US/README.txt
@@ -0,0 +1,3 @@
+For information about installing, running and configuring Lightning
+including a list of known issues and troubleshooting information,
+refer to: http://www.mozilla.org/projects/calendar/
diff --git a/comm/calendar/locales/en-US/calendar/calendar-context-menus.ftl b/comm/calendar/locales/en-US/calendar/calendar-context-menus.ftl
new file mode 100644
index 0000000000..e74e218719
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-context-menus.ftl
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+list-calendar-context-reload-menuitem =
+ .label = Synchronize
+ .accesskey = S
+
+calendar-item-context-menu-modify-menuitem =
+ .label = Edit
+ .accesskey = E
diff --git a/comm/calendar/locales/en-US/calendar/calendar-delete-prompt.ftl b/comm/calendar/locales/en-US/calendar/calendar-delete-prompt.ftl
new file mode 100644
index 0000000000..6f0ac69901
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-delete-prompt.ftl
@@ -0,0 +1,43 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+## Variables:
+## $count (Number) - Number of events selected for deletion.
+
+calendar-delete-event-prompt-title = { $count ->
+ [one] Delete Event
+ *[other] Delete Events
+}
+calendar-delete-event-prompt-message = { $count ->
+ [one] Do you really want to delete this event?
+ *[other] Do you really want to delete these { $count } events?
+}
+
+## Variables:
+## $count (Number) - Number of tasks selected for deletion.
+
+calendar-delete-task-prompt-title = { $count ->
+ [one] Delete Task
+ *[other] Delete Tasks
+}
+calendar-delete-task-prompt-message = { $count ->
+ [one] Do you really want to delete this task?
+ *[other] Do you really want to delete these { $count } tasks?
+}
+
+## Variables:
+## $count (Number) - Number of items selected for deletion.
+
+calendar-delete-item-prompt-title = { $count ->
+ [one] Delete Item
+ *[other] Delete Items
+}
+calendar-delete-item-prompt-message = { $count ->
+ [one] Do you really want to delete this item?
+ *[other] Do you really want to delete these { $count } items?
+}
+
+##
+
+calendar-delete-prompt-disable-message = Don’t ask me again.
diff --git a/comm/calendar/locales/en-US/calendar/calendar-editable-item.ftl b/comm/calendar/locales/en-US/calendar/calendar-editable-item.ftl
new file mode 100644
index 0000000000..6b7f6236e0
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-editable-item.ftl
@@ -0,0 +1,42 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-editable-item-privacy-icon-private =
+ .alt = Privacy: Private Event
+
+calendar-editable-item-privacy-icon-confidential =
+ .alt = Privacy: Show Time and Date Only
+
+calendar-editable-item-recurrence =
+ .alt = Recurring
+
+calendar-editable-item-recurrence-exception =
+ .alt = Recurrence exception
+
+calendar-editable-item-todo-icon-task =
+ .alt = Task
+
+calendar-editable-item-todo-icon-completed-task =
+ .alt = Completed task
+
+calendar-editable-item-multiday-event-icon-start =
+ .alt = Multiple-day event begins
+
+calendar-editable-item-multiday-event-icon-continue =
+ .alt = Multiple-day event continues
+
+calendar-editable-item-multiday-event-icon-end =
+ .alt = Multiple-day event ends
+
+calendar-editable-item-reminder-icon-alarm =
+ .alt = A reminder alert is scheduled
+
+calendar-editable-item-reminder-icon-suppressed-alarm =
+ .alt = A reminder alert is scheduled but currently suppressed
+
+calendar-editable-item-reminder-icon-email =
+ .alt = A reminder email is scheduled
+
+calendar-editable-item-reminder-icon-audio =
+ .alt = A reminder audio alert is scheduled
diff --git a/comm/calendar/locales/en-US/calendar/calendar-event-dialog-reminder.ftl b/comm/calendar/locales/en-US/calendar/calendar-event-dialog-reminder.ftl
new file mode 100644
index 0000000000..cd4946439d
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-event-dialog-reminder.ftl
@@ -0,0 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-event-reminder-icon-display =
+ .alt = Show an Alert
+
+calendar-event-reminder-icon-email =
+ .alt = Send an Email
+
+calendar-event-reminder-icon-audio =
+ .alt = Play an audio Alert
diff --git a/comm/calendar/locales/en-US/calendar/calendar-ics-file-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-ics-file-dialog.ftl
new file mode 100644
index 0000000000..e532ce8b12
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-ics-file-dialog.ftl
@@ -0,0 +1,61 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-ics-file-window-title = Import Calendar Events and Tasks
+
+calendar-ics-file-dialog-import-event-button-label = Import Event
+calendar-ics-file-dialog-import-task-button-label = Import Task
+
+calendar-ics-file-dialog-2 =
+ .buttonlabelaccept = Import All
+
+calendar-ics-file-accept-button-ok-label = OK
+calendar-ics-file-cancel-button-close-label = Close
+
+calendar-ics-file-dialog-message-2 = Import from file:
+calendar-ics-file-dialog-calendar-menu-label = Import into calendar:
+
+calendar-ics-file-dialog-items-loading-message =
+ .value = Loading items…
+
+calendar-ics-file-dialog-search-input =
+ .placeholder = Filter items…
+
+calendar-ics-file-dialog-sort-start-ascending =
+ .label = Sort by start date (first to last)
+calendar-ics-file-dialog-sort-start-descending =
+ .label = Sort by start date (last to first)
+# "A > Z" is used as a concise way to say "alphabetical order".
+# You may replace it with something appropriate to your language.
+calendar-ics-file-dialog-sort-title-ascending =
+ .label = Sort by title (A > Z)
+# "Z > A" is used as a concise way to say "reverse alphabetical order".
+# You may replace it with something appropriate to your language.
+calendar-ics-file-dialog-sort-title-descending =
+ .label = Sort by title (Z > A)
+
+calendar-ics-file-dialog-progress-message = Importing…
+
+calendar-ics-file-import-success = Successfully imported!
+calendar-ics-file-import-error = An error occurred and the import failed.
+
+calendar-ics-file-import-complete = Import complete.
+
+# Variables:
+# $duplicatesCount (Number) - Number of items already existing in the target calendar.
+calendar-ics-file-import-duplicates =
+ { $duplicatesCount ->
+ [one] One item was ignored since it already exists in the destination calendar.
+ *[other] { $duplicatesCount } items were ignored since they already exist in the destination calendar.
+ }
+
+# Variables:
+# $errorsCount (Number) - Number of errors while importing ics file.
+calendar-ics-file-import-errors =
+ { $errorsCount ->
+ [one] One item failed to import. Check the Error Console for details.
+ *[other] { $errorsCount } items failed to import. Check the Error Console for details.
+ }
+
+calendar-ics-file-dialog-no-calendars = There are no calendars that can import events or tasks.
diff --git a/comm/calendar/locales/en-US/calendar/calendar-invitation-panel.ftl b/comm/calendar/locales/en-US/calendar/calendar-invitation-panel.ftl
new file mode 100644
index 0000000000..04583cc5e9
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-invitation-panel.ftl
@@ -0,0 +1,137 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-invitation-panel-status-new = You have been invited to this event.
+
+calendar-invitation-panel-status-processed = This event has already been added to your calendar.
+
+calendar-invitation-panel-status-updateminor = This message contains an update for this event.
+
+calendar-invitation-panel-status-updatemajor = This message contains an update for this event. You should re-confirm your attendance.
+
+calendar-invitation-panel-status-cancelled = This message contains a cancellation for this event.
+
+calendar-invitation-panel-status-cancelled-notfound = This message contains a cancellation for an event not found on your calendar.
+
+# Variables:
+# $organizer (String) - The participant that cancelled the invitation.
+calendar-invitation-panel-intro-cancel = { $organizer } has cancelled:
+
+# Variables:
+# $summary (String) - A short summary or title of the event.
+calendar-invitation-panel-title = { $summary }
+
+calendar-invitation-panel-view-button = View
+
+calendar-invitation-panel-update-button = Update
+
+calendar-invitation-panel-delete-button = Delete
+
+calendar-invitation-panel-accept-button = Yes
+
+calendar-invitation-panel-decline-button = No
+
+calendar-invitation-panel-tentative-button = Maybe
+
+calendar-invitation-panel-more-button = More
+
+calendar-invitation-panel-menu-item-save-copy =
+ .label = Save a copy
+
+calendar-invitation-panel-menu-item-toggle-changes=
+ .label = Show Changes
+
+calendar-invitation-panel-prop-title-when = When:
+
+calendar-invitation-panel-prop-title-location = Location:
+
+# Example: Friday, September 16, 2022
+# Variables:
+# $startDate (String) - The date (without time) the event starts on.
+calendar-invitation-interval-all-day = { $startDate }
+
+# Example: September 16, 2022 – September 16, 2023
+# Variables:
+# $startMonth (String) - The month the interval starts.
+# $startDay (String) - The day of the month the interval starts.
+# $startYear (String) - The year the interval starts.
+# $endMonth (String) - The month the interval ends.
+# $endDay (String) - The day of the month the interval ends.
+# $endYear (String) - The year the interval ends.
+calendar-invitation-interval-all-day-between-years = { $startMonth } { $startDay }, { $startYear } – { $endMonth } { $endDay }, { $endYear }
+
+# Example: September 16 – 20, 2022
+# Variables:
+# $month (String) - The month the interval is in.
+# $startDay (String) - The day of the month the interval starts.
+# $endDay (String) - The day of the month the interval ends.
+# $year (String) - The year the interval is in.
+calendar-invitation-interval-all-day-in-month = { $month } { $startDay } – { $endDay }, { $year }
+
+# Example: September 16 – October 20, 2022
+# Variables:
+# $startMonth (String) - The month the interval starts.
+# $startDay (String) - The day of the month the interval starts.
+# $endMonth (String) - The month the interval ends.
+# $endDay (String) - The day of the month the interval ends.
+# $year (String) - The year the interval is in.
+calendar-invitation-interval-all-day-between-months = { $startMonth } { $startDay } – { $endMonth } { $endDay }, { $year }
+
+# Example: Friday, September 16, 2022 15:00 America/Port of Spain
+# Variables:
+# $startDate (String) - The date the interval starts.
+# $startTime (String) - The time the interval starts.
+# $timezone (String) - The timezone the interval is in.
+calendar-invitation-interval-same-date-time = { $startDate } <b>{ $startTime }</b> { $timezone }
+
+# Example: Friday, September 16, 2022 14:00 – 16:00 America/Port of Spain
+# Variables:
+# $startDate (String) - The date the interval starts.
+# $startTime (String) - The time the interval starts.
+# $endTime (String) - The time the interval ends.
+# $timezone (String) - The timezone the interval is in.
+calendar-invitation-interval-same-day = { $startDate } <b>{ $startTime }</b> – <b>{ $endTime }</b> { $timezone }
+
+# Example: Friday, September 16, 2022 14:00 – Tuesday, September 20, 2022 16:00 America/Port of Spain
+# Variables:
+# $startDate (String) - The date the interval starts.
+# $startTime (String) - The time the interval starts.
+# $endDate (String) - The date the interval ends.
+# $endTime (String) - The time the interval ends.
+# $timezone (String) - The timezone the interval is in.
+calendar-invitation-interval-several-days = { $startDate } <b>{ $startTime }</b> – { $endDate } <b>{ $endTime }</b> { $timezone }
+
+calendar-invitation-panel-prop-title-recurrence = Repeats:
+
+calendar-invitation-panel-prop-title-attendees = Attendees:
+
+calendar-invitation-panel-prop-title-description = Description:
+
+# Variables:
+# $count (Number) - The number of attendees with the "ACCEPTED" participation status.
+calendar-invitation-panel-partstat-accepted = { $count } yes
+
+# Variables:
+# $count (Number) - The number of attendees with the "DECLINED" participation status.
+calendar-invitation-panel-partstat-declined = { $count } no
+
+# Variables:
+# $count (Number) - The number of attendees with the "TENTATIVE" participation status.
+calendar-invitation-panel-partstat-tentative = { $count } maybe
+
+# Variables:
+# $count (Number) - The number of attendees with the "NEEDS-ACTION" participation status.
+calendar-invitation-panel-partstat-needs-action = { $count } pending
+
+# Variables:
+# $count (Number) - The total number of attendees.
+calendar-invitation-panel-partstat-total = { $count } participants
+
+calendar-invitation-panel-prop-title-attachments = Attachments:
+
+calendar-invitation-change-indicator-removed = Removed
+
+calendar-invitation-change-indicator-added = New
+
+calendar-invitation-change-indicator-modified = Changed
diff --git a/comm/calendar/locales/en-US/calendar/calendar-invitations-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-invitations-dialog.ftl
new file mode 100644
index 0000000000..def5d5642a
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-invitations-dialog.ftl
@@ -0,0 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-invitation-current-participation-status-icon-accepted =
+ .alt = Currently accepted
+
+calendar-invitation-current-participation-status-icon-declined =
+ .alt = Currently declined
+
+calendar-invitation-current-participation-status-icon-needs-action =
+ .alt = Currently undecided
diff --git a/comm/calendar/locales/en-US/calendar/calendar-itip-identity-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-itip-identity-dialog.ftl
new file mode 100644
index 0000000000..76024d1922
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-itip-identity-dialog.ftl
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-itip-identity-dialog-title = Party crashing?
+
+calendar-itip-identity-warning = You are not on the guest list yet.
+
+calendar-itip-identity-label = Respond as:
+
+calendar-itip-identity-label-none = Associate this event with:
diff --git a/comm/calendar/locales/en-US/calendar/calendar-print.ftl b/comm/calendar/locales/en-US/calendar/calendar-print.ftl
new file mode 100644
index 0000000000..8a0e4250e6
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-print.ftl
@@ -0,0 +1,20 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-print-layout-label = Layout
+calendar-print-layout-list = List
+calendar-print-layout-month-grid = Monthly Grid
+calendar-print-layout-week-planner = Weekly Planner
+
+calendar-print-filter-label = What to Print
+calendar-print-filter-events = Events
+calendar-print-filter-tasks = Tasks
+calendar-print-filter-completedtasks = Completed tasks
+calendar-print-filter-taskswithnoduedate = Tasks with no due date
+
+calendar-print-range-from = From
+calendar-print-range-to = To
+
+calendar-print-back-button = Back
+calendar-print-next-button = Next
diff --git a/comm/calendar/locales/en-US/calendar/calendar-recurrence-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-recurrence-dialog.ftl
new file mode 100644
index 0000000000..5ed1e580ae
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-recurrence-dialog.ftl
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-recurrence-preview-label = Preview
+
+calendar-recurrence-next = Next Month
+
+calendar-recurrence-previous = Previous Month
+
+calendar-recurrence-today = Today
diff --git a/comm/calendar/locales/en-US/calendar/calendar-summary-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-summary-dialog.ftl
new file mode 100644
index 0000000000..85f054b8c7
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-summary-dialog.ftl
@@ -0,0 +1,21 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-summary-dialog-edit-button =
+ .label = Edit
+ .accesskey = E
+
+calendar-summary-dialog-edit-menu-button =
+ .label = Edit
+
+edit-button-context-menu-this-occurrence =
+ .label = Edit only this occurrence
+ .accesskey = t
+
+edit-button-context-menu-all-occurrences =
+ .label = Edit all occurrences
+ .accesskey = a
+
+description-context-menu-copy-link-text =
+ .label = Copy Link Text
diff --git a/comm/calendar/locales/en-US/calendar/calendar-uri-redirect-dialog.ftl b/comm/calendar/locales/en-US/calendar/calendar-uri-redirect-dialog.ftl
new file mode 100644
index 0000000000..3cbb7918e9
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-uri-redirect-dialog.ftl
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-uri-redirect-window-title = Calendar URI Redirect
+
+# Variables:
+# $calendarName (String) - Display name of the calendar.
+calendar-uri-redirect-description =
+ The server is redirecting the URI for the calendar β€œ{ $calendarName }”.
+ Accept the redirect and start using the new URI for this calendar?
+
+calendar-uri-redirect-original-uri-label = Current URI:
+
+calendar-uri-redirect-target-uri-label = Redirecting to new URI:
diff --git a/comm/calendar/locales/en-US/calendar/calendar-widgets.ftl b/comm/calendar/locales/en-US/calendar/calendar-widgets.ftl
new file mode 100644
index 0000000000..9ca3a75298
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/calendar-widgets.ftl
@@ -0,0 +1,145 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-deactivated-notification-events = All calendars are currently disabled. Enable an existing calendar or add a new one to create and edit events.
+calendar-deactivated-notification-tasks = All calendars are currently disabled. Enable an existing calendar or add a new one to create and edit tasks.
+
+calendar-notifications-label = Show notifications for upcoming events
+
+calendar-add-notification-button =
+ .label = Add notification
+
+## Side panel
+
+calendar-list-header = Calendars
+
+# Variables:
+# $calendarName (String) - Calendar name as given by the user
+calendar-no-reminders-tooltip =
+ .title = { $calendarName } calendar has been muted
+
+calendar-enable-button = Enable
+
+# Variables:
+# $calendarName (String) - Calendar name as given by the user
+calendar-list-item-context-button =
+ .title = { $calendarName } calendar options
+
+calendar-import-new-calendar = New Calendar…
+ .title = Create or subscribe to a new calendar
+
+calendar-refresh-calendars =
+ .title = Reload all calendars and synchronize changes
+
+calendar-new-event-primary-button = New Event
+
+calendar-new-task-primary-button = New Task
+
+## Calendar navigation
+
+calendar-nav-button-prev-tooltip-day =
+ .title = Previous Day
+ .accesskey = s
+
+calendar-nav-button-prev-tooltip-week =
+ .title = Previous Week
+ .accesskey = s
+
+calendar-nav-button-prev-tooltip-multiweek =
+ .title = Previous Week
+ .accesskey = s
+
+calendar-nav-button-prev-tooltip-month =
+ .title = Previous Month
+ .accesskey = s
+
+calendar-nav-button-prev-tooltip-year =
+ .title = Previous Year
+ .accesskey = s
+
+calendar-nav-button-next-tooltip-day =
+ .title = Next Day
+ .accesskey = x
+
+calendar-nav-button-next-tooltip-week =
+ .title = Next Week
+ .accesskey = x
+
+calendar-nav-button-next-tooltip-multiweek =
+ .title = Next Week
+ .accesskey = x
+
+calendar-nav-button-next-tooltip-month =
+ .title = Next Month
+ .accesskey = x
+
+calendar-nav-button-next-tooltip-year =
+ .title = Next Year
+ .accesskey = x
+
+calendar-today-button-tooltip =
+ .title = Go to Today
+
+calendar-view-toggle-day = Day
+ .title = Switch to day view
+
+calendar-view-toggle-week = Week
+ .title = Switch to week view
+
+calendar-view-toggle-multiweek = Multiweek
+ .title = Switch to multiweek view
+
+calendar-view-toggle-month = Month
+ .title = Switch to month view
+
+## Menu on calendar control bar
+
+calendar-control-bar-menu-button =
+ .title = Calendar layout options
+
+calendar-find-events-menu-option =
+ .label = Find Events Pane
+
+calendar-hide-weekends-option =
+ .label = Workweek days only
+
+calendar-define-workweek-option =
+ .label = Define workweek days
+
+calendar-show-tasks-calendar-option =
+ .label = Show tasks in calendar
+
+## Calendar Context Menu
+
+calendar-context-menu-previous-day =
+ .label = Previous Day
+ .accesskey = s
+
+calendar-context-menu-previous-week =
+ .label = Previous Week
+ .accesskey = s
+
+calendar-context-menu-previous-multiweek =
+ .label = Previous Week
+ .accesskey = s
+
+calendar-context-menu-previous-month =
+ .label = Previous Month
+ .accesskey = s
+
+calendar-context-menu-next-day =
+ .label = Next Day
+ .accesskey = x
+
+calendar-context-menu-next-week =
+ .label = Next Week
+ .accesskey = x
+
+calendar-context-menu-next-multiweek =
+ .label = Next Week
+ .accesskey = x
+
+calendar-context-menu-next-month =
+ .label = Next Month
+ .accesskey = x
diff --git a/comm/calendar/locales/en-US/calendar/category-dialog.ftl b/comm/calendar/locales/en-US/calendar/category-dialog.ftl
new file mode 100644
index 0000000000..69b9a70518
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/category-dialog.ftl
@@ -0,0 +1,8 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+category-name-label = Name
+
+category-color-label =
+ .label = Use Color
diff --git a/comm/calendar/locales/en-US/calendar/preferences.ftl b/comm/calendar/locales/en-US/calendar/preferences.ftl
new file mode 100644
index 0000000000..83399c802f
--- /dev/null
+++ b/comm/calendar/locales/en-US/calendar/preferences.ftl
@@ -0,0 +1,237 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+calendar-title = Calendar
+calendar-title-reminder = Reminders
+calendar-title-notification = Notifications
+calendar-title-category = Categories
+
+dateformat-label =
+ .value = Date Text Format:
+ .accesskey = D
+
+# $date (String) - the formatted example date
+dateformat-long =
+ .label = Long: { $date }
+
+# $date (String) - the formatted example date
+dateformat-short =
+ .label = Short: { $date }
+
+use-system-timezone-radio-button =
+ .label = Use system timezone
+set-timezone-manually-radio-button =
+ .label = Set timezone manually
+
+timezone-label =
+ .value = Timezone:
+
+weekstart-label =
+ .value = Start the week on:
+ .accesskey = r
+
+day-1-name =
+ .label = Sunday
+day-2-name =
+ .label = Monday
+day-3-name =
+ .label = Tuesday
+day-4-name =
+ .label = Wednesday
+day-5-name =
+ .label = Thursday
+day-6-name =
+ .label = Friday
+day-7-name =
+ .label = Saturday
+
+show-weeknumber-label =
+ .label = Show week number in views and minimonth
+ .accesskey = n
+
+workdays-label =
+ .value = Workweek days:
+
+day-1-checkbox =
+ .label = Sun
+ .accesskey = S
+day-2-checkbox =
+ .label = Mon
+ .accesskey = M
+day-3-checkbox =
+ .label = Tue
+ .accesskey = T
+day-4-checkbox =
+ .label = Wed
+ .accesskey = W
+day-5-checkbox =
+ .label = Thu
+ .accesskey = h
+day-6-checkbox =
+ .label = Fri
+ .accesskey = F
+day-7-checkbox =
+ .label = Sat
+ .accesskey = a
+
+dayweek-legend = Day and Week Views
+
+visible-hours-label =
+ .value = Show:
+ .accesskey = o
+
+visible-hours-end-label =
+ .value = hours at a time
+
+day-start-label =
+ .value = Day starts at:
+ .accesskey = D
+
+day-end-label =
+ .value = Day ends at:
+ .accesskey = y
+
+midnight-label =
+ .label = Midnight
+noon-label =
+ .label = Noon
+
+location-checkbox =
+ .label = Show location
+ .accesskey = L
+
+multiweek-legend = Multiweek View
+
+number-of-weeks-label =
+ .value = Number of weeks to show (including previous weeks):
+ .accesskey = e
+
+week-0-label =
+ .label = none
+week-1-label =
+ .label = 1 week
+week-2-label =
+ .label = 2 weeks
+week-3-label =
+ .label = 3 weeks
+week-4-label =
+ .label = 4 weeks
+week-5-label =
+ .label = 5 weeks
+week-6-label =
+ .label = 6 weeks
+
+previous-weeks-label =
+ .value = Previous weeks to show:
+ .accesskey = P
+
+todaypane-legend = Today Pane
+
+agenda-days =
+ .value = The agenda shows:
+ .accesskey = g
+
+event-task-legend = Events and Tasks
+
+default-length-label =
+ .value = Default Event and Task Length:
+ .accesskey = E
+
+task-start-label =
+ .value = Start Date:
+
+task-start-1-label =
+ .label = None
+task-start-2-label =
+ .label = Start of Day
+task-start-3-label =
+ .label = End of Day
+task-start-4-label =
+ .label = Tomorrow
+task-start-5-label =
+ .label = Next Week
+task-start-6-label =
+ .label = Relative to Current Time
+task-start-7-label =
+ .label = Relative to Start
+task-start-8-label =
+ .label = Relative to Next Hour
+
+task-due-label =
+ .value = Due Date:
+
+edit-intab-label =
+ .label = Edit events and tasks in a tab instead of in a dialog window.
+ .accesskey = t
+
+prompt-delete-label =
+ .label = Prompt before deleting events and tasks.
+ .accesskey = V
+
+reminder-legend = When a Reminder is Due:
+
+reminder-play-checkbox =
+ .label = Play a sound
+ .accesskey = s
+
+reminder-play-alarm-button =
+ .label = Play
+ .accesskey = P
+
+reminder-default-sound-label =
+ .label = Use default sound
+ .accesskey = d
+
+reminder-custom-sound-label =
+ .label = Use the following sound file
+ .accesskey = U
+
+reminder-browse-sound-label =
+ .label = Browse…
+ .accesskey = B
+
+reminder-dialog-label =
+ .label = Show the reminder dialog
+ .accesskey = x
+
+missed-reminder-label =
+ .label = Show missed reminders for writable calendars
+ .accesskey = m
+
+reminder-default-legend = Reminder Defaults
+
+default-snooze-label =
+ .value = Default Snooze Length:
+ .accesskey = S
+
+event-alarm-label =
+ .value = Default reminder setting for events:
+ .accesskey = e
+
+alarm-on-label =
+ .label = On
+alarm-off-label =
+ .label = Off
+
+task-alarm-label =
+ .value = Default reminder setting for tasks:
+ .accesskey = a
+
+event-alarm-time-label =
+ .value = Default time a reminder is set before an event:
+ .accesskey = u
+
+task-alarm-time-label =
+ .value = Default time a reminder is set before a task:
+ .accesskey = o
+
+calendar-notifications-customize-label = Notifications can be customized for each calendar in the calendar’s properties window.
+
+category-new-label = New Category
+
+category-edit-label = Edit Category
+
+category-overwrite-title = Warning: Duplicate name
+category-overwrite = A category already exists with that name. Do you want to overwrite it?
+category-blank-warning = You must enter a category name.
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-alarms.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-alarms.properties
new file mode 100644
index 0000000000..1bf6e56f4c
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-alarms.properties
@@ -0,0 +1,39 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (reminderCustomTitle):
+# %1$S = unit, %2$S = reminderCustomOrigin
+# Example: "3 minutes" "before the task starts"
+reminderCustomTitle=%1$S %2$S
+reminderTitleAtStartEvent=The moment the event starts
+reminderTitleAtStartTask=The moment the task starts
+reminderTitleAtEndEvent=The moment the event ends
+reminderTitleAtEndTask=The moment the task ends
+
+# LOCALIZATION NOTE (reminderSnoozeOkA11y)
+# This string is not seen in the UI, it is read by screen readers when the user
+# focuses the "OK" button in the "Snooze for..." popup of the alarm dialog.
+# %1$S = any of unit*
+reminderSnoozeOkA11y=Snooze reminder for %1$S
+
+reminderCustomOriginBeginBeforeEvent=before the event starts
+reminderCustomOriginBeginAfterEvent=after the event starts
+reminderCustomOriginEndBeforeEvent=before the event ends
+reminderCustomOriginEndAfterEvent=after the event ends
+reminderCustomOriginBeginBeforeTask=before the task starts
+reminderCustomOriginBeginAfterTask=after the task starts
+reminderCustomOriginEndBeforeTask=before the task ends
+reminderCustomOriginEndAfterTask=after the task ends
+
+reminderErrorMaxCountReachedEvent=The selected calendar has a limitation of #1 reminder per event.;The selected calendar has a limitation of #1 reminders per event.
+reminderErrorMaxCountReachedTask=The selected calendar has a limitation of #1 reminder per task.;The selected calendar has a limitation of #1 reminders per task.
+
+# LOCALIZATION NOTE (reminderReadonlyNotification)
+# This notification will be presented in the alarm dialog if reminders for not
+# writable items/calendars are displayed.
+# %1$S - localized value of calendar.alarm.snoozeallfor.label (defined in calendar.dtd)
+reminderReadonlyNotification=Reminders for read-only calendars currently cannot be snoozed but only dismissed - the button '%1$S' will only snooze reminders for writable calendars.
+# LOCALIZATION NOTE (reminderDisabledSnoozeButtonTooltip)
+# This tooltip is only displayed, if the button is disabled
+reminderDisabledSnoozeButtonTooltip=Snoozing of a reminder is not supported for read-only calendars
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog-attendees.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog-attendees.properties
new file mode 100644
index 0000000000..4b55f1d7ec
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog-attendees.properties
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+event.attendee.role.required = Required Attendee
+event.attendee.role.optional = Optional Attendee
+event.attendee.role.nonparticipant = Non Participant
+event.attendee.role.chair = Chair
+event.attendee.role.unknown = Unknown Attendee (%1$S)
+
+event.attendee.usertype.individual = Individual
+event.attendee.usertype.group = Group
+event.attendee.usertype.resource = Resource
+event.attendee.usertype.room = Room
+event.attendee.usertype.unknown = Unknown Type (%1$S)
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
new file mode 100644
index 0000000000..bbe94ecb6f
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
@@ -0,0 +1,418 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY event.title.label "Edit Item" >
+
+<!ENTITY event.dialog.keepDurationButton.tooltip "Keep the duration when changing the end date">
+<!ENTITY event.dialog.keepDurationButton.accesskey "K">
+
+<!ENTITY newevent.from.label "From" >
+<!ENTITY newevent.to.label "To" >
+
+<!ENTITY newevent.status.label "Status" >
+<!ENTITY newevent.status.accesskey "S" >
+<!ENTITY newevent.eventStatus.none.label "Not specified" >
+<!ENTITY newevent.eventStatus.none.accesskey "o" >
+<!ENTITY newevent.todoStatus.none.label "Not specified" >
+<!ENTITY newevent.eventStatus.cancelled.label "Canceled" >
+<!ENTITY newevent.eventStatus.cancelled.accesskey "n" >
+<!ENTITY newevent.todoStatus.cancelled.label "Canceled" >
+<!ENTITY newevent.status.tentative.label "Tentative" >
+<!ENTITY newevent.status.tentative.accesskey "T" >
+<!ENTITY newevent.status.confirmed.label "Confirmed" >
+<!ENTITY newevent.status.confirmed.accesskey "C" >
+<!ENTITY newevent.status.needsaction.label "Needs Action" >
+<!ENTITY newevent.status.inprogress.label "In Process" >
+<!ENTITY newevent.status.completed.label "Completed on" >
+
+<!-- The following entity is for New Task dialog only -->
+<!ENTITY newtodo.percentcomplete.label "&#37; complete">
+
+<!-- LOCALIZATON NOTE(event.attendees.notify.label,event.attendees.notifyundisclosed.label,
+ event.attendees.disallowcounter.label)
+ - These three labels are displayed side by side in the event dialog, make sure
+ - they still fit in. -->
+<!ENTITY event.attendees.notify.label "Notify attendees">
+<!ENTITY event.attendees.notify.accesskey "f">
+<!ENTITY event.attendees.notifyundisclosed.label "Separate invitation per attendee">
+<!ENTITY event.attendees.notifyundisclosed.accesskey "x">
+<!ENTITY event.attendees.notifyundisclosed.tooltip "This option sends one invitation email per attendee. Each invitation only contains the recipient attendee so that other attendee identities are not disclosed.">
+<!ENTITY event.attendees.disallowcounter.label "Disallow counter">
+<!ENTITY event.attendees.disallowcounter.accesskey "a">
+<!ENTITY event.attendees.disallowcounter.tooltip "Indicates that you will not accept counterproposals">
+
+<!-- Keyboard Shortcuts -->
+<!ENTITY event.dialog.new.message.key2 "N">
+<!ENTITY event.dialog.close.key "W">
+<!ENTITY event.dialog.save.key "S">
+<!ENTITY event.dialog.saveandclose.key "L">
+<!ENTITY event.dialog.undo.key "Z">
+<!ENTITY event.dialog.redo.key "Y">
+<!ENTITY event.dialog.cut.key "X">
+<!ENTITY event.dialog.copy.key "C">
+<!ENTITY event.dialog.paste.key "V">
+<!ENTITY event.dialog.select.all.key "A">
+
+<!-- Menubar -->
+<!ENTITY event.menu.item.new.label "New">
+<!ENTITY event.menu.item.new.accesskey "N">
+<!ENTITY event.menu.item.new.event.label "Event">
+<!ENTITY event.menu.item.new.event.accesskey "E">
+<!ENTITY event.menu.item.new.task.label "Task">
+<!ENTITY event.menu.item.new.task.accesskey "T">
+<!ENTITY event.menu.item.new.message.label "Message">
+<!ENTITY event.menu.item.new.message.accesskey "M">
+<!ENTITY event.menu.item.new.contact.label "Address Book Contact">
+<!ENTITY event.menu.item.new.contact.accesskey "C">
+<!ENTITY event.menu.item.close.label "Close">
+<!ENTITY event.menu.item.close.accesskey "C">
+
+<!-- LOCALIZATION NOTE
+ - event.menu.item.save.accesskey is used for the "Save" menu item
+ - when editing events/tasks in a dialog window.
+ - event.menu.item.save.tab.accesskey is used for the "Save" menu item
+ - when editing events/tasks in a tab. -->
+<!ENTITY event.menu.item.save.label "Save">
+<!ENTITY event.menu.item.save.accesskey "S">
+<!ENTITY event.menu.item.save.tab.accesskey "a">
+
+<!-- LOCALIZATION NOTE
+ - event.menu.item.saveandclose.accesskey is used for "Save and Close"
+ - menu item when editing events/tasks in a dialog window.
+ - event.menu.item.saveandclose.tab.accesskey is used for "Save and Close"
+ - when editing events/tasks in a tab. -->
+<!ENTITY event.menu.item.saveandclose.label "Save and Close">
+<!ENTITY event.menu.item.saveandclose.accesskey "l">
+<!ENTITY event.menu.item.saveandclose.tab.accesskey "z">
+
+<!ENTITY event.menu.item.delete.label "Delete…">
+<!ENTITY event.menu.item.delete.accesskey "D">
+
+<!ENTITY event.menu.edit.label "Edit">
+<!ENTITY event.menu.edit.accesskey "E">
+<!ENTITY event.menu.edit.undo.label "Undo">
+<!ENTITY event.menu.edit.undo.accesskey "U">
+<!ENTITY event.menu.edit.redo.label "Redo">
+<!ENTITY event.menu.edit.redo.accesskey "R">
+<!ENTITY event.menu.edit.cut.label "Cut">
+<!ENTITY event.menu.edit.cut.accesskey "t">
+<!ENTITY event.menu.edit.copy.label "Copy">
+<!ENTITY event.menu.edit.copy.accesskey "C">
+<!ENTITY event.menu.edit.paste.label "Paste">
+<!ENTITY event.menu.edit.paste.accesskey "P">
+<!ENTITY event.menu.edit.select.all.label "Select All">
+<!ENTITY event.menu.edit.select.all.accesskey "A">
+
+<!ENTITY event.menu.view.label "View">
+<!ENTITY event.menu.view.accesskey "V">
+<!ENTITY event.menu.view.toolbars.label "Toolbars">
+<!ENTITY event.menu.view.toolbars.accesskey "T">
+<!ENTITY event.menu.view.toolbars.event.label "Event Toolbar">
+<!ENTITY event.menu.view.toolbars.event.accesskey "E">
+<!ENTITY event.menu.view.toolbars.customize.label "Customize…">
+<!ENTITY event.menu.view.toolbars.customize.accesskey "C">
+
+<!ENTITY event.menu.options.label "Options">
+<!ENTITY event.menu.options.accesskey "O">
+<!ENTITY event.menu.options.attendees.label "Invite Attendees…">
+<!ENTITY event.menu.options.attendees.accesskey "I">
+<!ENTITY event.menu.options.timezone2.label "Show Timezones">
+<!ENTITY event.menu.options.timezone2.accesskey "z">
+<!ENTITY event.menu.options.priority2.label "Priority">
+<!ENTITY event.menu.options.priority2.accesskey "y">
+<!ENTITY event.menu.options.priority.notspecified.label "Not specified">
+<!ENTITY event.menu.options.priority.notspecified.accesskey "o">
+<!ENTITY event.menu.options.priority.low.label "Low">
+<!ENTITY event.menu.options.priority.low.accesskey "L">
+<!ENTITY event.menu.options.priority.normal.label "Normal">
+<!ENTITY event.menu.options.priority.normal.accesskey "N">
+<!ENTITY event.menu.options.priority.high.label "High">
+<!ENTITY event.menu.options.priority.high.accesskey "H">
+<!ENTITY event.menu.options.privacy.label "Privacy">
+<!ENTITY event.menu.options.privacy.accesskey "P">
+<!ENTITY event.menu.options.privacy.public.label "Public Event">
+<!ENTITY event.menu.options.privacy.public.accesskey "u">
+<!ENTITY event.menu.options.privacy.confidential.label "Show Time and Date Only">
+<!ENTITY event.menu.options.privacy.confidential.accesskey "S">
+<!ENTITY event.menu.options.privacy.private.label "Private Event">
+<!ENTITY event.menu.options.privacy.private.accesskey "r">
+<!ENTITY event.menu.options.show.time.label "Show Time as">
+<!ENTITY event.menu.options.show.time.accesskey "T">
+<!ENTITY event.menu.options.show.time.busy.label "Busy">
+<!ENTITY event.menu.options.show.time.busy.accesskey "B">
+<!ENTITY event.menu.options.show.time.free.label "Free">
+<!ENTITY event.menu.options.show.time.free.accesskey "F">
+
+<!ENTITY event.invite.attendees.label "Invite Attendees…">
+<!ENTITY event.invite.attendees.accesskey "I">
+<!ENTITY event.email.attendees.label "Compose Email to All Attendees…">
+<!ENTITY event.email.attendees.accesskey "A">
+<!ENTITY event.email.tentative.attendees.label "Compose Email to Undecided Attendees…">
+<!ENTITY event.email.tentative.attendees.accesskey "U">
+<!ENTITY event.remove.attendees.label2 "Remove all attendees">
+<!ENTITY event.remove.attendees.accesskey "r">
+<!ENTITY event.remove.attendee.label "Remove attendee">
+<!ENTITY event.remove.attendee.accesskey "e">
+
+<!-- Toolbar -->
+<!ENTITY event.toolbar.save.label2 "Save">
+<!ENTITY event.toolbar.saveandclose.label "Save and Close">
+<!ENTITY event.toolbar.delete.label "Delete">
+<!ENTITY event.toolbar.attendees.label "Invite Attendees">
+<!ENTITY event.toolbar.privacy.label "Privacy">
+
+<!ENTITY event.toolbar.save.tooltip2 "Save">
+<!ENTITY event.toolbar.saveandclose.tooltip "Save and Close">
+<!ENTITY event.toolbar.delete.tooltip "Delete">
+<!ENTITY event.toolbar.attendees.tooltip "Invite Attendees">
+<!ENTITY event.toolbar.attachments.tooltip "Add Attachments">
+<!ENTITY event.toolbar.privacy.tooltip "Change Privacy">
+<!ENTITY event.toolbar.priority.tooltip "Change Priority">
+<!ENTITY event.toolbar.status.tooltip "Change Status">
+<!ENTITY event.toolbar.freebusy.tooltip "Change Free/Busy time">
+
+<!-- Counter box -->
+<!-- LOCALIZATON NOTE(counter.button.*)
+ - This is only visible in the UI if you have received a counterproposal before and are going to
+ - reschedule the event from the imipbar in the email view. Clicking on the buttons will only
+ - populate the form fields in the dialog, there's no other immediate action on clicking like with
+ - the imip bar. Rescheduling will happen after clicking on save&close as usual. This screenshot
+ - illustrates how it might look like: https://bugzilla.mozilla.org/attachment.cgi?id=8810121 -->
+<!ENTITY counter.button.proposal.label "Apply proposal">
+<!ENTITY counter.button.proposal.accesskey "p">
+<!ENTITY counter.button.proposal.tooltip2 "Event fields will be filled in using the values from the counterproposal, only saving with or without additional changes will notify all attendees accordingly">
+<!ENTITY counter.button.original.label "Apply original data">
+<!ENTITY counter.button.original.accesskey "r">
+<!ENTITY counter.button.original.tooltip2 "The fields will be set to the values from the original event, before the counterproposal was made">
+
+<!-- Main page -->
+<!ENTITY event.title.textbox.label "Title:" >
+<!ENTITY event.title.textbox.accesskey "I">
+<!ENTITY event.location.label "Location:" >
+<!ENTITY event.location.accesskey "L">
+<!ENTITY event.categories.label "Category:">
+<!ENTITY event.categories.accesskey "y">
+<!ENTITY event.categories.textbox.label "Add a new category" >
+<!ENTITY event.calendar.label "Calendar:" >
+<!ENTITY event.calendar.accesskey "C">
+<!ENTITY event.attendees.label "Attendees:" >
+<!ENTITY event.attendees.accesskey "n">
+<!ENTITY event.alldayevent.label "All day Event" >
+<!ENTITY event.alldayevent.accesskey "d">
+<!ENTITY event.from.label "Start:" >
+<!ENTITY event.from.accesskey "S">
+<!ENTITY task.from.label "Start:" >
+<!ENTITY task.from.accesskey "S">
+<!ENTITY event.to.label "End:" >
+<!ENTITY event.to.accesskey "u">
+<!ENTITY task.to.label "Due Date:" >
+<!ENTITY task.to.accesskey "u">
+<!ENTITY task.status.label "Status:" >
+<!ENTITY task.status.accesskey "a">
+<!ENTITY event.repeat.label "Repeat:" >
+<!ENTITY event.repeat.accesskey "R">
+<!ENTITY event.until.label "Until:">
+<!ENTITY event.until.accesskey "B">
+<!ENTITY event.reminder.label "Reminder:" >
+<!ENTITY event.reminder.accesskey "m">
+<!ENTITY event.description.label "Description:" >
+<!ENTITY event.description.accesskey "p">
+<!ENTITY event.attachments.label "Attachments:" >
+<!ENTITY event.attachments.accesskey "h" >
+<!ENTITY event.attachments.menubutton.label "Attach">
+<!ENTITY event.attachments.menubutton.accesskey "A">
+<!ENTITY event.attachments.url.label "Webpage…">
+<!ENTITY event.attachments.url.accesskey "W">
+<!ENTITY event.attachments.popup.remove.label "Remove" >
+<!ENTITY event.attachments.popup.remove.accesskey "R" >
+<!ENTITY event.attachments.popup.open.label "Open" >
+<!ENTITY event.attachments.popup.open.accesskey "O" >
+<!ENTITY event.attachments.popup.removeAll.label "Remove All" >
+<!ENTITY event.attachments.popup.removeAll.accesskey "A" >
+<!ENTITY event.attachments.popup.attachPage.label "Attach Webpage…" >
+<!ENTITY event.attachments.popup.attachPage.accesskey "g" >
+<!ENTITY event.url.label "Related Link:" >
+<!ENTITY event.priority2.label "Priority:">
+
+<!ENTITY event.reminder.none.label "No reminder " >
+<!ENTITY event.reminder.0minutes.before.label "0 minutes before" >
+<!ENTITY event.reminder.5minutes.before.label "5 minutes before" >
+<!ENTITY event.reminder.15minutes.before.label "15 minutes before" >
+<!ENTITY event.reminder.30minutes.before.label "30 minutes before" >
+<!ENTITY event.reminder.1hour.before.label "1 hour before" >
+<!ENTITY event.reminder.2hours.before.label "2 hours before" >
+<!ENTITY event.reminder.12hours.before.label "12 hours before" >
+<!ENTITY event.reminder.1day.before.label "1 day before" >
+<!ENTITY event.reminder.2days.before.label "2 days before" >
+<!ENTITY event.reminder.1week.before.label "1 week before" >
+<!ENTITY event.reminder.custom.label "Custom…" >
+
+<!ENTITY event.reminder.multiple.label "Multiple Reminders…" >
+
+<!ENTITY event.statusbarpanel.freebusy.label "Time as:">
+<!ENTITY event.statusbarpanel.privacy.label "Privacy:">
+
+<!-- Recurrence dialog -->
+<!ENTITY recurrence.title.label "Edit Recurrence">
+
+<!ENTITY event.repeat.does.not.repeat.label "Does not repeat">
+<!ENTITY event.repeat.daily.label "Daily">
+<!ENTITY event.repeat.weekly.label "Weekly">
+<!ENTITY event.repeat.every.weekday.label "Every Weekday">
+<!ENTITY event.repeat.bi.weekly.label "Bi-weekly">
+<!ENTITY event.repeat.monthly.label "Monthly">
+<!ENTITY event.repeat.yearly.label "Yearly">
+<!ENTITY event.repeat.custom.label "Custom…">
+
+<!ENTITY event.recurrence.pattern.label "Recurrence pattern">
+<!ENTITY event.recurrence.occurs.label "Repeat" >
+<!ENTITY event.recurrence.day.label "daily" >
+<!ENTITY event.recurrence.week.label "weekly" >
+<!ENTITY event.recurrence.month.label "monthly" >
+<!ENTITY event.recurrence.year.label "annually" >
+
+<!ENTITY event.recurrence.pattern.every.label "Every" >
+<!ENTITY repeat.units.days.both "Day(s)" >
+<!ENTITY event.recurrence.pattern.every.weekday.label "Every weekday" >
+
+<!ENTITY event.recurrence.pattern.weekly.every.label "Every" >
+<!ENTITY repeat.units.weeks.both "Week(s)" >
+<!ENTITY event.recurrence.on.label "On:" >
+
+<!ENTITY event.recurrence.pattern.monthly.every.label "Every" >
+<!ENTITY repeat.units.months.both "Month(s)" >
+<!ENTITY event.recurrence.monthly.every.label "Every" >
+<!ENTITY event.recurrence.monthly.first.label "The First">
+<!ENTITY event.recurrence.monthly.second.label "The Second">
+<!ENTITY event.recurrence.monthly.third.label "The Third">
+<!ENTITY event.recurrence.monthly.fourth.label "The Fourth">
+<!ENTITY event.recurrence.monthly.fifth.label "The Fifth">
+<!ENTITY event.recurrence.monthly.last.label "The Last">
+<!ENTITY event.recurrence.pattern.monthly.week.1.label "Sunday" >
+<!ENTITY event.recurrence.pattern.monthly.week.2.label "Monday" >
+<!ENTITY event.recurrence.pattern.monthly.week.3.label "Tuesday" >
+<!ENTITY event.recurrence.pattern.monthly.week.4.label "Wednesday" >
+<!ENTITY event.recurrence.pattern.monthly.week.5.label "Thursday" >
+<!ENTITY event.recurrence.pattern.monthly.week.6.label "Friday" >
+<!ENTITY event.recurrence.pattern.monthly.week.7.label "Saturday" >
+<!ENTITY event.recurrence.repeat.dayofmonth.label "Day of the month">
+<!ENTITY event.recurrence.repeat.recur.label "Recur on day(s)">
+
+<!ENTITY event.recurrence.every.label "Every:" >
+<!ENTITY repeat.units.years.both "Year(s)" >
+<!ENTITY event.recurrence.pattern.yearly.every.month.label "Every" >
+
+<!-- LOCALIZATON NOTE
+ Some languages use a preposition when describing dates:
+ Portuguese: 6 de Setembro
+ English: 6 [of] September
+ event.recurrence.pattern.yearly.of.label is "of" in
+ Edit recurrence window -> Recurrence pattern -> Repeat yearly
+-->
+<!ENTITY event.recurrence.pattern.yearly.of.label "" >
+
+<!ENTITY event.recurrence.pattern.yearly.month.1.label "January" >
+<!ENTITY event.recurrence.pattern.yearly.month.2.label "February" >
+<!ENTITY event.recurrence.pattern.yearly.month.3.label "March" >
+<!ENTITY event.recurrence.pattern.yearly.month.4.label "April" >
+<!ENTITY event.recurrence.pattern.yearly.month.5.label "May" >
+<!ENTITY event.recurrence.pattern.yearly.month.6.label "June" >
+<!ENTITY event.recurrence.pattern.yearly.month.7.label "July" >
+<!ENTITY event.recurrence.pattern.yearly.month.8.label "August" >
+<!ENTITY event.recurrence.pattern.yearly.month.9.label "September" >
+<!ENTITY event.recurrence.pattern.yearly.month.10.label "October" >
+<!ENTITY event.recurrence.pattern.yearly.month.11.label "November" >
+<!ENTITY event.recurrence.pattern.yearly.month.12.label "December" >
+<!ENTITY event.recurrence.yearly.every.label "Every">
+<!ENTITY event.recurrence.yearly.first.label "The First">
+<!ENTITY event.recurrence.yearly.second.label "The Second">
+<!ENTITY event.recurrence.yearly.third.label "The Third">
+<!ENTITY event.recurrence.yearly.fourth.label "The Fourth">
+<!ENTITY event.recurrence.yearly.fifth.label "The Fifth">
+<!ENTITY event.recurrence.yearly.last.label "The Last">
+<!ENTITY event.recurrence.pattern.yearly.week.1.label "Sunday" >
+<!ENTITY event.recurrence.pattern.yearly.week.2.label "Monday" >
+<!ENTITY event.recurrence.pattern.yearly.week.3.label "Tuesday" >
+<!ENTITY event.recurrence.pattern.yearly.week.4.label "Wednesday" >
+<!ENTITY event.recurrence.pattern.yearly.week.5.label "Thursday" >
+<!ENTITY event.recurrence.pattern.yearly.week.6.label "Friday" >
+<!ENTITY event.recurrence.pattern.yearly.week.7.label "Saturday" >
+<!ENTITY event.recurrence.pattern.yearly.day.label "day" >
+<!ENTITY event.recurrence.of.label "of" >
+<!ENTITY event.recurrence.pattern.yearly.month2.1.label "January" >
+<!ENTITY event.recurrence.pattern.yearly.month2.2.label "February" >
+<!ENTITY event.recurrence.pattern.yearly.month2.3.label "March" >
+<!ENTITY event.recurrence.pattern.yearly.month2.4.label "April" >
+<!ENTITY event.recurrence.pattern.yearly.month2.5.label "May" >
+<!ENTITY event.recurrence.pattern.yearly.month2.6.label "June" >
+<!ENTITY event.recurrence.pattern.yearly.month2.7.label "July" >
+<!ENTITY event.recurrence.pattern.yearly.month2.8.label "August" >
+<!ENTITY event.recurrence.pattern.yearly.month2.9.label "September" >
+<!ENTITY event.recurrence.pattern.yearly.month2.10.label "October" >
+<!ENTITY event.recurrence.pattern.yearly.month2.11.label "November" >
+<!ENTITY event.recurrence.pattern.yearly.month2.12.label "December" >
+
+<!ENTITY event.recurrence.range.label "Range of recurrence">
+<!ENTITY event.recurrence.forever.label "No end date" >
+<!ENTITY event.recurrence.repeat.for.label "Create" >
+<!ENTITY event.recurrence.appointments.label "Appointment(s)" >
+<!ENTITY event.repeat.until.label "Repeat until" >
+
+<!-- Attendees dialog -->
+<!ENTITY invite.title.label "Invite Attendees">
+<!ENTITY event.organizer.label "Organizer">
+<!ENTITY event.freebusy.suggest.slot "Suggest time slot:">
+<!ENTITY event.freebusy.button.next.slot "Next slot">
+<!ENTITY event.freebusy.button.previous.slot "Previous slot">
+<!ENTITY event.freebusy.zoom "Zoom:">
+<!ENTITY event.freebusy.legend.free "Free" >
+<!ENTITY event.freebusy.legend.busy "Busy" >
+<!ENTITY event.freebusy.legend.busy_tentative "Tentative" >
+<!ENTITY event.freebusy.legend.busy_unavailable "Out of Office" >
+<!ENTITY event.freebusy.legend.unknown "No Information" >
+<!ENTITY event.attendee.role.required "Required Attendee">
+<!ENTITY event.attendee.role.optional "Optional Attendee">
+<!ENTITY event.attendee.role.chair "Chair">
+<!ENTITY event.attendee.role.nonparticipant "Non Participant">
+<!ENTITY event.attendee.usertype.individual "Individual">
+<!ENTITY event.attendee.usertype.group "Group">
+<!ENTITY event.attendee.usertype.resource "Resource">
+<!ENTITY event.attendee.usertype.room "Room">
+<!ENTITY event.attendee.usertype.unknown "Unknown">
+
+<!-- Timezone dialog -->
+<!ENTITY timezone.title.label "Please Specify the Timezone">
+<!ENTITY event.timezone.custom.label "More Timezones…">
+
+<!-- Read-Only dialog -->
+<!ENTITY read.only.general.label "General">
+<!ENTITY read.only.title.label "Title:">
+<!ENTITY read.only.calendar.label "Calendar:">
+<!ENTITY read.only.event.start.label "Start Date:">
+<!ENTITY read.only.task.start.label "Start Date:">
+<!ENTITY read.only.event.end.label "End Date:">
+<!ENTITY read.only.task.due.label "Due Date:">
+<!ENTITY read.only.repeat.label "Repeat:">
+<!ENTITY read.only.location.label "Location:">
+<!ENTITY read.only.category.label "Category:">
+<!ENTITY read.only.organizer.label "Organizer:">
+<!ENTITY read.only.reminder.label "Reminder:">
+<!ENTITY read.only.attachments.label "Attachments:">
+<!ENTITY read.only.attendees.label "Attendees">
+<!ENTITY read.only.description.label "Description">
+<!ENTITY read.only.link.label "Related Link">
+
+<!-- Summary dialog -->
+<!ENTITY summary.dialog.saveclose.label "Save and Close">
+<!ENTITY summary.dialog.saveclose.tooltiptext "Save changes and close the window without changing the participation status and sending a response">
+<!ENTITY summary.dialog.accept.label "Accept">
+<!ENTITY summary.dialog.accept.tooltiptext "Accept the invitation">
+<!ENTITY summary.dialog.tentative.label "Tentative">
+<!ENTITY summary.dialog.tentative.tooltiptext "Accept the invitation tentatively">
+<!ENTITY summary.dialog.decline.label "Decline">
+<!ENTITY summary.dialog.decline.tooltiptext "Decline the invitation">
+<!ENTITY summary.dialog.dontsend.label "Do not send a response">
+<!ENTITY summary.dialog.dontsend.tooltiptext "Change your participation status without sending a reply to the organizer and close the window">
+<!ENTITY summary.dialog.send.label "Send a response now">
+<!ENTITY summary.dialog.send.tooltiptext "Send out a response to the organizer and close the window">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.properties
new file mode 100644
index 0000000000..6f1592242d
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.properties
@@ -0,0 +1,541 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (dailyEveryNth):
+# Edit recurrence window -> Recurrence pattern -> Daily repeat rules
+# #1 - number
+# e.g. "every 4 days"
+dailyEveryNth=every day;every #1 days
+repeatDetailsRuleDaily4=every weekday
+
+# LOCALIZATION NOTE (weeklyNthOnNounclass...)
+# Edit recurrence window -> Recurrence pattern -> Weekly repeat rules
+# Translate these strings according to noun class/gender of weekday (%1$S)
+# set in 'repeadDetailsDay...Nounclass' strings.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add others strings with suffix 3, 4,... for others noun classes if your
+# language need them. In this case, corresponding strings must be added for
+# others rule strings with 'Nounclass...' suffix and corresponding values
+# "nounclass..." must be written in 'repeatDetailsDayxNounclass' strings.
+# %1$S - weekday (one or more)
+# #2 - week interval
+# e.g. "every 3 weeks on Tuesday, Wednesday and Thursday
+weeklyNthOnNounclass1=every %1$S;every #2 weeks on %1$S
+weeklyNthOnNounclass2=every %1$S;every #2 weeks on %1$S
+
+# LOCALIZATION NOTE (weeklyEveryNth):
+# Edit recurrence window -> Recurrence pattern -> Weekly repeat rules
+# #1 - interval
+# e.g. "every 5 weeks"
+weeklyEveryNth=every week;every #1 weeks
+
+# LOCALIZATION NOTE ('repeatDetailsDay...' and 'repeatDetailsDay...Nounclass'):
+# Week days names and week days noun classes (feminine/masculine grammatical
+# gender) for languages that need different localization when weekdays nouns
+# have different noun classes (genders).
+# For every weekday, in 'repeatDetailsDay...Nounclass' strings write:
+# "nounclass1" for languages with grammatical genders -> MASCULINE gender;
+# for languages with noun classes -> a noun class;
+# for languages without noun classes or grammatical gender.
+#
+# "nounclass2" for languages with grammatical genders -> FEMININE gender;
+# for languages with noun classes -> a different noun class.
+#
+# "nounclass3", "nounclass4" and so on for languages that need more than two
+# noun classes for weekdays. In this case add corresponding
+# rule string with "Nounclass..." suffix and ordinal string
+# "repeatOrdinalxNounclass..."
+# Will be used rule strings with "Nounclass..." suffix corresponding to the
+# following strings if there is a weekday in the rule string.
+repeatDetailsDay1=Sunday
+repeatDetailsDay1Nounclass=nounclass1
+repeatDetailsDay2=Monday
+repeatDetailsDay2Nounclass=nounclass1
+repeatDetailsDay3=Tuesday
+repeatDetailsDay3Nounclass=nounclass1
+repeatDetailsDay4=Wednesday
+repeatDetailsDay4Nounclass=nounclass1
+repeatDetailsDay5=Thursday
+repeatDetailsDay5Nounclass=nounclass1
+repeatDetailsDay6=Friday
+repeatDetailsDay6Nounclass=nounclass1
+repeatDetailsDay7=Saturday
+repeatDetailsDay7Nounclass=nounclass1
+
+# LOCALIZATION NOTE (repeatDetailsAnd)
+# Used to show a number of weekdays in a list
+# i.e. "Sunday, Monday, Tuesday " + and + " Wednesday"
+repeatDetailsAnd=and
+
+# LOCALIZATION NOTE (monthlyRuleNthOfEveryNounclass...):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# Translate these strings according to noun class/gender of weekday (%2$S)
+# set in 'repeadDetailsDay...Nounclass' strings.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add others strings with suffix 3, 4,... for others noun classes if your
+# language need them. In this case, corresponding strings must be added for
+# others rule strings with 'Nounclass...' suffix and corresponding values
+# "nounclass..." must be written in 'repeatDetailsDayxNounclass' strings.
+# %1$S - list of weekdays with ordinal, article and noun class/gender
+# (ordinal and weekday of every element in the list follow the order
+# and the rule of ordinalWeekdayOrder string)
+# #2 - interval
+# e.g. "the first Monday and the last Friday of every 3 months"
+monthlyRuleNthOfEveryNounclass1=%1$S of every month;%1$S of every #2 months
+monthlyRuleNthOfEveryNounclass2=%1$S of every month;%1$S of every #2 months
+
+# LOCALIZATION NOTE (ordinalWeekdayOrder):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# This string allows to change the order of the elements "ordinal" and
+# "weekday" (or to insert a word between them) for the argument %1$S of the
+# string monthlyRuleNthOfEveryNounclass...
+# Without changing this string, the order is that one required from most
+# languages: ordinal + weekday (e.g. "'the first' 'Monday' of every 2 months").
+# %1$S - ordinal with article
+# %2$S - weekday noun
+# e.g. "'the first' 'Monday'"
+# DONT_TRANSLATE: Make sure there are no extra words in this property, just variables.
+ordinalWeekdayOrder=%1$S %2$S
+
+# LOCALIZATION NOTE (monthlyEveryOfEveryNounclass...):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# Translate these strings according to noun class/gender of weekday (%1$S)
+# set in 'repeadDetailsDay...Nounclass' strings.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add others strings with suffix 3, 4,... for others noun classes if your
+# language need them. In this case, corresponding strings must be added for
+# others rule strings with 'Nounclass...' suffix and corresponding values
+# "nounclass..." must be written in 'repeatDetailsDayxNounclass' strings.
+# %1$S - list of single weekdays and/or weekdays with ordinal, article and
+# noun class/gender when rule contains also specific day in the month
+# #2 - interval
+# e.g. "every Monday, Tuesday and the second Sunday of every month"
+monthlyEveryOfEveryNounclass1=every %1$S of every month;every %1$S of every #2 months
+monthlyEveryOfEveryNounclass2=every %1$S of every month;every %1$S of every #2 months
+
+# LOCALIZATION NOTE (monthlyDaysOfNth_day):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# %1$S - day of month or a sequence of days of month, possibly followed by an ordinal symbol
+# (depending on the string dayOrdinalSymbol in dateFormat.properties) separated with commas;
+# e.g. "days 3, 6 and 9" or "days 3rd, 6th and 9th"
+monthlyDaysOfNth_day=day %1$S;days %1$S
+
+# LOCALIZATION NOTE (monthlyDaysOfNth):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# %1$S - it's the string monthlyDaysOfNth_day: day of month or a sequence of days
+# of month, possibly followed by an ordinal symbol, separated with commas;
+# #2 - monthly interval
+# e.g. "days 3, 6, 9 and 12 of every 3 months"
+monthlyDaysOfNth=%1$S of every month;%1$S of every #2 months
+
+# LOCALIZATION NOTE (monthlyLastDayOfNth):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# %1$S - day of month
+# #2 - month interval
+# e.g. "the last day of every 3 months"
+monthlyLastDayOfNth=the last day of the month; the last day of every #1 months
+
+# LOCALIZATION NOTE (monthlyEveryDayOfNth):
+# Edit recurrence window -> Recurrence pattern -> Monthly repeat rules
+# #2 - month interval
+# e.g. "every day of the month every 4 months"
+monthlyEveryDayOfNth=every day of every month;every day of the month every #2 months
+
+# LOCALIZATION NOTE (repeatOrdinal...Nounclass...):
+# Ordinal numbers nouns for every noun class (grammatical genders) of weekdays
+# considered in 'repeatDetailsDayxNounclass' strings. For languages that need
+# localization according to genders or noun classes.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add 'repeatOrdinal...Nounclass' strings with suffix 3, 4 and so on for
+# languages with more than two noun classes for weekdays. In this case
+# must be added corresponding rule strings with 'Nounclass...' suffix and
+# corresponding values "nounclass..." must be written in
+# 'repeatDetailsDayxNounclass' strings.
+repeatOrdinal1Nounclass1=the first
+repeatOrdinal2Nounclass1=the second
+repeatOrdinal3Nounclass1=the third
+repeatOrdinal4Nounclass1=the fourth
+repeatOrdinal5Nounclass1=the fifth
+repeatOrdinal-1Nounclass1=the last
+repeatOrdinal1Nounclass2=the first
+repeatOrdinal2Nounclass2=the second
+repeatOrdinal3Nounclass2=the third
+repeatOrdinal4Nounclass2=the fourth
+repeatOrdinal5Nounclass2=the fifth
+repeatOrdinal-1Nounclass2=the last
+
+# LOCALIZATION NOTE (yearlyNthOn):
+# Edit recurrence window -> Recurrence pattern -> Yearly repeat rules
+# %1$S - month name
+# %2$S - day of month possibly followed by an ordinal symbol (depending on the string
+# dayOrdinalSymbol in dateFormat.properties)
+# #3 - yearly interval
+# e.g. "every 3 years on December 14"
+# "every 2 years on December 8th"
+yearlyNthOn=every %1$S %2$S;every #3 years on %1$S %2$S
+
+# LOCALIZATION NOTE (yearlyNthOnNthOfNounclass...):
+# Edit recurrence window -> Recurrence pattern -> Yearly repeat rules
+# Translate these strings according to noun class/gender of weekday (%2$S)
+# set in 'repeadDetailsDay...Nounclass' strings.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add others strings with suffix 3, 4,... for others noun classes if your
+# language need them. In this case, corresponding strings must be added for
+# others rule strings with 'Nounclass...' suffix and corresponding values
+# "nounclass..." must be written in 'repeatDetailsDayxNounclass' strings.
+# %1$S - ordinal with article and noun class/gender corresponding to weekday
+# %2$S - weekday
+# %3$S - month
+# #4 - yearly interval
+# e.g. "the second Monday of every March"
+# e.g "every 3 years the second Monday of March"
+yearlyNthOnNthOfNounclass1=%1$S %2$S of every %3$S;every #4 years on %1$S %2$S of %3$S
+yearlyNthOnNthOfNounclass2=%1$S %2$S of every %3$S;every #4 years on %1$S %2$S of %3$S
+
+# LOCALIZATION NOTE (yearlyOnEveryNthOfNthNounclass...):
+# Edit recurrence window -> Recurrence pattern -> Yearly repeat rules
+# Translate these strings according to noun class/gender of weekday (%1$S)
+# set in 'repeadDetailsDay...Nounclass' strings.
+# Nounclass1 <-> Masculine gender; Nounclass2 <-> Feminine gender.
+# Add others strings with suffix 3, 4,... for others noun classes if your
+# language need them. In this case, corresponding strings must be added for
+# others rule strings with 'Nounclass...' suffix and corresponding values
+# "nounclass..." must be written in 'repeatDetailsDayxNounclass' strings.
+# %1$S - weekday
+# %2$S - month
+# #3 - yearly interval
+# e.g. "every Thursday of March"
+# e.g "every 3 years on every Thursday of March"
+yearlyOnEveryNthOfNthNounclass1=every %1$S of %2$S;every #3 years on every %1$S of %2$S
+yearlyOnEveryNthOfNthNounclass2=every %1$S of %2$S;every #3 years on every %1$S of %2$S
+
+#LOCALIZATION NOTE (yearlyEveryDayOf):
+# Edit recurrence window -> Recurrence pattern -> Yearly repeat rules
+# This string describes part of a yearly rule which includes every day of a month.
+# %1$S - month
+# #2 - yearly interval
+# e.g. "every day of December"
+# e.g. "every 3 years every day of December"
+yearlyEveryDayOf=every day of %1$S;every #2 years every day of %1$S
+
+repeatDetailsMonth1=January
+repeatDetailsMonth2=February
+repeatDetailsMonth3=March
+repeatDetailsMonth4=April
+repeatDetailsMonth5=May
+repeatDetailsMonth6=June
+repeatDetailsMonth7=July
+repeatDetailsMonth8=August
+repeatDetailsMonth9=September
+repeatDetailsMonth10=October
+repeatDetailsMonth11=November
+repeatDetailsMonth12=December
+
+# LOCALIZATION NOTE (repeatCount):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# %3$S - event start time (e.g. hh:mm (PM/AM))
+# %4$S - event end time (e.g. hh:mm (PM/AM))
+# #5 - event occurrence times: number
+# e.g. with monthlyRuleNthOfEvery:
+# "Occurs the first Sunday of every 3 month
+# only on 1/1/2009"
+# from 5:00 PM to 6:00 PM"
+# "Occurs the first Sunday of every 3 month
+# effective 1/1/2009 for 5 times
+# from 5:00 PM to 6:00 PM"
+repeatCount=Occurs %1$S\neffective %2$S for #5 time\nfrom %3$S to %4$S.;Occurs %1$S\neffective %2$S for #5 times\nfrom %3$S to %4$S.
+
+# LOCALIZATION NOTE (repeatCountAllDay):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# #3 - event occurrence times: number
+# e.g. with monthlyRuleNthOfEvery:
+# "Occurs the first Sunday of every 3 month
+# only on 1/1/2009"
+# "Occurs the first Sunday of every 3 month
+# effective 1/1/2009 for 5 times"
+repeatCountAllDay=Occurs %1$S\neffective %2$S for #3 time.;Occurs %1$S\neffective %2$S for #3 times.
+
+# LOCALIZATION NOTE (repeatDetailsUntil):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# %3$S - event end date (e.g. mm/gg/yyyy)
+# %4$S - event start time (e.g. hh:mm (PM/AM))
+# %5$S - event end time (e.g. hh:mm (PM/AM))
+# e.g. with weeklyNthOn:
+# "Occurs every 2 weeks on Sunday and Friday
+# effective 1/1/2009 until 1/1/2010
+# from 5:00 PM to 6:00 PM"
+repeatDetailsUntil=Occurs %1$S\neffective %2$S until %3$S\nfrom %4$S to %5$S.
+
+# LOCALIZATION NOTE (repeatDetailsUntilAllDay):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# %3$S - event end date (e.g. mm/gg/yyyy)
+# e.g. with monthlyDaysOfNth and all day event:
+# "Occurs day 3 of every 5 month
+# effective 1/1/2009 until 1/1/2010"
+repeatDetailsUntilAllDay=Occurs %1$S\neffective %2$S until %3$S.
+
+# LOCALIZATION NOTE (repeatDetailsInfinite):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# %3$S - event start time (e.g. hh:mm (PM/AM))
+# %4$S - event end time (e.g. hh:mm (PM/AM))
+# e.g. with monthlyDaysOfNth:
+# "Occurs day 3 of every 5 month
+# effective 1/1/2009
+# from 5:00 PM to 6:00 PM"
+repeatDetailsInfinite=Occurs %1$S\neffective %2$S\nfrom %3$S to %4$S.
+
+# LOCALIZATION NOTE (repeatDetailsInfiniteAllDay):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# %1%$ - A rule string (see above). This is the first line of the link
+# %2%$ - event start date (e.g. mm/gg/yyyy)
+# e.g. with monthlyDaysOfNth and all day event:
+# "Occurs day 3 of every 5 month
+# effective 1/1/2009"
+repeatDetailsInfiniteAllDay=Occurs %1$S\neffective %2$S.
+
+# LOCALIZATION NOTE (monthlyLastDay):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# A monthly rule with one or more days of the month (monthlyDaysOfNth) and the
+# string "the last day" of the month.
+# e.g.: "Occurs day 15, 20, 25 and the last day of every 3 months"
+monthlyLastDay=the last day
+
+# LOCALIZATION NOTE (ruleTooComplex):
+# This string is shown in the repeat details area if our code can't handle the
+# complexity of the recurrence rule yet.
+ruleTooComplex=Click here for details
+
+# LOCALIZATION NOTE (ruleTooComplexSummary):
+# This string is shown in the event summary dialog if our code can't handle the
+# complexity of the recurrence rule yet.
+ruleTooComplexSummary=Repeat details unknown
+
+# differences between the dialog for an Event or a Task
+newEvent=New Event
+newTask=New Task
+itemMenuLabelEvent=Event
+itemMenuAccesskeyEvent2=T
+itemMenuLabelTask=Task
+itemMenuAccesskeyTask2=T
+
+emailSubjectReply=Re: %1$S
+
+# Link Location Dialog
+specifyLinkLocation=Please specify the link location
+enterLinkLocation=Enter a web page, or document location.
+
+summaryDueTaskLabel=Due:
+
+# Attach File Dialog
+attachViaFilelink=File using %1$S
+selectAFile=Please select the file(s) to attach
+removeCalendarsTitle=Remove Attachments
+
+# LOCALIZATION NOTE (removeAttachmentsText): Semi-colon list of plural forms for
+# prompting attachment removal.
+# See http://developer.mozilla.org/en/Localization_and_Plurals
+removeAttachmentsText=Do you really want to remove #1 attachment?;Do you really want to remove #1 attachments?
+
+# Recurrence Dialog Widget Order
+# LOCALIZATION NOTE: You can change the order of below params
+# Edit recurrence window -> Recurrence pattern -> Repeat monthly
+# %1$S - ordinal with article, %2$S - weekday
+# e.g. "the First Saturday"
+# DONT_TRANSLATE: Make sure there are no extra words in this property, just variables.
+monthlyOrder=%1$S %2$S
+
+# Edit recurrence window -> Recurrence pattern -> Repeat yearly
+# %1$S - day of month, %2$S - of, %3$S - month
+# e.g. "6 [of] September"
+# If you don't need %2$S in your locale - please put this on the third place.
+# DONT_TRANSLATE: Make sure there are no extra words in this property, just variables.
+yearlyOrder=%1$S %3$S %2$S
+
+# Edit recurrence window -> Recurrence pattern -> Repeat yearly
+# %1$S - ordinal with article, %2$S - weekday, %3$S - of, %4$S - month
+# e.g. "the First Saturday of September"
+# If you don't need %3$S in your locale - please put this on the third place.
+# DONT_TRANSLATE: Make sure there are no extra words in this property, just variables.
+yearlyOrder2=%1$S %2$S %3$S %4$S
+
+# LOCALIZATION NOTE (pluralForWeekdays):
+# This string allows to set the use of weekdays nouns in plural form for
+# languages that need them in sentences like "every Monday" or "every Sunday
+# of March" etc.
+# Rule strings involved by this setting are:
+# - weeklyNthOn (only the first part) e.g. "every Sunday"
+# - monthlyEveryOfEvery
+# e.g. "every Monday of every month;every Monday every 2 months"
+# - yearlyOnEveryNthOfNth
+# e.g. "every Friday of March;every 2 years on every Friday of March"
+# In your local write:
+# "true" if sentences like those above need weekday in plural form;
+# "false" if sentences like those above don't need weekday in plural form;
+pluralForWeekdays=false
+
+# LOCALIZATION NOTE (repeatDetailsDayxxxPlural):
+# Edit recurrence window -> Recurrence details link on Event/Task dialog window
+# Weekdays in plural form used inside sentences like "every Monday" or
+# "every Sunday of May" etc. for languages that need them.
+# These plurals will be used inside the following rule strings only if string
+# 'pluralForWeekdays' (see above) is set to "true":
+# - weeklyNthOn (only the first part) e.g. "every Sunday"
+# - monthlyEveryOfEvery
+# e.g. "every Monday of every month;every Monday every 2 months"
+# - yearlyOnEveryNthOfNth
+# e.g. "every Friday of March;every 2 years on every Friday of March"
+repeatDetailsDay1Plural=Sunday
+repeatDetailsDay2Plural=Monday
+repeatDetailsDay3Plural=Tuesday
+repeatDetailsDay4Plural=Wednesday
+repeatDetailsDay5Plural=Thursday
+repeatDetailsDay6Plural=Friday
+repeatDetailsDay7Plural=Saturday
+
+# LOCALIZATION NOTE (eventRecurrenceForeverLabel):
+# Edit/New Event dialog -> datepicker that sets the until date.
+# For recurring rules that repeat forever, this labels appears in the
+# datepicker, below the minimonth, as an option for the until date.
+eventRecurrenceForeverLabel=Forever
+
+# LOCALIZATION NOTE (eventRecurrenceMonthlyLastDayLabel):
+# Edit dialog recurrence -> Monthly Recurrence pattern -> Monthly daypicker
+# The label on the monthly daypicker's last button that allows to select
+# the last day of the month inside a BYMONTHDAY rule.
+eventRecurrenceMonthlyLastDayLabel=Last day
+
+# LOCALIZATION NOTE (counterSummaryAccepted) - this is only visible when opening the dialog from the
+# email summary view after receiving a counter message
+# %1$S - the name or email address of the replying attendee
+counterSummaryAccepted=%1$S has accepted the invitation, but made a counter proposal:
+
+# LOCALIZATION NOTE (counterSummaryDeclined) - this is only visible when opening the dialog from the
+# email summary view after receiving a counter message
+# %1$S - the name or email address of the replying attendee
+counterSummaryDeclined=%1$S has declined the invitation, but made a counter proposal:
+
+# LOCALIZATION NOTE (counterSummaryDelegated) - this is only visible when opening the dialog from the
+# email summary view after receiving a counter message
+# %1$S - the name or email address of the replying attendee
+counterSummaryDelegated=%1$S has delegated the invitation, but made a counter proposal:
+
+# LOCALIZATION NOTE (counterSummaryNeedsAction) - this is only visible when opening the dialog from the
+# email summary view after receiving a counter message
+# %1$S - the name or email address of the replying attendee
+counterSummaryNeedsAction=%1$S hasn't decided whether to participate and made a counter proposal:
+
+# LOCALIZATION NOTE (counterSummaryTentative) - this is only visible when opening the dialog from the
+# email summary view after receiving a counter message
+# %1$S - the name or email address of the replying attendee
+counterSummaryTentative=%1$S has accepted the invitation only tentatively and made a counter proposal:
+
+# LOCALIZATION NOTE (counterOnPreviousVersionNotification) - this is only visible when opening the
+# dialog from the email summary view after receiving a counter message
+counterOnPreviousVersionNotification=This is a counter proposal for a previous version of this event.
+
+# LOCALIZATION NOTE (counterOnCounterDisallowedNotification) - this is only visible when opening the
+# dialog from the email summary view after receiving a counter message
+counterOnCounterDisallowedNotification=You disallowed countering when sending out the invitation.
+
+# LOCALIZATION NOTE (eventAccepted) - this will be displayed as notification
+# in the summary dialog if the user has accepted the event invitation
+eventAccepted=You have accepted this invitation
+
+# LOCALIZATION NOTE (eventTentative) - this will be displayed as notification
+# in the summary dialog if the user has accepted the event invitation tentatively
+eventTentative=You have accepted this invitation tentatively
+
+# LOCALIZATION NOTE (eventDeclined) - this will be displayed as notification
+# in the summary dialog if the user has declined the event invitation
+eventDeclined=You have declined this invitation
+
+# LOCALIZATION NOTE (eventDelegated) - this will be displayed as notification
+# in the summary dialog if the user has delegated his/her participation to one
+# or more other participants (without attending / working on it his/herself)
+eventDelegated=You have delegated this invitation
+
+# LOCALIZATION NOTE (eventNeedsAction) - this will be displayed as notification
+# in the summary dialog if the user hasn't yet responded to an invitation
+eventNeedsAction=You haven't yet responded to this invitation
+
+# LOCALIZATION NOTE (taskAccepted) - this will be displayed as notification
+# in the summary dialog if the user has accepted the assigned task
+taskAccepted=You have accepted to work on this task
+
+# LOCALIZATION NOTE (taskTentative) - this will be displayed as notification
+# in the summary dialog if the user has accepted tentatively the assigned task
+taskTentative=You have tentatively accepted to work on this task
+
+# LOCALIZATION NOTE (taskDeclined) - this will be displayed as notification
+# in the summary dialog if the user has declined the assigned task
+taskDeclined=You have declined to work on this task
+
+# LOCALIZATION NOTE (taskDelegated) - this will be displayed as notification
+# in the summary dialog if the user has delegated his/her assignment to one or
+# more others (without attending / working on it his/herself)
+taskDelegated=You have delegated the work on this task
+
+# LOCALIZATION NOTE (taskNeedsAction) - this will be displayed as notification
+# in the summary dialog if the user hasn't yet responded to the task assignment
+taskNeedsAction=You haven't yet responded to this task assignment
+
+# LOCALIZATION NOTE (taskInProgress) - this will be displayed as notification
+# in the summary dialog if the user is working on an assigned task
+taskInProgress=You have started to work on this assigned task
+
+# LOCALIZATION NOTE (taskCompleted) - this will be displayed as notification
+# in the summary dialog if the user has completed the work on this assigned task
+taskCompleted=You have completed your work on this assigned task
+
+# LOCALIZATION NOTE (sendandcloseButtonLabel) - this is a runtime replacement for
+# event.toolbar.saveandclose.label in the event dialog/tab toolbar if attendees
+# will be notified on saving & closing
+sendandcloseButtonLabel=Send And Close
+
+# LOCALIZATION NOTE (sendandcloseButtonTooltip) - this is a runtime replacement for
+# event.toolbar.saveandclose.tooltip in the event dialog/tab toolbar if attendees
+# will be notified on saving & closing
+sendandcloseButtonTooltip=Notify attendees and close
+
+# LOCALIZATION NOTE (saveandsendButtonLabel) - this is a runtime replacement for
+# event.toolbar.save.label2 in the event dialog/tab toolbar if attendees
+# will be notified on saving
+saveandsendButtonLabel=Save And Send
+
+# LOCALIZATION NOTE (saveandsendButtonTooltip) - this is a runtime replacement
+# for event.toolbar.save.tooltip2 in the event dialog/tab toolbar if attendees
+# will be notified on saving
+saveandsendButtonTooltip=Save and notify attendees
+
+# LOCALIZATION NOTE (saveandsendMenuLabel) - this is a runtime replacement for
+# event.menu.item.save.label in the event dialog/tab toolbar if attendees
+# will be notified on saving
+saveandsendMenuLabel=Save and Send
+
+# LOCALIZATION NOTE (sendandcloseMenuLabel) - this is a runtime replacement for
+# event.menu.item.saveandclose.label in the event dialog/tab toolbar if attendees
+# will be notified on saving
+sendandcloseMenuLabel=Send and Close
+
+# LOCALIZATION NOTE (attendeesTabLabel) - this is a runtime replacement for
+# event.attendees.label defined in calendar-event-dialog.dtd and used in the
+# event dialog/tab as attendee tab label if an event has at least one attendee
+# %1$S - the number of attendee (1-n)
+attendeesTabLabel=Attendees (%1$S):
+
+# LOCALIZATION NOTE (attachmentsTabLabel) - this is a runtime replacement for
+# event.attachments.label defined in calendar-event-dialog.dtd and used in the
+# event dialog/tab as attendee tab label if an event has at least one attachment
+# %1$S - the number of attachments (1-n)
+attachmentsTabLabel=Attachments (%1$S):
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-extract.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-extract.properties
new file mode 100644
index 0000000000..89fe718758
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-extract.properties
@@ -0,0 +1,294 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE:
+# Strings here are used to create events and tasks with start and end times
+# based on email content.
+# None of the strings are displayed in the user interface.
+#
+# You don't have to fill all from.*, until.*, *.prefix and *.suffix patterns.
+# It's ok to leave some empty.
+# Please consider declensions and gender if your language has them.
+# Don't just translate directly. The number of variants doesn't have to be
+# the same as in en-US. All of 0, 1, 2, etc is allowed in patterns except alphabet
+# pattern. You can and should add language specific variants.
+#
+# There are two different ways to find a start time text in email:
+# 1) it matches a from.* pattern and does not have end.prefix or end.suffix next to it
+# 2) it matches until.* pattern and has start.prefix or start.suffix next to it
+# Similar inverse logic applies to end times.
+# These rules enable using prefix and suffix values with only start.* or only until.*
+# patterns localized for some languages and thus not having to repeat the same
+# values in both.
+#
+# Patterns are partially space-insensitive.
+# "deadline is" pattern will find both "deadlineis" and "deadline is"
+# but "deadlineis" won't find "deadline is" or "deadline is".
+# Therefore you should include all spaces that are valid within a pattern.
+
+# LOCALIZATION NOTE (start.prefix):
+# datetimes with these in front are extracted as start times
+# can be a list of values, separate variants by |
+start.prefix =
+
+# LOCALIZATION NOTE (start.suffix):
+# datetimes followed by these are extracted as start times
+start.suffix = by | until | to | - | till | til | and
+
+# LOCALIZATION NOTE (end.prefix):
+# datetimes with these in front are extracted as end times
+end.prefix = by | until | to | - | till | til | and | due: | due | ends | deadline is | deadline:
+
+# LOCALIZATION NOTE (end.suffix):
+# datetimes followed by these are extracted as end times
+# can be a list of values, separate variants by |
+end.suffix =
+
+# LOCALIZATION NOTE (no.datetime.prefix):
+# datetimes with these in front won't be used
+# specify full words here
+no.datetime.prefix = last week | sent | email | e-mail | instead of | > | unfortunately | in | not
+
+# LOCALIZATION NOTE (no.datetime.suffix):
+# datetimes followed by these won't be used
+no.datetime.suffix = floor | flr | : | email | e-mail | > | % | usd | dollars | $
+
+# LOCALIZATION NOTE (from.*):
+# can be a list of values, separate variants by |
+
+# LOCALIZATION NOTE (from.today):
+# must not be empty!
+from.today = today
+
+from.tomorrow = tomorrow
+# LOCALIZATION NOTE (until.*):
+# can be a list of values, separate variants by |
+until.tomorrow =
+
+# LOCALIZATION NOTE (from.ordinal.date):
+# #1 = matches numbers 1-31 and number.x
+# should not have "#1" as this would match any single number in email to a time
+from.ordinal.date = #1st | #1nd | #1rd | #1th
+
+# LOCALIZATION NOTE (until.ordinal.date):
+# #1 = matches numbers 1-31 and number.x
+until.ordinal.date =
+
+from.noon = noon
+until.noon =
+
+# LOCALIZATION NOTE (from.hour):
+# #1 = matches numbers 0-23 and number.0-number.23
+# should not have "#1" as this would match any single number in email to a time
+from.hour = at #1 | around #1 | #1 - | #1 to
+
+# LOCALIZATION NOTE (until.hour):
+# #1 = matches numbers 0-23 and number.0-number.23
+# should also list how to find end of a timeframe
+until.hour = - #1 | to #1 | until #1 | by #1
+
+# LOCALIZATION NOTE (from.hour.am):
+# #1 = matches numbers 0-23 and number.0-number.23
+from.hour.am = #1 am | #1 a.m
+
+# LOCALIZATION NOTE (until.hour.am):
+# #1 = matches numbers 0-23 and number.0-number.23
+# should also list how to find end of a timeframe
+until.hour.am =
+
+# LOCALIZATION NOTE (from.hour.pm):
+# #1 = matches numbers 0-23 and number.0-number.23
+from.hour.pm = #1 pm | #1 p.m | #1 p
+
+# LOCALIZATION NOTE (until.hour.pm):
+# #1 = matches numbers 0-23 and number.0-number.23
+# should also list how to find end of a timeframe
+until.hour.pm =
+
+# LOCALIZATION NOTE (from.half.hour.before):
+# denotes times 30 minutes before next full hour
+from.half.hour.before = half an hour before #1
+
+# LOCALIZATION NOTE (until.half.hour.before):
+# denotes times 30 minutes before next full hour
+until.half.hour.before =
+
+# LOCALIZATION NOTE (from.half.hour.after):
+# denotes times 30 minutes after last full hour
+from.half.hour.after = half past #1
+
+# LOCALIZATION NOTE (until.half.hour.after):
+# denotes times 30 minutes after last full hour
+until.half.hour.after =
+
+# LOCALIZATION NOTE (from.hour.minutes):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+from.hour.minutes = #1:#2 | at #1#2
+
+# LOCALIZATION NOTE (until.hour.minutes):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+until.hour.minutes =
+
+# LOCALIZATION NOTE (from.hour.minutes.am):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+from.hour.minutes.am = #1:#2 am | #1:#2 a.m
+
+# LOCALIZATION NOTE (until.hour.minutes.am):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+until.hour.minutes.am =
+
+# LOCALIZATION NOTE (from.hour.minutes.pm):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+from.hour.minutes.pm = #1:#2 pm | #1:#2 p.m | #1:#2 p
+
+# LOCALIZATION NOTE (until.hour.minutes.pm):
+# #1 = matches numbers 0-23
+# #2 = matches numbers 0-59
+until.hour.minutes.pm =
+
+# LOCALIZATION NOTE (from.monthname.day):
+# #1 = matches numbers 1-31 and number.x
+# #2 = matches monthname
+from.monthname.day = #1 #2 | #2 #1 | #2 #1st | #2 #1nd | #2 #1rd | #2 #1th | #1st of #2 | #1nd of #2 | #1rd of #2 | #1th of #2
+
+# LOCALIZATION NOTE (until.monthname.day):
+# #1 = matches numbers 1-31
+# #2 = matches monthname
+until.monthname.day =
+
+# LOCALIZATION NOTE (from.month.day):
+# #1 = matches numbers 1-31
+# #2 = matches numbers 1-12
+from.month.day = #2/#1
+
+# LOCALIZATION NOTE (until.month.day):
+# #1 = matches numbers 1-31 and number.x
+# #2 = matches numbers 1-12
+until.month.day =
+
+# LOCALIZATION NOTE (from.year.month.day):
+# #1 = matches numbers 1-31
+# #2 = matches numbers 1-12
+# #3 = matches 2/4 numbers
+from.year.month.day = #2/#1/#3 | #3/#2/#1 | #3-#2-#1
+
+# LOCALIZATION NOTE (until.year.month.day):
+# #1 = matches numbers 1-31
+# #2 = matches numbers 1-12
+# #3 = matches 2/4 numbers
+until.year.month.day =
+
+# LOCALIZATION NOTE (from.year.monthname.day):
+# #1 = matches numbers 1-31
+# #2 = matches monthname
+# #3 = matches 2/4 numbers
+from.year.monthname.day = #1 #2 #3 | #1st #2 #3 | #1nd #2 #3 | #1rd #2 #3 | #1th #2 #3 | #2 #1, #3 | #3-#2-#1
+
+# LOCALIZATION NOTE (until.year.monthname.day):
+# #1 = matches numbers 1-31
+# #2 = matches monthname
+# #3 = matches 2/4 numbers
+until.year.monthname.day =
+
+# LOCALIZATION NOTE (duration.*):
+# can be a list of values, separate variants by |
+
+# LOCALIZATION NOTE (duration.minutes):
+# #1 = matches 1/2 numbers and number.0 - and number.31
+duration.minutes = #1 minutes | #1 min | #1 mins
+
+# LOCALIZATION NOTE (duration.hours):
+# #1 = matches 1/2 numbers and number.0 - and number.31
+duration.hours = #1 hour | #1 hours
+
+# LOCALIZATION NOTE (duration.days):
+# #1 = matches 1/2 numbers and number.0 - and number.31
+duration.days = #1 days
+
+# LOCALIZATION NOTE (month.*):
+# can be a list of values, separate variants by |
+month.1 = january | jan | jan.
+month.2 = february | feb | feb.
+month.3 = march | mar | mar.
+month.4 = april | apr | apr.
+month.5 = may
+month.6 = june | jun | jun.
+month.7 = july | jul | jul.
+month.8 = august | aug | aug.
+month.9 = september | sep | sep. | sept.
+month.10 = october | oct | oct.
+month.11 = november | nov | nov.
+month.12 = december | dec | dec.
+
+# LOCALIZATION NOTE (from.weekday.*):
+# used to derive start date based on weekdays mentioned
+# can be a list of values, separate variants by |
+# LOCALIZATION NOTE (from.weekday.0):
+# Regardless of what the first day of the week is in your country, 0 is Sunday here.
+from.weekday.0 = sunday | sundays
+from.weekday.1 = monday | mondays
+from.weekday.2 = tuesday | tuesdays
+from.weekday.3 = wednesday | wednesdays
+from.weekday.4 = thursday | thursdays
+from.weekday.5 = friday | fridays
+from.weekday.6 = saturday | saturdays
+
+# LOCALIZATION NOTE (until.weekday.*):
+# used to derive end date based on weekdays mentioned
+# can be a list of values, separate variants by |
+# LOCALIZATION NOTE (until.weekday.0):
+# Regardless of what the first day of the week is in your country, 0 is Sunday here.
+until.weekday.0 =
+until.weekday.1 =
+until.weekday.2 =
+until.weekday.3 =
+until.weekday.4 =
+until.weekday.5 =
+until.weekday.6 =
+
+# LOCALIZATION NOTE (number.*):
+# used within other patterns to understand dates where day of month isn't written with digits
+# can be a list of values, separate variants by |
+number.0 = zero
+number.1 = one | first
+number.2 = two | second
+number.3 = three | third
+number.4 = four | fourth
+number.5 = five | fifth
+number.6 = six | sixth
+number.7 = seven | seventh
+number.8 = eight | eighth
+number.9 = nine | ninth
+number.10 = ten | tenth
+number.11 = eleven | eleventh
+number.12 = twelve | twelfth
+number.13 = thirteen | thirteenth
+number.14 = fourteen | fourteenth
+number.15 = fifteen | fifteenth
+number.16 = sixteen | sixteenth
+number.17 = seventeen | seventeenth
+number.18 = eighteen | eighteenth
+number.19 = nineteen | nineteenth
+number.20 = twenty | twentieth
+number.21 = twenty one | twenty first
+number.22 = twenty two | twenty second
+number.23 = twenty three | twenty third
+number.24 = twenty four | twenty fourth
+number.25 = twenty five | twenty fifth
+number.26 = twenty six | twenty sixth
+number.27 = twenty seven | twenty seventh
+number.28 = twenty eight | twenty eighth
+number.29 = twenty nine | twenty ninth
+number.30 = thirty | thirtieth
+number.31 = thirty one | thirty first
+
+# LOCALIZATION NOTE (alphabet):
+# list all lower and uppercase letters if your language has an alphabet
+# otherwise leave it empty
+alphabet = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.dtd b/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.dtd
new file mode 100644
index 0000000000..eb5b5e9f13
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.dtd
@@ -0,0 +1,13 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+-->
+
+<!-- Calendar Invitations Dialog -->
+<!ENTITY calendar.invitations.dialog.invitations.text "Invitations">
+<!ENTITY calendar.invitations.dialog.statusmessage.updating.text "Updating list of invitations.">
+<!ENTITY calendar.invitations.dialog.statusmessage.noinvitations.text "No unconfirmed invitations found.">
+
+<!-- Calendar Invitations List -->
+<!ENTITY calendar.invitations.list.accept.button.label "Accept">
+<!ENTITY calendar.invitations.list.decline.button.label "Decline">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.properties
new file mode 100644
index 0000000000..ff2b43899a
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-invitations-dialog.properties
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+allday-event=All day event
+recurrent-event=Repeating event
+location=Location: %S
+organizer=Organizer: %S
+attendee=Attendee: %S
+none=None
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.dtd b/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.dtd
new file mode 100644
index 0000000000..5864a9335f
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.dtd
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY buttons.occurrence.accesskey "t">
+<!ENTITY buttons.allfollowing.accesskey "f">
+<!ENTITY buttons.parent.accesskey "a">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.properties
new file mode 100644
index 0000000000..c3c9abae05
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar-occurrence-prompt.properties
@@ -0,0 +1,53 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+header.isrepeating.event.label=is a repeating event
+header.isrepeating.task.label=is a repeating task
+header.containsrepeating.event.label=contains repeating events
+header.containsrepeating.task.label=contains repeating tasks
+header.containsrepeating.mixed.label=contains repeating items of different type
+
+windowtitle.event.copy=Copy Repeating Event
+windowtitle.task.copy=Copy Repeating Task
+windowtitle.mixed.copy=Copy Repeating Items
+windowtitle.event.cut=Cut Repeating Event
+windowtitle.task.cut=Cut Repeating Task
+windowtitle.mixed.cut=Cut Repeating Items
+windowtitle.event.delete=Delete Repeating Event
+windowtitle.task.delete=Delete Repeating Task
+windowtitle.mixed.delete=Delete Repeating Items
+windowtitle.event.edit=Edit Repeating Event
+windowtitle.task.edit=Edit Repeating Task
+windowtitle.mixed.edit=Edit Repeating Items
+windowtitle.multipleitems=Selected items
+
+buttons.single.occurrence.copy.label=Copy only this occurrence
+buttons.single.occurrence.cut.label=Cut only this occurrence
+buttons.single.occurrence.delete.label=Delete only this occurrence
+buttons.single.occurrence.edit.label=Edit only this occurrence
+
+buttons.multiple.occurrence.copy.label=Copy only selected occurrences
+buttons.multiple.occurrence.cut.label=Cut only selected occurrences
+buttons.multiple.occurrence.delete.label=Delete only selected occurrences
+buttons.multiple.occurrence.edit.label=Edit only selected occurrences
+
+buttons.single.allfollowing.copy.label=Copy this and all future occurrences
+buttons.single.allfollowing.cut.label=Cut this and all future occurrences
+buttons.single.allfollowing.delete.label=Delete this and all future occurrences
+buttons.single.allfollowing.edit.label=Edit this and all future occurrences
+
+buttons.multiple.allfollowing.copy.label=Copy selected and all future occurrences
+buttons.multiple.allfollowing.cut.label=Cut selected and all future occurrences
+buttons.multiple.allfollowing.delete.label=Delete selected and all future occurrences
+buttons.multiple.allfollowing.edit.label=Edit selected and all future occurrences
+
+buttons.single.parent.copy.label=Copy all occurrences
+buttons.single.parent.cut.label=Cut all occurrences
+buttons.single.parent.delete.label=Delete all occurrences
+buttons.single.parent.edit.label=Edit all occurrences
+
+buttons.multiple.parent.copy.label=Copy all occurrences of selected items
+buttons.multiple.parent.cut.label=Cut all occurrences of selected items
+buttons.multiple.parent.delete.label=Delete all occurrences of selected items
+buttons.multiple.parent.edit.label=Edit all occurrences of selected items
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar.dtd b/comm/calendar/locales/en-US/chrome/calendar/calendar.dtd
new file mode 100644
index 0000000000..e8312f127e
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar.dtd
@@ -0,0 +1,354 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- General -->
+<!ENTITY calendar.calendar.label "Calendar">
+<!ENTITY calendar.calendar.accesskey "C">
+
+<!ENTITY calendar.newevent.button.tooltip "Create a new event" >
+<!ENTITY calendar.newtask.button.tooltip "Create a new task" >
+
+<!ENTITY calendar.unifinder.showcompletedtodos.label "Show completed Tasks">
+
+<!ENTITY calendar.today.button.label "Today">
+<!ENTITY calendar.tomorrow.button.label "Tomorrow">
+
+<!ENTITY calendar.events.filter.all.label "All Events">
+<!ENTITY calendar.events.filter.today.label "Today's Events">
+<!ENTITY calendar.events.filter.future.label "All Future Events">
+<!ENTITY calendar.events.filter.current.label "Currently Selected Day">
+<!ENTITY calendar.events.filter.currentview.label "Events in Current View">
+<!ENTITY calendar.events.filter.next7Days.label "Events in the Next 7 Days">
+<!ENTITY calendar.events.filter.next14Days.label "Events in the Next 14 Days">
+<!ENTITY calendar.events.filter.next31Days.label "Events in the Next 31 Days">
+<!ENTITY calendar.events.filter.thisCalendarMonth.label "Events in this Calendar Month">
+
+<!-- LOCALIZATION NOTE(calendar.unifinder.tree.done.tooltip)
+ - This label and tooltip is used for the column with the checkbox in the
+ - task tree view. -->
+<!ENTITY calendar.unifinder.tree.done.label "Done">
+<!ENTITY calendar.unifinder.tree.done.tooltip2 "Sort by completion">
+<!ENTITY calendar.unifinder.tree.priority.label "Priority">
+<!ENTITY calendar.unifinder.tree.priority.tooltip2 "Sort by priority">
+<!ENTITY calendar.unifinder.tree.title.label "Title">
+<!ENTITY calendar.unifinder.tree.title.tooltip2 "Sort by title">
+<!ENTITY calendar.unifinder.tree.percentcomplete.label "&#37; Complete">
+<!ENTITY calendar.unifinder.tree.percentcomplete.tooltip2 "Sort by &#37; complete">
+<!ENTITY calendar.unifinder.tree.startdate.label "Start">
+<!ENTITY calendar.unifinder.tree.startdate.tooltip2 "Sort by start date">
+<!ENTITY calendar.unifinder.tree.enddate.label "End">
+<!ENTITY calendar.unifinder.tree.enddate.tooltip2 "Sort by end date">
+<!ENTITY calendar.unifinder.tree.duedate.label "Due">
+<!ENTITY calendar.unifinder.tree.duedate.tooltip2 "Sort by due date">
+<!ENTITY calendar.unifinder.tree.completeddate.label "Completed">
+<!ENTITY calendar.unifinder.tree.completeddate.tooltip2 "Sort by completed date">
+<!ENTITY calendar.unifinder.tree.categories.label "Category">
+<!ENTITY calendar.unifinder.tree.categories.tooltip2 "Sort by category">
+<!ENTITY calendar.unifinder.tree.location.label "Location">
+<!ENTITY calendar.unifinder.tree.location.tooltip2 "Sort by location">
+<!ENTITY calendar.unifinder.tree.status.label "Status">
+<!ENTITY calendar.unifinder.tree.status.tooltip2 "Sort by status">
+<!ENTITY calendar.unifinder.tree.calendarname.label "Calendar Name">
+<!ENTITY calendar.unifinder.tree.calendarname.tooltip2 "Sort by calendar name">
+<!ENTITY calendar.unifinder.tree.duration.label "Due in">
+<!ENTITY calendar.unifinder.tree.duration.tooltip2 "Sort by time until due">
+<!ENTITY calendar.unifinder.close.tooltip "Close event search and event list">
+
+<!ENTITY calendar.today.button.tooltip "Go to Today" >
+<!ENTITY calendar.todaypane.button.tooltip "Show Today Pane" >
+
+<!ENTITY calendar.newevent.button.label "New Event" >
+<!ENTITY calendar.newtask.button.label "New Task" >
+
+<!ENTITY calendar.onlyworkday.checkbox.label "Workweek days only" >
+<!ENTITY calendar.onlyworkday.checkbox.accesskey "r" >
+<!ENTITY calendar.displaytodos.checkbox.label "Tasks in View" >
+<!ENTITY calendar.displaytodos.checkbox.accesskey "k" >
+<!ENTITY calendar.completedtasks.checkbox.label "Show completed Tasks" >
+<!ENTITY calendar.completedtasks.checkbox.accesskey "c" >
+
+<!ENTITY calendar.orientation.label "Rotate View" >
+<!ENTITY calendar.orientation.accesskey "o" >
+
+<!ENTITY calendar.search.options.searchfor " contain">
+
+<!ENTITY calendar.list.header.label "Calendar">
+
+<!ENTITY calendar.task.filter.title.label "Show">
+<!ENTITY calendar.task.filter.all.label "All">
+<!ENTITY calendar.task.filter.all.accesskey "A">
+<!ENTITY calendar.task.filter.today.label "Today">
+<!ENTITY calendar.task.filter.today.accesskey "T">
+<!ENTITY calendar.task.filter.next7days.label "Next Seven Days">
+<!ENTITY calendar.task.filter.next7days.accesskey "N">
+<!ENTITY calendar.task.filter.notstarted.label "Not Started Tasks">
+<!ENTITY calendar.task.filter.notstarted.accesskey "a">
+<!ENTITY calendar.task.filter.overdue.label "Overdue Tasks">
+<!ENTITY calendar.task.filter.overdue.accesskey "O">
+<!ENTITY calendar.task.filter.completed.label "Completed Tasks">
+<!ENTITY calendar.task.filter.completed.accesskey "C">
+<!ENTITY calendar.task.filter.open.label "Incomplete Tasks">
+<!ENTITY calendar.task.filter.open.accesskey "m">
+
+<!-- LOCALIZATION NOTE(calendar.task.filter.current.label)
+ "Current Tasks" will show all tasks, except those with a start date set
+ that is after today and after the selected date. If a task repeats, a
+ separate entry will be shown for each of the occurrences that happen on or
+ before today (or the selected date, whichever is later). -->
+<!ENTITY calendar.task.filter.current.label "Current Tasks">
+<!ENTITY calendar.task.filter.current.accesskey "u">
+
+<!ENTITY calendar.task-details.title.label "title">
+<!ENTITY calendar.task-details.organizer.label "from">
+<!ENTITY calendar.task-details.priority.label "priority">
+<!ENTITY calendar.task-details.priority.low.label "Low">
+<!ENTITY calendar.task-details.priority.normal.label "Normal">
+<!ENTITY calendar.task-details.priority.high.label "High">
+<!ENTITY calendar.task-details.status.label "status">
+<!ENTITY calendar.task-details.category.label "category">
+<!ENTITY calendar.task-details.repeat.label "repeat">
+<!ENTITY calendar.task-details.attachments.label "attachments">
+<!ENTITY calendar.task-details.start.label "start date">
+<!ENTITY calendar.task-details.due.label "due date">
+
+<!ENTITY calendar.task.category.button.tooltip "Categorize tasks">
+<!ENTITY calendar.task.complete.button.tooltip "Mark selected tasks completed">
+<!ENTITY calendar.task.priority.button.tooltip "Change the priority">
+
+<!ENTITY calendar.task.text-filter.textbox.emptytext.base1 "Filter tasks #1">
+<!ENTITY calendar.task.text-filter.textbox.emptytext.keylabel.nonmac "&lt;Ctrl+Shift+K&gt;">
+<!ENTITY calendar.task.text-filter.textbox.emptytext.keylabel.mac "&lt;&#x21E7;&#x2318;K&gt;">
+
+<!-- Context Menu -->
+<!ENTITY calendar.context.modifyorviewitem.label "Open">
+<!ENTITY calendar.context.modifyorviewitem.accesskey "O">
+<!ENTITY calendar.context.modifyorviewtask.label "Open Task…">
+<!ENTITY calendar.context.modifyorviewtask.accesskey "O">
+<!ENTITY calendar.context.newevent.label "New Event…">
+<!ENTITY calendar.context.newevent.accesskey "N">
+<!ENTITY calendar.context.newtodo.label "New Task…">
+<!ENTITY calendar.context.newtodo.accesskey "k">
+<!ENTITY calendar.context.deletetask.label "Delete Task">
+<!ENTITY calendar.context.deletetask.accesskey "l">
+<!ENTITY calendar.context.deleteevent.label "Delete Event">
+<!ENTITY calendar.context.deleteevent.accesskey "l">
+<!ENTITY calendar.context.cutevent.label "Cut">
+<!ENTITY calendar.context.cutevent.accesskey "t">
+<!ENTITY calendar.context.copyevent.label "Copy">
+<!ENTITY calendar.context.copyevent.accesskey "C">
+<!ENTITY calendar.context.pasteevent.label "Paste">
+<!ENTITY calendar.context.pasteevent.accesskey "P">
+<!ENTITY calendar.context.button.label "Today Pane">
+<!ENTITY calendar.context.button.accesskey "T">
+
+<!ENTITY calendar.context.attendance.menu.label "Attendance">
+<!ENTITY calendar.context.attendance.menu.accesskey "d">
+<!ENTITY calendar.context.attendance.occurrence.label "This Occurrence">
+<!ENTITY calendar.context.attendance.all2.label "Complete Series">
+<!ENTITY calendar.context.attendance.send.label "Send a notification now">
+<!ENTITY calendar.context.attendance.send.accesskey "S">
+<!ENTITY calendar.context.attendance.dontsend.label "Do not send a notification">
+<!ENTITY calendar.context.attendance.dontsend.accesskey "D">
+
+<!ENTITY calendar.context.attendance.occ.accepted.accesskey "A">
+<!ENTITY calendar.context.attendance.occ.accepted.label "Accepted">
+<!ENTITY calendar.context.attendance.occ.tentative.accesskey "y">
+<!ENTITY calendar.context.attendance.occ.tentative.label "Accepted tentatively">
+<!ENTITY calendar.context.attendance.occ.declined.accesskey "c">
+<!ENTITY calendar.context.attendance.occ.declined.label "Declined">
+<!ENTITY calendar.context.attendance.occ.delegated.accesskey "g">
+<!ENTITY calendar.context.attendance.occ.delegated.label "Delegated">
+<!ENTITY calendar.context.attendance.occ.needsaction.accesskey "S">
+<!ENTITY calendar.context.attendance.occ.needsaction.label "Still needs action">
+<!ENTITY calendar.context.attendance.occ.inprogress.accesskey "I">
+<!ENTITY calendar.context.attendance.occ.inprogress.label "In progress">
+<!ENTITY calendar.context.attendance.occ.completed.accesskey "C">
+<!ENTITY calendar.context.attendance.occ.completed.label "Completed">
+
+<!ENTITY calendar.context.attendance.all.accepted.accesskey "e">
+<!ENTITY calendar.context.attendance.all.accepted.label "Accepted">
+<!ENTITY calendar.context.attendance.all.tentative.accesskey "v">
+<!ENTITY calendar.context.attendance.all.tentative.label "Accepted tentatively">
+<!ENTITY calendar.context.attendance.all.declined.accesskey "d">
+<!ENTITY calendar.context.attendance.all.declined.label "Declined">
+<!ENTITY calendar.context.attendance.all.delegated.accesskey "l">
+<!ENTITY calendar.context.attendance.all.delegated.label "Delegated">
+<!ENTITY calendar.context.attendance.all.needsaction.accesskey "l">
+<!ENTITY calendar.context.attendance.all.needsaction.label "Still needs action">
+<!ENTITY calendar.context.attendance.all.inprogress.accesskey "p">
+<!ENTITY calendar.context.attendance.all.inprogress.label "In progress">
+<!ENTITY calendar.context.attendance.all.completed.accesskey "m">
+<!ENTITY calendar.context.attendance.all.completed.label "Completed">
+
+<!-- Task Context Menu -->
+<!ENTITY calendar.context.progress.label "Progress">
+<!ENTITY calendar.context.progress.accesskey "P">
+<!ENTITY calendar.context.priority.label "Priority">
+<!ENTITY calendar.context.priority.accesskey "r">
+<!ENTITY calendar.context.postpone.label "Postpone Task">
+<!ENTITY calendar.context.postpone.accesskey "s">
+
+<!ENTITY percnt "&#38;#37;" ><!--=percent sign-->
+
+<!ENTITY calendar.context.markcompleted.label "Mark Completed">
+<!ENTITY calendar.context.markcompleted.accesskey "o">
+
+<!ENTITY progress.level.0 "0&percnt; Completed">
+<!ENTITY progress.level.0.accesskey "0">
+<!ENTITY progress.level.25 "25&percnt; Completed">
+<!ENTITY progress.level.25.accesskey "2">
+<!ENTITY progress.level.50 "50&percnt; Completed">
+<!ENTITY progress.level.50.accesskey "5">
+<!ENTITY progress.level.75 "75&percnt; Completed">
+<!ENTITY progress.level.75.accesskey "7">
+<!ENTITY progress.level.100 "100&percnt; Completed">
+<!ENTITY progress.level.100.accesskey "1">
+
+<!ENTITY priority.level.none "Not specified">
+<!ENTITY priority.level.none.accesskey "s">
+<!ENTITY priority.level.low "Low">
+<!ENTITY priority.level.low.accesskey "L">
+<!ENTITY priority.level.normal "Normal">
+<!ENTITY priority.level.normal.accesskey "N">
+<!ENTITY priority.level.high "High">
+<!ENTITY priority.level.high.accesskey "H">
+
+<!ENTITY calendar.context.postpone.1hour.label "1 Hour">
+<!ENTITY calendar.context.postpone.1hour.accesskey "H">
+<!ENTITY calendar.context.postpone.1day.label "1 Day">
+<!ENTITY calendar.context.postpone.1day.accesskey "D">
+<!ENTITY calendar.context.postpone.1week.label "1 Week">
+<!ENTITY calendar.context.postpone.1week.accesskey "W">
+
+<!ENTITY calendar.copylink.label "Copy Link Location">
+<!ENTITY calendar.copylink.accesskey "C">
+
+<!-- Task View -->
+<!-- Note that the above *.context.* strings are currently used for the other
+ task action buttons -->
+<!ENTITY calendar.taskview.delete.label "Delete">
+
+<!-- Server Context Menu -->
+<!ENTITY calendar.context.newserver.label "New Calendar…">
+<!ENTITY calendar.context.newserver.accesskey "N">
+<!ENTITY calendar.context.findcalendar.label "Find Calendar…" >
+<!ENTITY calendar.context.findcalendar.accesskey "F" >
+<!ENTITY calendar.context.deleteserver2.label "Delete Calendar…">
+<!ENTITY calendar.context.deleteserver2.accesskey "D">
+
+<!-- LOCALIZATION NOTE (calendar.context.removeserver.label): Removing the
+ calendar is the general action of removing it, while deleting means to
+ clear the data and unsubscribing means just taking it out of the calendar
+ list. -->
+<!ENTITY calendar.context.removeserver.label "Remove Calendar…">
+<!ENTITY calendar.context.removeserver.accesskey "R">
+<!ENTITY calendar.context.unsubscribeserver.label "Unsubscribe Calendar…">
+<!ENTITY calendar.context.unsubscribeserver.accesskey "U">
+<!ENTITY calendar.context.publish.label "Publish Calendar…">
+<!ENTITY calendar.context.publish.accesskey "b">
+<!ENTITY calendar.context.export.label "Export Calendar…">
+<!ENTITY calendar.context.export.accesskey "E">
+<!ENTITY calendar.context.properties.label "Properties">
+<!ENTITY calendar.context.properties.accesskey "P">
+
+<!-- LOCALIZATION NOTE (calendar.context.showcalendar.accesskey)
+ This is the access key used for the showCalendar string -->
+<!ENTITY calendar.context.showcalendar.accesskey "h">
+
+<!-- LOCALIZATION NOTE (calendar.context.hidecalendar.accesskey)
+ This is the access key used for the hideCalendar string -->
+<!ENTITY calendar.context.hidecalendar.accesskey "H">
+
+<!-- LOCALIZATION NOTE (calendar.context.showonly.accesskey)
+ This is the access key used for the showOnlyCalendar string -->
+<!ENTITY calendar.context.showonly.accesskey "O">
+<!ENTITY calendar.context.showall.label "Show All Calendars">
+<!ENTITY calendar.context.showall.accesskey "A">
+
+<!ENTITY calendar.context.convertmenu.label "Convert To">
+<!ENTITY calendar.context.convertmenu.accesskey.mail "n">
+<!ENTITY calendar.context.convertmenu.accesskey.calendar "v">
+<!ENTITY calendar.context.convertmenu.event.label "Event…">
+<!ENTITY calendar.context.convertmenu.event.accesskey "E">
+<!ENTITY calendar.context.convertmenu.message.label "Message…">
+<!ENTITY calendar.context.convertmenu.message.accesskey "M">
+<!ENTITY calendar.context.convertmenu.task.label "Task…">
+<!ENTITY calendar.context.convertmenu.task.accesskey "T">
+
+<!ENTITY calendar.tasks.view.minimonth.label "Mini-Month">
+<!ENTITY calendar.tasks.view.minimonth.accesskey "M">
+
+<!ENTITY calendar.tasks.view.calendarlist.label "Calendar List">
+<!ENTITY calendar.tasks.view.calendarlist.accesskey "L">
+
+<!ENTITY calendar.tasks.view.filtertasks.label "Filter Tasks">
+<!ENTITY calendar.tasks.view.filtertasks.accesskey "F">
+
+<!-- Calendar Alarm Dialog -->
+
+<!ENTITY calendar.alarm.location.label "Location:" >
+<!ENTITY calendar.alarm.details.label "Details…" >
+
+<!ENTITY calendar.alarm.snoozefor.label "Snooze for" >
+<!ENTITY calendar.alarm.snoozeallfor.label "Snooze All for" >
+<!ENTITY calendar.alarm.title.label "Calendar Reminders" >
+<!ENTITY calendar.alarm.dismiss.label "Dismiss" >
+<!ENTITY calendar.alarm.dismissall.label "Dismiss All" >
+
+<!ENTITY calendar.alarm.snooze.5minutes.label "5 Minutes" >
+<!ENTITY calendar.alarm.snooze.10minutes.label "10 Minutes" >
+<!ENTITY calendar.alarm.snooze.15minutes.label "15 Minutes" >
+<!ENTITY calendar.alarm.snooze.30minutes.label "30 Minutes" >
+<!ENTITY calendar.alarm.snooze.45minutes.label "45 Minutes" >
+<!ENTITY calendar.alarm.snooze.1hour.label "1 Hour" >
+<!ENTITY calendar.alarm.snooze.2hours.label "2 Hours" >
+<!ENTITY calendar.alarm.snooze.1day.label "1 Day" >
+
+<!-- LOCALIZATION NOTE (calendar.alarm.snooze.cancel)
+ This string is not seen in the UI, it is read by screen readers when the
+ user focuses the "Cancel" button in the "Snooze for..." popup of the alarm
+ dialog. -->
+<!ENTITY calendar.alarm.snooze.cancel "Cancel Snooze">
+
+<!-- Calendar Server Dialog -->
+<!ENTITY calendar.server.dialog.title.edit "Edit Calendar">
+<!ENTITY calendar.server.dialog.name.label "Calendar Name:">
+
+<!-- Calendar Properties -->
+<!ENTITY calendarproperties.color.label "Color:">
+<!ENTITY calendarproperties.webdav.label "iCalendar (ICS)">
+<!ENTITY calendarproperties.caldav.label "CalDAV">
+<!ENTITY calendarproperties.format.label "Format:">
+<!ENTITY calendarproperties.location.label "Location:">
+<!ENTITY calendarproperties.refreshInterval.label "Refresh Calendar:">
+<!ENTITY calendarproperties.refreshInterval.manual.label "Manually">
+<!ENTITY calendarproperties.name.label "Name:">
+<!ENTITY calendarproperties.readonly.label "Read Only">
+<!ENTITY calendarproperties.firealarms.label "Show Reminders">
+<!ENTITY calendarproperties.cache3.label "Offline Support">
+<!ENTITY calendarproperties.enabled2.label "Enable This Calendar">
+<!ENTITY calendarproperties.forceDisabled.label "The provider for this calendar could not be found. This often happens if you have disabled or uninstalled certain addons.">
+<!ENTITY calendarproperties.unsubscribe.label "Unsubscribe">
+<!ENTITY calendarproperties.unsubscribe.accesskey "U">
+
+<!-- Calendar Publish Dialog -->
+<!ENTITY calendar.publish.dialog.title "Publish Calendar">
+<!ENTITY calendar.publish.url.label "Publishing URL">
+<!ENTITY calendar.publish.publish.button "Publish">
+<!ENTITY calendar.publish.close.button "Close">
+
+<!-- Select Calendar Dialog -->
+<!ENTITY calendar.select.dialog.title "Select Calendar">
+
+<!-- Error reporting -->
+<!ENTITY calendar.error.detail "Details…">
+<!ENTITY calendar.error.code "Error code:">
+<!ENTITY calendar.error.description "Description:">
+<!ENTITY calendar.error.title "An error has occurred">
+
+<!-- Extract buttons in message header -->
+<!ENTITY calendar.extract.event.button "Add as event">
+<!ENTITY calendar.extract.task.button "Add as task">
+<!ENTITY calendar.extract.event.button.tooltip "Extract calendaring information from the message and add it to your calendar as an event">
+<!ENTITY calendar.extract.task.button.tooltip "Extract calendaring information from the message and add it to your calendar as a task">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendar.properties b/comm/calendar/locales/en-US/chrome/calendar/calendar.properties
new file mode 100644
index 0000000000..5efab779fa
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendar.properties
@@ -0,0 +1,696 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Default name for new events
+newEvent=New Event
+
+# Titles for the event/task dialog
+newEventDialog=New Event
+editEventDialog=Edit Event
+newTaskDialog=New Task
+editTaskDialog=Edit Task
+
+# Do you want to save changes?
+askSaveTitleEvent=Save Event
+askSaveTitleTask=Save Task
+askSaveMessageEvent=Event has not been saved. Do you want to save the event?
+askSaveMessageTask=Task has not been saved. Do you want to save the task?
+
+# Event Dialog Warnings
+warningEndBeforeStart=The end date you entered occurs before the start date
+warningUntilDateBeforeStart=The until date occurs before the start date
+
+# The name of the calendar provided with the application by default
+homeCalendarName=Home
+
+# The name given to a calendar if an opened calendar has an empty filename
+untitledCalendarName=Untitled Calendar
+
+# Event status: Tentative, Confirmed, Cancelled
+# ToDo task status: NeedsAction, InProcess, Completed, Cancelled
+statusTentative =Tentative
+statusConfirmed =Confirmed
+eventStatusCancelled=Canceled
+todoStatusCancelled =Canceled
+statusNeedsAction =Needs Action
+statusInProcess =In Process
+statusCompleted =Completed
+
+# Task priority, these should match the priority.level.* labels in calendar.dtd
+highPriority=High
+normalPriority=Normal
+lowPriority=Low
+
+importPrompt=Which calendar do you want to import these items into?
+exportPrompt=Which calendar do you want to export from?
+pastePrompt=Which of your currently writable calendars do you want to paste into?
+publishPrompt=Which calendar do you want to publish?
+
+# LOCALIZATION NOTE (pasteEventAlso): The users pasting operation includes among
+# others also a meeting invitation - this is used as a affix in
+# pasteNotifyAbout
+pasteEventAlso=Your pasting includes a meeting
+# LOCALIZATION NOTE (pasteEventsAlso): The users pasting operation includes among
+# others also several meeting invitations - this is used as a affix in
+# pasteNotifyAbout
+pasteEventsAlso=Your pasting includes meetings
+# LOCALIZATION NOTE (pasteTaskAlso): The users pasting operation includes among
+# others also an assigned task - this is used as a affix in pasteNotifyAbout
+pasteTaskAlso=Your pasting includes an assigned task
+# LOCALIZATION NOTE (pasteTasksAlso): The users pasting operation include among
+# others also several assigned tasks - this is used as a affix in
+# pasteNotifyAbout
+pasteTasksAlso=Your pasting includes assigned tasks
+# LOCALIZATION NOTE (pasteItemsAlso): The users pasting operation includes among
+# others also assigned task(s) and meeting invitation(s) - this is used as a affix
+# in pasteNotifyAbout
+pasteItemsAlso=Your pasting includes meetings and assigned tasks
+# LOCALIZATION NOTE (pasteEventOnly): The users is pasting a meeting -
+# this is used as a affix in pasteNotifyAbout
+pasteEventOnly=You are pasting a meeting
+# LOCALIZATION NOTE (pasteEventsOnly): The users is pasting several meetings -
+# this is used as a affix in pasteNotifyAbout
+pasteEventsOnly=You are pasting meetings
+# LOCALIZATION NOTE (pasteEventOnly): The users is pasting an assigned task -
+# this is used as a affix in pasteNotifyAbout
+pasteTaskOnly=You are pasting an assigned task
+# LOCALIZATION NOTE (pasteEventsOnly): The users is pasting several assigned
+# tasks - this is used as a affix in pasteNotifyAbout
+pasteTasksOnly=You are pasting assigned tasks
+# LOCALIZATION NOTE (pasteEventsOnly): The users is pasting assigned task(s) and
+# meeting(s) - this is used as a affix in pasteNotifyAbout
+pasteItemsOnly=You are pasting meetings and assigned tasks
+
+# LOCALIZATION NOTE (pasteNotifyAbout): Text displayed if pasting an invitation
+# or assigned task
+# %1$S - pasteEvent* or pasteTask*
+pasteNotifyAbout=%1$S - do you want to send an update to everybody involved?
+
+# LOCALIZATION NOTE (pasteAndNotifyLabel): button label used in calendar prompt
+# of the pasted item has attendees
+pasteAndNotifyLabel=Paste and send now
+# LOCALIZATION NOTE (pasteDontNotifyLabel): button label used in calendar prompt
+# of the pasted item has attendees
+pasteDontNotifyLabel=Paste without sending
+
+# LOCALIZATION NOTE (importItemsFailed):
+# %1$S will be replaced with number of failed items
+# %2$S will be replaced with last error code / error string
+importItemsFailed=%1$S items failed to import. The last error was: %2$S
+# LOCALIZATION NOTE (noItemsInCalendarFile2):
+# %1$S will be replaced with file path
+noItemsInCalendarFile2=Cannot import from %1$S. There are no importable items in this file.
+
+#spaces needed at the end of the following lines
+eventDescription=Description:
+
+unableToRead=Unable to read from file:
+unableToWrite=Unable to write to file:
+defaultFileName=MozillaCalEvents
+HTMLTitle=Mozilla Calendar
+
+# LOCALIZATION NOTE (timezoneError):
+# used for an error message like 'An unknown and undefined timezone was found while reading c:\Mycalendarfile.ics'
+# %1$S will be replaced with the path to a file
+timezoneError=An unknown and undefined timezone was found while reading %1$S.
+
+# LOCALIZATION NOTE (duplicateError):
+# %1$S will be replaced with number of duplicate items
+# %2$S will be replaced with a file path pointing to a calendar
+duplicateError=%1$S item(s) were ignored since they exist in both the destination calendar and %2$S.
+
+unableToCreateProvider=An error was encountered preparing the calendar located at %1$S for use. It will not be available.
+
+# Sample: Unknown timezone "USPacific" in "Dentist Appt". Using the 'floating' local timezone instead: 2008/02/28 14:00:00
+unknownTimezoneInItem=Unknown timezone "%1$S" in "%2$S". Treated as 'floating' local timezone instead: %3$S
+TimezoneErrorsAlertTitle=Timezone Errors
+TimezoneErrorsSeeConsole=See Error Console: Unknown timezones are treated as the 'floating' local timezone.
+
+# The following strings are for the prompt to delete/unsubscribe from the calendar
+removeCalendarTitle=Remove Calendar
+removeCalendarButtonDelete=Delete Calendar
+removeCalendarButtonUnsubscribe=Unsubscribe
+
+# LOCALIZATION NOTE (removeCalendarMessageDeleteOrUnsubscribe): Shown for
+# calendar where both deleting and unsubscribing is possible.
+# %1$S: The name of a calendar
+removeCalendarMessageDeleteOrUnsubscribe=Do you want to remove the calendar "%1$S"? Unsubscribing will remove the calendar from the list, deleting will also permanently purge its data.
+
+# LOCALIZATION NOTE (removeCalendarMessageDelete): Shown for calendar where
+# deleting is the only option.
+# %1$S: The name of a calendar
+removeCalendarMessageDelete=Do you want to permanently delete the calendar "%1$S"?
+
+# LOCALIZATION NOTE (removeCalendarMessageUnsubscribe): Shown for calendar
+# where unsubscribing is the only option.
+# %1$S: The name of a calendar
+removeCalendarMessageUnsubscribe=Do you want to unsubscribe from the calendar "%1$S"?
+
+WeekTitle=Week %1$S
+None=None
+
+# Error strings
+## @name UID_NOT_FOUND
+## @loc none
+
+# LOCALIZATION NOTE (tooNewSchemaErrorText):
+# %1$S will be replaced with the name of the host application, e.g. 'Thunderbird'
+# %2$S will be replaced with the name of the new copy of the file, e.g. 'local-2020-05-11T21-30-17.sqlite'
+tooNewSchemaErrorText=Your calendar data is not compatible with this version of %1$S. The calendar data in your profile was updated by a newer version of %1$S. A backup of the data file has been created, named "%2$S". Continuing with a newly created data file.
+
+# List of events or todos (unifinder)
+eventUntitled=Untitled
+
+# Tooltips of events or todos
+tooltipTitle=Title:
+tooltipLocation=Location:
+# event date, usually an interval, such as
+# Date: 7:00--8:00 Thu 9 Oct 2011
+# Date: Thu 9 Oct 2000 -- Fri 10 Oct 2000
+tooltipDate=Date:
+# event calendar name
+tooltipCalName=Calendar Name:
+# event status: tentative, confirmed, cancelled
+tooltipStatus=Status:
+# event organizer
+tooltipOrganizer=Organizer:
+# task/todo fields
+# start date time, due date time, task priority number, completed date time
+tooltipStart=Start:
+tooltipDue=Due:
+tooltipPriority=Priority:
+tooltipPercent=% Complete:
+tooltipCompleted=Completed:
+
+#File commands and dialogs
+New=New
+Open=Open
+filepickerTitleImport=Import
+filepickerTitleExport=Export
+
+# Filters for export/import/open file picker. %1$S will be replaced with
+# wildmat used to filter files by extension, such as (*.html; *.htm).
+filterIcs=iCalendar (%1$S)
+filterHtml=Web Page (%1$S)
+
+# Remote calendar errors
+genericErrorTitle=An error has occurred
+httpPutError=Publishing the calendar file failed.\nStatus code: %1$S: %2$S
+otherPutError=Publishing the calendar file failed.\nStatus code: 0x%1$S
+
+# LOCALIZATION NOTE (readOnlyMode):
+# used for an message like 'There has been an error reading data for calendar: Home. It has been...'
+# %1$S will be replaced with the name of a calendar
+readOnlyMode=There has been an error reading data for calendar: %1$S. It has been placed in read-only mode, since changes to this calendar will likely result in data-loss. You may change this setting by choosing 'Edit Calendar'.
+
+# LOCALIZATION NOTE (disabledMode):
+# used for an message like 'There has been an error reading data for calendar: Home. It has been...'
+# %1$S will be replaced with the name of a calendar
+disabledMode=There has been an error reading data for calendar: %1$S. It has been disabled until it is safe to use it.
+
+# LOCALIZATION NOTE (minorError):
+# used for an message like 'There has been an error reading data for calendar: Home. However this...'
+# %1$S will be replaced with the name of a calendar
+minorError=There has been an error reading data for calendar: %1$S. However, this error is believed to be minor, so the program will attempt to continue.
+
+# LOCALIZATION NOTE (stillReadOnlyError):
+# used for an message like 'There has been an error reading data for calendar: Home.'
+# %1$S will be replaced with the name of a calendar
+stillReadOnlyError=There has been an error reading data for calendar: %1$S.
+utf8DecodeError=An error occurred while decoding an iCalendar (ics) file as UTF-8. Check that the file, including symbols and accented letters, is encoded using the UTF-8 character encoding.
+icsMalformedError=Parsing an iCalendar (ics) file failed. Check that the file conforms to iCalendar (ics) file syntax.
+itemModifiedOnServerTitle=Item changed on server
+itemModifiedOnServer=This item has recently been changed on the server.\n
+modifyWillLoseData=Submitting your changes will overwrite the changes made on the server.
+deleteWillLoseData=Deleting this item will cause loss of the changes made on the server.
+updateFromServer=Discard my changes and reload
+proceedModify=Submit my changes anyway
+proceedDelete=Delete anyway
+dav_notDav=The resource at %1$S is either not a DAV collection or not available
+dav_davNotCaldav=The resource at %1$S is a DAV collection but not a CalDAV calendar
+itemPutError=There was an error storing the item on the server.
+itemDeleteError=There was an error deleting the item from the server.
+caldavRequestError=An error occurred when sending the invitation.
+caldavResponseError=An error occurred when sending the response.
+caldavRequestStatusCode=Status Code: %1$S
+caldavRequestStatusCodeStringGeneric=The request cannot be processed.
+caldavRequestStatusCodeString400=The request contains bad syntax and cannot be processed.
+caldavRequestStatusCodeString403=The user lacks the required permission to perform the request.
+caldavRequestStatusCodeString404=Resource not found.
+caldavRequestStatusCodeString409=Resource conflict.
+caldavRequestStatusCodeString412=Precondition failed.
+caldavRequestStatusCodeString500=Internal server error.
+caldavRequestStatusCodeString502=Bad gateway (Proxy configuration?).
+caldavRequestStatusCodeString503=Internal server error (Temporary server outage?).
+caldavRedirectTitle=Update location for calendar %1$S?
+caldavRedirectText=The requests for %1$S are being redirected to a new location. Would you like to change the location to the following value?
+caldavRedirectDisableCalendar=Disable Calendar
+
+
+# LOCALIZATION NOTE (likelyTimezone):
+# Translators, please put the most likely timezone(s) where the people using
+# your locale will be. Use the Olson ZoneInfo timezone name *in English*,
+# ie "Europe/Paris", (continent or ocean)/(largest city in timezone).
+# Order does not matter, except if two historically different zones now match,
+# such as America/New_York and America/Toronto, will only find first listed.
+# (Particularly needed to guess the most relevant timezones if there are
+# similar timezones at the same June/December GMT offsets with alphabetically
+# earlier ZoneInfo timezone names. Sample explanations for English below.)
+# for english-US:
+# America/Los_Angeles likelier than America/Dawson
+# America/New_York likelier than America/Detroit (NY for US-EasternTime)
+# for english:
+# Europe/London likelier than Atlantic/Canary
+# Europe/Paris likelier than Africa/Ceuta (for WestEuropeanTime)
+# America/Halifax likelier than America/Glace_Bay (Canada-AtlanticTime)
+# America/Mexico_City likelier than America/Cancun
+# America/Argentina/Buenos_Aires likelier than America/Araguaina
+# America/Sao_Paolo (may not recognize: summer-time dates change every year)
+# Asia/Singapore likelier than Antarctica/Casey
+# Asia/Tokyo likelier than Asia/Dili
+# Africa/Lagos likelier than Africa/Algiers (for WestAfricanTime)
+# Africa/Johannesburg likelier than Africa/Blantyre (for SouthAfricanStdTime)
+# Africa/Nairobi likelier than Africa/Addis_Ababa (for EastAfricanTime)
+# Australia/Brisbane likelier than Antarctica/DumontDUrville
+# Australia/Sydney likelier than Australia/Currie or Australia/Hobart
+# Pacific/Auckland likelier than Antarctica/McMurdo
+likelyTimezone=America/New_York, America/Chicago, America/Denver, America/Phoenix, America/Los_Angeles, America/Anchorage, America/Adak, Pacific/Honolulu, America/Puerto_Rico, America/Halifax, America/Mexico_City, America/Argentina/Buenos_Aires, America/Sao_Paulo, Europe/London, Europe/Paris, Asia/Singapore, Asia/Tokyo, Africa/Lagos, Africa/Johannesburg, Africa/Nairobi, Australia/Brisbane, Australia/Sydney, Pacific/Auckland
+
+# Guessed Timezone errors and warnings.
+# Testing note:
+# * remove preference for calendar.timezone.default in userprofile/prefs.js
+# * repeat
+# - set OS timezone to a city (windows: click right on clock in taskbar)
+# - restart
+# - observe guess in error console and verify whether guessed timezone city
+# makes sense for OS city.
+#
+# 'Warning: Operating system timezone "E. South America Standard Time"
+# no longer matches ZoneInfo timezone "America/Sao_Paulo".'
+# Testing notes:
+# - Brasil DST change dates are set every year by decree, so likely out of sync.
+# - Only appears on OSes from which timezone can be obtained
+# (windows; or TZ env var, /etc/localtime target path, or line in
+# /etc/timezone or /etc/sysconfig/clock contains ZoneInfo timezone id).
+# - Windows: turning off "Automatically adjust clock for daylight saving time"
+# can also trigger this warning.
+WarningOSTZNoMatch=Warning: Operating system timezone "%1$S"\nno longer matches the internal ZoneInfo timezone "%2$S".
+
+# "Skipping Operating System timezone 'Pacific/New_Country'."
+# Testing note: not easily testable. May occur someday if (non-windows)
+# OS uses different version of ZoneInfo database which has a timezone name
+# that is not included in our current ZoneInfo database (or if the mapping
+# mapping from windows to ZoneInfo timezone ids does).
+SkippingOSTimezone=Skipping Operating System timezone '%1$S'.
+
+# "Skipping locale timezone 'America/New_Yawk'."
+# Testing note: Skipping occurs if a likelyTimezone id is unknown or misspelled.
+SkippingLocaleTimezone=Skipping locale timezone '%1$S'.
+
+# Testing note: "No match" timezones include Bucharest on W2k.
+# Brazil timezones may be "No match" (change every year, so often out of date,
+# and changes are often more than a week different).
+warningUsingFloatingTZNoMatch=Warning: Using "floating" timezone.\nNo ZoneInfo timezone data matched the operating system timezone data.
+
+# "Warning: Using guessed timezone
+# America/New York (UTC-0500/-0400).
+# [rfc2445 summer daylight saving shift rules for timezone]
+# This ZoneInfo timezone almost matches/seems to match..."
+# This ZoneInfo timezone was chosen based on ... "
+WarningUsingGuessedTZ=Warning: Using guessed timezone\n %1$S (UTC%2$S).\n%3$S\n%4$S
+
+# Testing note: "Almost match" timezones include Cairo on W2k.
+TZAlmostMatchesOSDifferAtMostAWeek=This ZoneInfo timezone almost matches the operating system timezone.\nFor this rule, the next transitions between daylight and standard time\ndiffer at most a week from the operating system timezone transitions.\nThere may be discrepancies in the data, such as differing start date,\nor differing rule, or approximation for non-Gregorian-calendar rule.
+
+TZSeemsToMatchOS=This ZoneInfo timezone seems to match the operating system timezone this year.
+
+# LOCALIZATION NOTE (TZFromOS):
+# used for a display of a chosen timezone
+# %1$S will be replaced with the name of a timezone
+TZFromOS=This ZoneInfo timezone was chosen based on the operating system timezone\nidentifier "%1$S".
+
+# Localization note (TZFromLocale): Substitute name of your locale language.
+TZFromLocale=This ZoneInfo timezone was chosen based on matching the operating system\ntimezone with likely timezones for internet users using US English.
+
+TZFromKnownTimezones=This ZoneInfo timezone was chosen based on matching the operating system\ntimezone with known timezones in alphabetical order of timezone id.
+
+# Print Layout
+tasksWithNoDueDate = Tasks with no due date
+
+# Providers
+caldavName=CalDAV
+compositeName=Composite
+icsName=iCalendar (ICS)
+memoryName=Temporary (memory)
+storageName=Local (SQLite)
+
+# Used in created html code for export
+htmlPrefixTitle=Title
+htmlPrefixWhen=When
+htmlPrefixLocation=Location
+htmlPrefixDescription=Description
+htmlTaskCompleted=%1$S (completed)
+
+# Categories
+addCategory=Add Category
+multipleCategories=Multiple Categories
+
+today=Today
+tomorrow=Tomorrow
+yesterday=Yesterday
+
+#Today pane
+eventsonly=Events
+eventsandtasks=Events and Tasks
+tasksonly=Tasks
+shortcalendarweek=CW
+
+go=Go
+
+# Some languages have different conjugations of 'next' and 'last'. If yours
+# does not, simply repeat the value. This will be used with day names, as in
+# 'next Sunday'.
+next1=next
+next2=next
+last1=last
+last2=last
+
+# Alarm Dialog
+# LOCALIZATION NOTE (alarmWindowTitle.label): Semi-colon list of plural
+# forms. See: http://developer.mozilla.org/en/Localization_and_Plurals
+alarmWindowTitle.label=#1 Reminder;#1 Reminders
+
+# LOCALIZATION NOTE (alarmStarts):
+# used for a display the start of an alarm like 'Starts: Thu 2 Oct 2008 13:21'
+# %1$S will be replaced with a date-time
+alarmStarts=Starts: %1$S
+
+# LOCALIZATION NOTE (alarmTodayAt):
+# used for a display the date-time of an alarm like 'Today at Thu 2 Oct 2008 13:21'
+# %1$S will be replaced with a date-time
+alarmTodayAt=Today at %1$S
+
+# LOCALIZATION NOTE (alarmTomorrowAt):
+# used for a display the date-time of an alarm like 'Tomorrow at Thu 2 Oct 2008 13:21'
+# %1$S will be replaced with a date-time
+alarmTomorrowAt=Tomorrow at %1$S
+
+# LOCALIZATION NOTE (alarmYesterdayAt):
+# used for a display the date-time of an alarm like 'Yesterday at Thu 2 Oct 2008 13:21'
+# %1$S will be replaced with a date-time
+alarmYesterdayAt=Yesterday at %1$S
+
+# Alarm interface strings
+# LOCALIZATION NOTE: These strings do not get displayed. They are only visible
+# when exporting an item with i.e a DISPLAY alarm, that doesn't have a
+# description set, or an EMAIL alarm that doesn't have a summary set.
+alarmDefaultDescription=Default Mozilla Description
+alarmDefaultSummary=Default Mozilla Summary
+
+# LOCALIZATION NOTE (alarmSnoozeLimitExceeded): Semi-colon list of plural
+# forms.
+alarmSnoozeLimitExceeded=You cannot snooze an alarm for more than #1 month.;You cannot snooze an alarm for more than #1 months.
+
+taskDetailsStatusNeedsAction=Needs Action
+
+# LOCALIZATION NOTE (taskDetailsStatusInProgress):
+# used for a display of how much of a task is completed '25% Complete'
+# %1$S will be replaced with the number of percentage completed
+taskDetailsStatusInProgress=%1$S%% Complete
+taskDetailsStatusCompleted=Completed
+
+# LOCALIZATION NOTE (taskDetailsStatusCompletedOn):
+# used for a display of completion date like 'Completed on Thu 2 Oct 2008 13:21'
+# %1$S will be replaced with the completion date-time of the task
+taskDetailsStatusCompletedOn=Completed on %1$S
+taskDetailsStatusCancelled=Canceled
+
+gettingCalendarInfoCommon=Checking Calendars…
+
+# LOCALIZATION NOTE (gettingCalendarInfoDetail):
+# used for a progress-display of processed like 'Checking Calendar 5 of 10'
+# %1$S will be replaced with the index of the currently processed calendar
+# %2$S will be replaced with the total numbers of calendars
+gettingCalendarInfoDetail=Checking Calendar %1$S of %2$S
+
+# LOCALIZATION NOTE (errorCode):
+# %1$S will be replaced with the number of an error code
+errorCode=Error code: %1$S
+
+# LOCALIZATION NOTE (errorDescription):
+# %1$S will be replaced with the description of an error
+errorDescription=Description: %1$S
+
+# LOCALIZATION NOTE (errorWriting):
+# used for an message like 'An error occurred when writing to the calendar Home!'
+# %1$S will be replaced with the name of a calendar
+errorWriting2=An error occurred when writing to the calendar %1$S! Please see below for more information.
+
+# LOCALIZATION NOTE (errorWritingDetails):
+# This will be displayed in the detail section of the error dialog
+errorWritingDetails=If you're seeing this message after snoozing or dismissing a reminder and this is for a calendar you do not want to add or edit events for, you can mark this calendar as read-only to avoid such experience in future. To do so, get to the calendar properties by right-clicking on this calendar in the list in the calendar or task view.
+
+# LOCALIZATION NOTE (tooltipCalendarDisabled):
+# used for an alert-message like 'The calendar Home is momentarily not available'
+# %1$S will be replaced with the name of a calendar
+tooltipCalendarDisabled=The calendar %1$S is momentarily not available
+
+# LOCALIZATION NOTE (tooltipCalendarReadOnly):
+# used for an message like 'The calendar Home is readonly'
+# %1$S will be replaced with the name of a calendar
+tooltipCalendarReadOnly=The calendar %1$S is readonly
+
+taskEditInstructions=Click here to add a new task
+taskEditInstructionsReadonly=Please select a writable calendar
+taskEditInstructionsCapability=Please select a calendar that supports tasks
+
+eventDetailsStartDate=Start:
+eventDetailsEndDate=End:
+
+# LOCALIZATION NOTE (datetimeWithTimezone):
+# used for a display of a date-time with timezone 'Thu 2 Oct 2008 13:21', Europe/Paris
+# %1$S will be replaced with the completion date-time
+# %2$S will be replaced with the name of the timezone
+datetimeWithTimezone=%1$S, %2$S
+
+# LOCALIZATION NOTE (singleLongCalendarWeek):
+# used for display of calendar weeks in short form like 'Calendar Week 43'
+# %1$S will be replaced with the index of the week
+singleLongCalendarWeek=Calendar Week: %1$S
+
+# LOCALIZATION NOTE (severalLongCalendarWeeks):
+# used for display of calendar weeks in short form like 'Calendar Weeks 43 - 45'
+# %1$S will be replaced with the index of the start-week
+# %2$S will be replaced with the index of the end-week
+severalLongCalendarWeeks=Calendar Weeks %1$S-%2$S
+
+# LOCALIZATION NOTE (singleShortCalendarWeek):
+# used for display of calendar weeks in short form like 'CW 43'
+# %1$S will be replaced with the index of the week
+singleShortCalendarWeek=CW: %1$S
+
+# LOCALIZATION NOTE (severalShortCalendarWeeks):
+# used for display of calendar weeks in short form like 'CWs 43 - 45'
+# %1$S will be replaced with the index of the start-week
+# %2$S will be replaced with the index of the end-week
+severalShortCalendarWeeks=CWs: %1$S-%2$S
+
+# LOCALIZATION NOTE (multiweekViewWeek):
+# Used for displaying the week number in the first day box of every week
+# in multiweek and month views.
+# It allows to localize the label with the week number in case your locale
+# requires it.
+# Take into account that this label is placed in the same room of the day label
+# inside the day boxes, exactly on left side, hence a possible string shouldn't
+# be too long otherwise it will create confusion between the week number and
+# the day number other than a possible crop when the window is resized.
+#
+# %1$S is a number from 1 to 53 that represents the week number.
+multiweekViewWeek=W %1$S
+
+# Task tree, "Due In" column.
+# LOCALIZATION NOTE (dueInDays, dueInHours): Semi-colon list of plural
+# forms. See: http://developer.mozilla.org/en/Localization_and_Plurals
+dueInDays=#1 day;#1 days
+dueInHours=#1 hour;#1 hours
+dueInLessThanOneHour=< 1 hour
+
+# LOCALIZATION NOTE (monthInYear):
+# used for display of Month-dates like 'December 2008'
+# %1$S will be replaced with name of the month
+# %2$S will be replaced with the year
+monthInYear=%1$S %2$S
+
+# LOCALIZATION NOTE (monthInYear.monthFormat):
+# If your language requires a different declension, change this to
+# one of the values specified in dateFormat.properties.
+# In any case, DO NOT TRANSLATE.
+monthInYear.monthFormat=nominative
+
+# LOCALIZATION NOTE (formatDateLong):
+# used for display dates in long format like 'Mon 15 Oct 2008' when it's
+# impossible to retrieve the formatatted date from the OS.
+# %1$S will be replaced with name of the day in short format;
+# %2$S will be replaced with the day-index of the month, possibly followed by an ordinal symbol
+# (depending on the string dayOrdinalSymbol in dateFormat.properties);
+# %3$S will be replaced with the name of the month in short format;
+# %4$S will be replaced with the year.
+formatDateLong=%1$S %2$S %3$S %4$S
+
+# LOCALIZATION NOTE (dayHeaderLabel):
+# used for display the labels in the header of the days in day/week views in short
+# or long format. For example: 'Monday 6 Oct.' or 'Mon. 6 Oct.'
+# %1$S will be replaced with name of the day in short or long format
+# %2$S will be replaced with the day-index of the month, possibly followed by an ordinal symbol
+# (depending on the string dayOrdinalSymbol in dateFormat.properties), plus the name
+# of the month in short format (the day/month order depends on the OS settings).
+dayHeaderLabel=%1$S %2$S
+
+# LOCALIZATION NOTE (daysIntervalInMonth):
+# used for display of intervals in the form of 'March 3 - 9, 2008'
+# %1$S will be replaced with name of the month of the start date
+# %2$S will be replaced with the day-index of the start date possibly followed by an ordinal symbol
+# %3$S will be replaced with the day-index of the end date possibly followed by an ordinal symbol
+# %4$S will be replaced with the common year of both dates
+# The presence of the ordinal symbol in the day-indexes depends on the string
+# dayOrdinalSymbol in dateFormat.properties
+daysIntervalInMonth=%1$S %2$S – %3$S, %4$S
+
+# LOCALIZATION NOTE (daysIntervalInMonth.monthFormat):
+# If your language requires a different declension, change this to
+# one of the values specified in dateFormat.properties.
+# In any case, DO NOT TRANSLATE.
+daysIntervalInMonth.monthFormat=nominative
+
+# LOCALIZATION NOTE (daysIntervalBetweenMonths):
+# used for display of intervals in the form 'September 29 - October 5, 2008'
+# %1$S will be replaced with name of the month of the start date
+# %2$S will be replaced with the day-index of the start date possibly followed by an ordinal symbol
+# %3$S will be replaced with name of the month of the end date
+# %4$S will be replaced with the day-index of the end date possibly followed by an ordinal symbol
+# %5$S will be replaced with the common year of both dates
+# The presence of the ordinal symbol in the day-indexes depends on the string
+# dayOrdinalSymbol in dateFormat.properties
+daysIntervalBetweenMonths=%1$S %2$S – %3$S %4$S, %5$S
+
+# LOCALIZATION NOTE (daysIntervalBetweenMonths.monthFormat):
+# If your language requires a different declension, change this to
+# one of the values specified in dateFormat.properties.
+# In any case, DO NOT TRANSLATE.
+daysIntervalBetweenMonths.monthFormat=nominative
+
+# LOCALIZATION NOTE (daysIntervalBetweenYears):
+# used for display of intervals in the form 'December 29, 2008 - January 4, 2009'
+# %1$S will be replaced with name of the month of the start date
+# %2$S will be replaced with the day-index of the start date possibly followed by an ordinal symbol
+# %3$S will be replaced with the year of the start date
+# %4$S will be replaced with name of the month of the end date
+# %5$S will be replaced with the day-index of the end date possibly followed by an ordinal symbol
+# %6$S will be replaced with the year of the end date
+# The presence of the ordinal symbol in the day-indexes depends on the string
+# dayOrdinalSymbol in dateFormat.properties
+daysIntervalBetweenYears=%1$S %2$S, %3$S – %4$S %5$S, %6$S
+
+# LOCALIZATION NOTE (daysIntervalBetweenYears.monthFormat):
+# If your language requires a different declension, change this to
+# one of the values specified in dateFormat.properties.
+# In any case, DO NOT TRANSLATE.
+daysIntervalBetweenYears.monthFormat=nominative
+
+# LOCALIZATION NOTE (datetimeIntervalOnSameDateTime):
+# used for intervals where end is equals to start
+# displayed form is '5 Jan 2006 13:00'
+# %1$S will be replaced with the date of the start date
+# %2$S will be replaced with the time of the start date
+datetimeIntervalOnSameDateTime=%1$S %2$S
+
+# LOCALIZATION NOTE (datetimeIntervalOnSameDay):
+# used for intervals where end is on the same day as start, so we can leave out the
+# end date but still include end time
+# displayed form is '5 Jan 2006 13:00 - 17:00'
+# %1$S will be replaced with the date of the start date
+# %2$S will be replaced with the time of the start date
+# %3$S will be replaced with the time of the end date
+datetimeIntervalOnSameDay=%1$S %2$S – %3$S
+
+# LOCALIZATION NOTE (datetimeIntervalOnSeveralDays):
+# used for intervals spanning multiple days by including date and time
+# displayed form is '5 Jan 2006 13:00 - 7 Jan 2006 9:00'
+# %1$S will be replaced with the date of the start date
+# %2$S will be replaced with the time of the start date
+# %3$S will be replaced with the date of the end date
+# %4$S will be replaced with the time of the end date
+datetimeIntervalOnSeveralDays=%1$S %2$S – %3$S %4$S
+
+# LOCALIZATION NOTE (datetimeIntervalTaskWithoutDate):
+# used for task without start and due date
+# (showed only in exported calendar in Html format)
+datetimeIntervalTaskWithoutDate= no start or due date
+# LOCALIZATION NOTE (datetimeIntervalTaskWithoutDueDate):
+# used for intervals in task with only start date
+# displayed form is 'start date 5 Jan 2006 13:00'
+# (showed only in exported calendar in Html format)
+# %1$S will be replaced with the date of the start date
+# %2$S will be replaced with the time of the start date
+datetimeIntervalTaskWithoutDueDate=start date %1$S %2$S
+# LOCALIZATION NOTE (datetimeIntervalTaskWithoutStartDate):
+# used for intervals in task with only due date
+# displayed form is 'due date 5 Jan 2006 13:00'
+# (showed only in exported calendar in Html format)
+# %1$S will be replaced with the date of the due date
+# %2$S will be replaced with the time of the due date
+datetimeIntervalTaskWithoutStartDate=due date %1$S %2$S
+
+# LOCALIZATION NOTE (dragLabelTasksWithOnlyEntryDate
+# dragLabelTasksWithOnlyDueDate)
+# Labels that appear while dragging a task with only
+# entry date OR due date
+dragLabelTasksWithOnlyEntryDate=Starting time
+dragLabelTasksWithOnlyDueDate=Due at
+
+deleteTaskLabel=Delete Task
+deleteTaskAccesskey=l
+deleteItemLabel=Delete
+deleteItemAccesskey=l
+deleteEventLabel=Delete Event
+deleteEventAccesskey=l
+
+calendarPropertiesEveryMinute=Every minute;Every #1 minutes
+
+# LOCALIZATION NOTE (extractUsing)
+# Used in message header
+# %1$S will be replaced with language name from languageNames.properties
+extractUsing=Using %1$S
+
+# LOCALIZATION NOTE (extractUsingRegion)
+# Used in message header
+# %1$S will be replaced with language name from languageNames.properties
+# %2$S will be replaced with region like US in en-US
+extractUsingRegion=Using %1$S (%2$S)
+
+# LOCALIZATION NOTE (unit)
+# Used to determine the correct plural form of a unit
+unitMinutes=#1 minute;#1 minutes
+unitHours=#1 hour;#1 hours
+unitDays=#1 day;#1 days
+unitWeeks=#1 week;#1 weeks
+
+# LOCALIZATION NOTE (showCalendar)
+# Used in calendar list context menu
+# %1$S will be replaced with the calendar name
+# uses the access key calendar.context.togglevisible.accesskey
+showCalendar=Show %1$S
+hideCalendar=Hide %1$S
+# uses the access key calendar.context.showonly.accesskey
+showOnlyCalendar=Show Only %1$S
+
+# LOCALIZATION NOTE (modifyConflict)
+# Used by the event dialog to resolve item modification conflicts.
+modifyConflictPromptTitle=Item Modification Conflict
+modifyConflictPromptMessage=The item being edited in the dialog has been modified since it was opened.
+modifyConflictPromptButton1=Overwrite the other changes
+modifyConflictPromptButton2=Discard these changes
+
+# Accessible description of a grid calendar with no selected date
+minimonthNoSelectedDate=No date selected
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.dtd b/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.dtd
new file mode 100644
index 0000000000..d3b643f80e
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.dtd
@@ -0,0 +1,51 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY wizard.title "Create New Calendar" >
+<!ENTITY wizard.label "Create a new calendar" >
+<!ENTITY wizard.description "Locate your calendar" >
+
+<!ENTITY initialpage.description "Your calendar can be stored on your computer or be stored on a server in order to access it remotely or share it with your friends or co-workers." >
+<!ENTITY initialpage.computer.label "On My Computer">
+<!ENTITY initialpage.network.label "On the Network">
+
+<!ENTITY locationpage.description "Provide info about what is needed to access your remote calendar" >
+<!ENTITY locationpage.login.description "Optional: enter an username and password" >
+<!ENTITY locationpage.username.label "Username:" >
+<!ENTITY locationpage.password.label "Password:" >
+
+<!ENTITY custompage.shortdescription "Customize your calendar" >
+<!ENTITY custompage.longdescription "You can give your calendar a nickname and colorize the events from this calendar." >
+
+<!ENTITY finishpage.shortdescription "Calendar Created" >
+<!ENTITY finishpage.longdescription "Your calendar has been created." >
+
+<!-- Below are new strings for the revised new calendar dialog. The above strings should be
+ removed/renamed later on -->
+
+<!ENTITY sourcetabs.other.label "Other">
+
+<!ENTITY buttons.create.label "Create Calendar">
+<!ENTITY buttons.create.accesskey "r">
+
+<!ENTITY buttons.find.label "Find Calendars">
+<!ENTITY buttons.find.accesskey "F">
+
+<!ENTITY buttons.back.label "Back">
+<!ENTITY buttons.back.accesskey "B">
+
+<!ENTITY buttons.subscribe.label "Subscribe">
+<!ENTITY buttons.subscribe.accesskey "S">
+
+<!ENTITY calendartype.label "Calendar Type:">
+<!ENTITY location.label "Location:">
+<!ENTITY location.placeholder "URL or host name of the calendar server">
+
+<!ENTITY network.nocredentials.label "This location doesn't require credentials">
+<!ENTITY network.loading.description "Please wait while your calendars are being discovered.">
+<!ENTITY network.notfound.description "Could not find calendars at this location. Please check your settings.">
+<!ENTITY network.authfail.description "The credentials you have entered were not accepted. Please check your settings.">
+
+<!ENTITY network.subscribe.single.description "Please select the calendars you would like to subscribe to.">
+<!ENTITY network.subscribe.multiple.description "Multiple calendar types are available for this location. Please select the calendar type, then mark the calendars you would like to subscribe to.">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.properties b/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.properties
new file mode 100644
index 0000000000..30bf726cb8
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/calendarCreation.properties
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+error.invalidUri=Please enter a valid location.
+error.alreadyExists=You are already subscribed to the calendar at this location.
diff --git a/comm/calendar/locales/en-US/chrome/calendar/categories.properties b/comm/calendar/locales/en-US/chrome/calendar/categories.properties
new file mode 100644
index 0000000000..8ad02f13a2
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/categories.properties
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# default categories
+
+categories2=Anniversary,Birthday,Business,Calls,Clients,Competition,Customer,Favorites,Follow up,Gifts,Holidays,Ideas,Issues,Meeting,Miscellaneous,Personal,Projects,Public Holiday,Status,Suppliers,Travel,Vacation
diff --git a/comm/calendar/locales/en-US/chrome/calendar/dateFormat.properties b/comm/calendar/locales/en-US/chrome/calendar/dateFormat.properties
new file mode 100644
index 0000000000..7569165ebd
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/dateFormat.properties
@@ -0,0 +1,146 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# In case you are looking for the note about different declensions on date
+# formats, here it is. If your language doesn't use different declensions of
+# month names, you shouldn't have much work. Just leave the *.monthFormat
+# string on "nominative" and the string month.*.name will be filled in.
+#
+# If you need a different form for a string, you can change the
+# *.monthFormat to a different value. Supported values are currently:
+# nominative (default), genitive
+# The modified month name form will then be filled in accordingly. If this
+# system does not suit your needs, please file a bug!
+
+# LOCALIZATION NOTE (month.*.name):
+# Some languages require different declensions of month names.
+# These values will be used if *.monthFormat is set to "nominative" or in places
+# where using a different declension is not yet supported.
+month.1.name=January
+month.2.name=February
+month.3.name=March
+month.4.name=April
+month.5.name=May
+month.6.name=June
+month.7.name=July
+month.8.name=August
+month.9.name=September
+month.10.name=October
+month.11.name=November
+month.12.name=December
+
+# LOCALIZATION NOTE (month.*.genitive):
+# Some languages require different declensions of month names.
+# These values will be used if *.monthFormat is set to "genitive"
+# If your language doesn't use different declensions, just set the same
+# values as for month.*.name.
+month.1.genitive=January
+month.2.genitive=February
+month.3.genitive=March
+month.4.genitive=April
+month.5.genitive=May
+month.6.genitive=June
+month.7.genitive=July
+month.8.genitive=August
+month.9.genitive=September
+month.10.genitive=October
+month.11.genitive=November
+month.12.genitive=December
+
+month.1.Mmm=Jan
+month.2.Mmm=Feb
+month.3.Mmm=Mar
+month.4.Mmm=Apr
+month.5.Mmm=May
+month.6.Mmm=Jun
+month.7.Mmm=Jul
+month.8.Mmm=Aug
+month.9.Mmm=Sep
+month.10.Mmm=Oct
+month.11.Mmm=Nov
+month.12.Mmm=Dec
+
+day.1.name=Sunday
+day.2.name=Monday
+day.3.name=Tuesday
+day.4.name=Wednesday
+day.5.name=Thursday
+day.6.name=Friday
+day.7.name=Saturday
+
+day.1.Mmm=Sun
+day.2.Mmm=Mon
+day.3.Mmm=Tue
+day.4.Mmm=Wed
+day.5.Mmm=Thu
+day.6.Mmm=Fri
+day.7.Mmm=Sat
+
+# Can someone tell me why we're not counting from zero?
+day.1.short=Su
+day.2.short=Mo
+day.3.short=Tu
+day.4.short=We
+day.5.short=Th
+day.6.short=Fr
+day.7.short=Sa
+
+# Localizable day's date
+day.1.number=1
+day.2.number=2
+day.3.number=3
+day.4.number=4
+day.5.number=5
+day.6.number=6
+day.7.number=7
+day.8.number=8
+day.9.number=9
+day.10.number=10
+day.11.number=11
+day.12.number=12
+day.13.number=13
+day.14.number=14
+day.15.number=15
+day.16.number=16
+day.17.number=17
+day.18.number=18
+day.19.number=19
+day.20.number=20
+day.21.number=21
+day.22.number=22
+day.23.number=23
+day.24.number=24
+day.25.number=25
+day.26.number=26
+day.27.number=27
+day.28.number=28
+day.29.number=29
+day.30.number=30
+day.31.number=31
+
+# LOCALIZATION NOTE (dayOrdinalSymbol):
+# Allows to insert a string, a character or a symbol after the number of a
+# monthday in order to give it the meaning of ordinal number e.g. 1 -> 1st etc.
+# It's mainly used when formatting dates with both monthday and month name. It
+# affects the following localizable strings that hence must be localized *without*
+# any ordinal symbol for the monthday number:
+# dayHeaderLabel, monthlyDaysOfNth_day,
+# yearlyNthOn, daysIntervalBetweenYears,
+# daysIntervalBetweenMonths, daysIntervalInMonth.
+# Write only a single string if the ordinal symbol is the same for every monthday, otherwise
+# write a sequence of _31_ strings (one for each monthday) separated with commas.
+# If your language doesn't require that in the mentioned strings, leave it empty.
+# e.g.
+# dayOrdinalSymbol=.
+# -> daysIntervalInMonth: 'March 3. - 9., 2008'
+# dayOrdinalSymbol=st,nd,rd,th,th,th,th,th,th,th,th,th,th,th,th,
+# th,th,th,th,th,st,nd,rd,th,th,th,th,th,th,th,st
+# -> daysIntervalBetweenMonths: 'September 29th - November 1st, 2008'
+dayOrdinalSymbol=
+
+noon=Noon
+midnight=Midnight
+
+AllDay=All Day
+Repeating=(Repeating)
diff --git a/comm/calendar/locales/en-US/chrome/calendar/dialogs/calendar-event-dialog-reminder.dtd b/comm/calendar/locales/en-US/chrome/calendar/dialogs/calendar-event-dialog-reminder.dtd
new file mode 100644
index 0000000000..ec4b3ca6a2
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/dialogs/calendar-event-dialog-reminder.dtd
@@ -0,0 +1,19 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY reminderdialog.title "Set up Reminders">
+<!ENTITY reminder.add.label "Add">
+<!ENTITY reminder.add.accesskey "A">
+<!ENTITY reminder.remove.label "Remove">
+<!ENTITY reminder.remove.accesskey "R">
+
+<!ENTITY reminder.reminderDetails.label "Reminder Details">
+<!ENTITY reminder.action.label "Choose a Reminder Action">
+
+<!ENTITY reminder.action.alert.label "Show an Alert">
+<!ENTITY reminder.action.email.label "Send an Email">
+
+<!ENTITY alarm.units.minutes "minutes" >
+<!ENTITY alarm.units.hours "hours" >
+<!ENTITY alarm.units.days "days" >
diff --git a/comm/calendar/locales/en-US/chrome/calendar/global.dtd b/comm/calendar/locales/en-US/chrome/calendar/global.dtd
new file mode 100644
index 0000000000..8a7c7949e5
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/global.dtd
@@ -0,0 +1,21 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY month.1.name "January" >
+<!ENTITY month.2.name "February" >
+<!ENTITY month.3.name "March" >
+<!ENTITY month.4.name "April" >
+<!ENTITY month.5.name "May" >
+<!ENTITY month.6.name "June" >
+<!ENTITY month.7.name "July" >
+<!ENTITY month.8.name "August" >
+<!ENTITY month.9.name "September" >
+<!ENTITY month.10.name "October" >
+<!ENTITY month.11.name "November" >
+<!ENTITY month.12.name "December" >
+
+<!ENTITY showToday.tooltip "Go to Today">
+<!ENTITY onedayforward.tooltip "One Day Forward">
+<!ENTITY onedaybackward.tooltip "One Day Backward">
+<!ENTITY showselectedday.tooltip "Show events for selected day">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/menuOverlay.dtd b/comm/calendar/locales/en-US/chrome/calendar/menuOverlay.dtd
new file mode 100644
index 0000000000..aa6b1c7529
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/menuOverlay.dtd
@@ -0,0 +1,46 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- Event Menu -->
+<!ENTITY event.new.event "New Event…">
+<!ENTITY event.new.event.accesskey "N">
+
+<!ENTITY event.new.task "New Task…">
+<!ENTITY event.new.task.accesskey "k">
+
+<!ENTITY calendar.import.label "Import…">
+<!ENTITY calendar.import.accesskey "I">
+
+<!ENTITY calendar.export.label "Export…">
+<!ENTITY calendar.export.accesskey "E">
+
+<!ENTITY calendar.publish.label "Publish…">
+<!ENTITY calendar.publish.accesskey "b">
+
+<!ENTITY calendar.deletecalendar.label "Delete Selected Calendar…">
+<!ENTITY calendar.deletecalendar.accesskey "D">
+<!ENTITY calendar.unsubscribecalendar.label "Unsubscribe Selected Calendar…">
+<!ENTITY calendar.unsubscribecalendar.accesskey "U">
+
+<!-- LOCALIZATION NOTE (calendar.removecalendar.label): Removing the calendar
+ is the general action of removing it, while deleting means to clear the
+ data and unsubscribing means just taking it out of the calendar list. -->
+<!ENTITY calendar.removecalendar.label "Remove Selected Calendar…">
+<!ENTITY calendar.removecalendar.accesskey "R">
+
+<!ENTITY showUnifinderCmd.label "Find Events">
+<!ENTITY showUnifinderCmd.accesskey "F">
+<!ENTITY showUnifinderCmd.tooltip "Toggle the find events pane">
+
+<!ENTITY calendar.displaytodos.checkbox.label "Show Tasks in Calendar">
+<!ENTITY calendar.displaytodos.checkbox.accesskey "T">
+
+<!ENTITY goTodayCmd.label "Today">
+<!ENTITY goTodayCmd.accesskey "T">
+
+<!ENTITY showCurrentView.label "Current View">
+<!ENTITY showCurrentView.accesskey "V">
+
+<!ENTITY calendar.properties.label "Calendar Properties…">
+<!ENTITY calendar.properties.accesskey "a">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/migration.dtd b/comm/calendar/locales/en-US/chrome/calendar/migration.dtd
new file mode 100644
index 0000000000..78440db5ee
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/migration.dtd
@@ -0,0 +1,9 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY migration.title "&brandFullName;: Data Import">
+<!ENTITY migration.welcome "Welcome">
+<!ENTITY migration.importing "Importing">
+<!ENTITY migration.list.description "&brandShortName; can import calendar data from many popular applications. Data from the following applications were found on your computer. Please select which of these you would like to import data from.">
+<!ENTITY migration.progress.description "Importing selected data">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/migration.properties b/comm/calendar/locales/en-US/chrome/calendar/migration.properties
new file mode 100644
index 0000000000..c8ae25fc1b
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/migration.properties
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+migratingApp = Migrating %1$S…
+
+# The next two lines are duplicated from migration.dtd until there is branding
+# for lightning
+migrationTitle = %1$S: Data Import
+migrationDescription=%1$S can import calendar data from many popular applications. Data from the following applications were found on your computer. Please select which of these you would like to import data from.
+finished = Complete
diff --git a/comm/calendar/locales/en-US/chrome/calendar/provider-uninstall.dtd b/comm/calendar/locales/en-US/chrome/calendar/provider-uninstall.dtd
new file mode 100644
index 0000000000..83186d835d
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/provider-uninstall.dtd
@@ -0,0 +1,12 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!ENTITY providerUninstall.title "Uninstall a Provider">
+<!ENTITY providerUninstall.accept.label "Unsubscribe Selected">
+<!ENTITY providerUninstall.accept.accesskey "U">
+<!ENTITY providerUninstall.cancel.label "Keep Addon">
+<!ENTITY providerUninstall.cancel.accesskey "K">
+<!ENTITY providerUninstall.preName.label "You have requested to uninstall or disable:">
+<!ENTITY providerUninstall.postName.label "This will cause the calendars below to be disabled.">
+<!ENTITY providerUninstall.reinstallNote.label "Unless you are planning to reinstall this provider, you may choose to unsubscribe from this provider's calendars.">
diff --git a/comm/calendar/locales/en-US/chrome/calendar/timezones.properties b/comm/calendar/locales/en-US/chrome/calendar/timezones.properties
new file mode 100644
index 0000000000..5bc9215fe6
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/calendar/timezones.properties
@@ -0,0 +1,490 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+pref.timezone.floating=Local Time
+pref.timezone.UTC=UTC/GMT
+
+# This list is derived from the IANA timezone database, but was always
+# incomplete. It will not be updated; future revisions will rely on metazones as
+# defined by CLDR, but these remain in place to prevent regressions in
+# localization.
+
+# timezone names:
+pref.timezone.Africa.Abidjan=Africa/Abidjan
+pref.timezone.Africa.Accra=Africa/Accra
+pref.timezone.Africa.Addis_Ababa=Africa/Addis Ababa
+pref.timezone.Africa.Algiers=Africa/Algiers
+pref.timezone.Africa.Asmara=Africa/Asmara
+pref.timezone.Africa.Bamako=Africa/Bamako
+pref.timezone.Africa.Bangui=Africa/Bangui
+pref.timezone.Africa.Banjul=Africa/Banjul
+pref.timezone.Africa.Bissau=Africa/Bissau
+pref.timezone.Africa.Blantyre=Africa/Blantyre
+pref.timezone.Africa.Brazzaville=Africa/Brazzaville
+pref.timezone.Africa.Bujumbura=Africa/Bujumbura
+pref.timezone.Africa.Cairo=Africa/Cairo
+pref.timezone.Africa.Casablanca=Africa/Casablanca
+pref.timezone.Africa.Ceuta=Africa/Ceuta
+pref.timezone.Africa.Conakry=Africa/Conakry
+pref.timezone.Africa.Dakar=Africa/Dakar
+pref.timezone.Africa.Dar_es_Salaam=Africa/Dar es Salaam
+pref.timezone.Africa.Djibouti=Africa/Djibouti
+pref.timezone.Africa.Douala=Africa/Douala
+pref.timezone.Africa.El_Aaiun=Africa/El Aaiun
+pref.timezone.Africa.Freetown=Africa/Freetown
+pref.timezone.Africa.Gaborone=Africa/Gaborone
+pref.timezone.Africa.Harare=Africa/Harare
+pref.timezone.Africa.Johannesburg=Africa/Johannesburg
+pref.timezone.Africa.Kampala=Africa/Kampala
+pref.timezone.Africa.Khartoum=Africa/Khartoum
+pref.timezone.Africa.Kigali=Africa/Kigali
+pref.timezone.Africa.Kinshasa=Africa/Kinshasa
+pref.timezone.Africa.Lagos=Africa/Lagos
+pref.timezone.Africa.Libreville=Africa/Libreville
+pref.timezone.Africa.Lome=Africa/Lome
+pref.timezone.Africa.Luanda=Africa/Luanda
+pref.timezone.Africa.Lubumbashi=Africa/Lubumbashi
+pref.timezone.Africa.Lusaka=Africa/Lusaka
+pref.timezone.Africa.Malabo=Africa/Malabo
+pref.timezone.Africa.Maputo=Africa/Maputo
+pref.timezone.Africa.Maseru=Africa/Maseru
+pref.timezone.Africa.Mbabane=Africa/Mbabane
+pref.timezone.Africa.Mogadishu=Africa/Mogadishu
+pref.timezone.Africa.Monrovia=Africa/Monrovia
+pref.timezone.Africa.Nairobi=Africa/Nairobi
+pref.timezone.Africa.Ndjamena=Africa/Ndjamena
+pref.timezone.Africa.Niamey=Africa/Niamey
+pref.timezone.Africa.Nouakchott=Africa/Nouakchott
+pref.timezone.Africa.Ouagadougou=Africa/Ouagadougou
+pref.timezone.Africa.Porto-Novo=Africa/Porto-Novo
+pref.timezone.Africa.Sao_Tome=Africa/Sao Tome
+pref.timezone.Africa.Tripoli=Africa/Tripoli
+pref.timezone.Africa.Tunis=Africa/Tunis
+pref.timezone.Africa.Windhoek=Africa/Windhoek
+pref.timezone.America.Adak=America/Adak
+pref.timezone.America.Anchorage=America/Anchorage
+pref.timezone.America.Anguilla=America/Anguilla
+pref.timezone.America.Antigua=America/Antigua
+pref.timezone.America.Araguaina=America/Araguaina
+pref.timezone.America.Argentina.Buenos_Aires=America/Argentina/Buenos Aires
+pref.timezone.America.Argentina.Catamarca=America/Argentina/Catamarca
+pref.timezone.America.Argentina.Cordoba=America/Argentina/Cordoba
+pref.timezone.America.Argentina.Jujuy=America/Argentina/Jujuy
+pref.timezone.America.Argentina.La_Rioja=America/Argentina/La Rioja
+pref.timezone.America.Argentina.Mendoza=America/Argentina/Mendoza
+pref.timezone.America.Argentina.Rio_Gallegos=America/Argentina/Rio Gallegos
+pref.timezone.America.Argentina.San_Juan=America/Argentina/San Juan
+pref.timezone.America.Argentina.Tucuman=America/Argentina/Tucuman
+pref.timezone.America.Argentina.Ushuaia=America/Argentina/Ushuaia
+pref.timezone.America.Aruba=America/Aruba
+pref.timezone.America.Asuncion=America/Asuncion
+pref.timezone.America.Atikokan=America/Atikokan
+pref.timezone.America.Bahia=America/Bahia
+pref.timezone.America.Barbados=America/Barbados
+pref.timezone.America.Belem=America/Belem
+pref.timezone.America.Belize=America/Belize
+pref.timezone.America.Blanc-Sablon=America/Blanc-Sablon
+pref.timezone.America.Boa_Vista=America/Boa Vista
+pref.timezone.America.Bogota=America/Bogota
+pref.timezone.America.Boise=America/Boise
+pref.timezone.America.Cambridge_Bay=America/Cambridge Bay
+pref.timezone.America.Campo_Grande=America/Campo Grande
+pref.timezone.America.Cancun=America/Cancun
+pref.timezone.America.Caracas=America/Caracas
+pref.timezone.America.Cayenne=America/Cayenne
+pref.timezone.America.Cayman=America/Cayman
+pref.timezone.America.Chicago=America/Chicago
+pref.timezone.America.Chihuahua=America/Chihuahua
+pref.timezone.America.Costa_Rica=America/Costa Rica
+pref.timezone.America.Cuiaba=America/Cuiaba
+pref.timezone.America.Curacao=America/Curacao
+pref.timezone.America.Danmarkshavn=America/Danmarkshavn
+pref.timezone.America.Dawson=America/Dawson
+pref.timezone.America.Dawson_Creek=America/Dawson Creek
+pref.timezone.America.Denver=America/Denver
+pref.timezone.America.Detroit=America/Detroit
+pref.timezone.America.Dominica=America/Dominica
+pref.timezone.America.Edmonton=America/Edmonton
+pref.timezone.America.Eirunepe=America/Eirunepe
+pref.timezone.America.El_Salvador=America/El Salvador
+pref.timezone.America.Fortaleza=America/Fortaleza
+pref.timezone.America.Glace_Bay=America/Glace Bay
+pref.timezone.America.Godthab=America/Godthab
+pref.timezone.America.Goose_Bay=America/Goose Bay
+pref.timezone.America.Grand_Turk=America/Grand Turk
+pref.timezone.America.Grenada=America/Grenada
+pref.timezone.America.Guadeloupe=America/Guadeloupe
+pref.timezone.America.Guatemala=America/Guatemala
+pref.timezone.America.Guayaquil=America/Guayaquil
+pref.timezone.America.Guyana=America/Guyana
+pref.timezone.America.Halifax=America/Halifax
+pref.timezone.America.Havana=America/Havana
+pref.timezone.America.Hermosillo=America/Hermosillo
+pref.timezone.America.Indiana.Indianapolis=America/Indiana/Indianapolis
+pref.timezone.America.Indiana.Knox=America/Indiana/Knox
+pref.timezone.America.Indiana.Marengo=America/Indiana/Marengo
+pref.timezone.America.Indiana.Petersburg=America/Indiana/Petersburg
+pref.timezone.America.Indiana.Vevay=America/Indiana/Vevay
+pref.timezone.America.Indiana.Vincennes=America/Indiana/Vincennes
+pref.timezone.America.Inuvik=America/Inuvik
+pref.timezone.America.Iqaluit=America/Iqaluit
+pref.timezone.America.Jamaica=America/Jamaica
+pref.timezone.America.Juneau=America/Juneau
+pref.timezone.America.Kentucky.Louisville=America/Kentucky/Louisville
+pref.timezone.America.Kentucky.Monticello=America/Kentucky/Monticello
+pref.timezone.America.La_Paz=America/La Paz
+pref.timezone.America.Lima=America/Lima
+pref.timezone.America.Los_Angeles=America/Los Angeles
+pref.timezone.America.Maceio=America/Maceio
+pref.timezone.America.Managua=America/Managua
+pref.timezone.America.Manaus=America/Manaus
+pref.timezone.America.Martinique=America/Martinique
+pref.timezone.America.Mazatlan=America/Mazatlan
+pref.timezone.America.Menominee=America/Menominee
+pref.timezone.America.Merida=America/Merida
+pref.timezone.America.Mexico_City=America/Mexico City
+pref.timezone.America.Miquelon=America/Miquelon
+pref.timezone.America.Moncton=America/Moncton
+pref.timezone.America.Monterrey=America/Monterrey
+pref.timezone.America.Montevideo=America/Montevideo
+pref.timezone.America.Montreal=America/Montreal
+pref.timezone.America.Montserrat=America/Montserrat
+pref.timezone.America.Nassau=America/Nassau
+pref.timezone.America.New_York=America/New York
+pref.timezone.America.Nipigon=America/Nipigon
+pref.timezone.America.Nome=America/Nome
+pref.timezone.America.Noronha=America/Noronha
+pref.timezone.America.North_Dakota.Center=America/North Dakota/Center
+pref.timezone.America.North_Dakota.New_Salem=America/North Dakota/New Salem
+pref.timezone.America.Panama=America/Panama
+pref.timezone.America.Pangnirtung=America/Pangnirtung
+pref.timezone.America.Paramaribo=America/Paramaribo
+pref.timezone.America.Phoenix=America/Phoenix
+pref.timezone.America.Port-au-Prince=America/Port-au-Prince
+pref.timezone.America.Port_of_Spain=America/Port of Spain
+pref.timezone.America.Porto_Velho=America/Porto Velho
+pref.timezone.America.Puerto_Rico=America/Puerto Rico
+pref.timezone.America.Rainy_River=America/Rainy River
+pref.timezone.America.Rankin_Inlet=America/Rankin Inlet
+pref.timezone.America.Recife=America/Recife
+pref.timezone.America.Regina=America/Regina
+pref.timezone.America.Rio_Branco=America/Rio Branco
+pref.timezone.America.Santiago=America/Santiago
+pref.timezone.America.Santo_Domingo=America/Santo Domingo
+pref.timezone.America.Sao_Paulo=America/Sao Paulo
+pref.timezone.America.Scoresbysund=America/Scoresbysund
+pref.timezone.America.Shiprock=America/Shiprock
+pref.timezone.America.St_Johns=America/St. Johns
+pref.timezone.America.St_Kitts=America/St. Kitts
+pref.timezone.America.St_Lucia=America/St. Lucia
+pref.timezone.America.St_Thomas=America/St. Thomas
+pref.timezone.America.St_Vincent=America/St. Vincent
+pref.timezone.America.Swift_Current=America/Swift Current
+pref.timezone.America.Tegucigalpa=America/Tegucigalpa
+pref.timezone.America.Thule=America/Thule
+pref.timezone.America.Thunder_Bay=America/Thunder Bay
+pref.timezone.America.Tijuana=America/Tijuana
+pref.timezone.America.Toronto=America/Toronto
+pref.timezone.America.Tortola=America/Tortola
+pref.timezone.America.Vancouver=America/Vancouver
+pref.timezone.America.Whitehorse=America/Whitehorse
+pref.timezone.America.Winnipeg=America/Winnipeg
+pref.timezone.America.Yakutat=America/Yakutat
+pref.timezone.America.Yellowknife=America/Yellowknife
+pref.timezone.Antarctica.Casey=Antarctica/Casey
+pref.timezone.Antarctica.Davis=Antarctica/Davis
+pref.timezone.Antarctica.DumontDUrville=Antarctica/DumontDUrville
+pref.timezone.Antarctica.Mawson=Antarctica/Mawson
+pref.timezone.Antarctica.McMurdo=Antarctica/McMurdo
+pref.timezone.Antarctica.Palmer=Antarctica/Palmer
+pref.timezone.Antarctica.Rothera=Antarctica/Rothera
+pref.timezone.Antarctica.South_Pole=Antarctica/South Pole
+pref.timezone.Antarctica.Syowa=Antarctica/Syowa
+pref.timezone.Antarctica.Vostok=Antarctica/Vostok
+pref.timezone.Arctic.Longyearbyen=Arctic/Longyearbyen
+pref.timezone.Asia.Aden=Asia/Aden
+pref.timezone.Asia.Almaty=Asia/Almaty
+pref.timezone.Asia.Amman=Asia/Amman
+pref.timezone.Asia.Anadyr=Asia/Anadyr
+pref.timezone.Asia.Aqtau=Asia/Aqtau
+pref.timezone.Asia.Aqtobe=Asia/Aqtobe
+pref.timezone.Asia.Ashgabat=Asia/Ashgabat
+pref.timezone.Asia.Baghdad=Asia/Baghdad
+pref.timezone.Asia.Bahrain=Asia/Bahrain
+pref.timezone.Asia.Baku=Asia/Baku
+pref.timezone.Asia.Bangkok=Asia/Bangkok
+pref.timezone.Asia.Beirut=Asia/Beirut
+pref.timezone.Asia.Bishkek=Asia/Bishkek
+pref.timezone.Asia.Brunei=Asia/Brunei
+pref.timezone.Asia.Choibalsan=Asia/Choibalsan
+pref.timezone.Asia.Chongqing=Asia/Chongqing
+pref.timezone.Asia.Colombo=Asia/Colombo
+pref.timezone.Asia.Damascus=Asia/Damascus
+pref.timezone.Asia.Dhaka=Asia/Dhaka
+pref.timezone.Asia.Dili=Asia/Dili
+pref.timezone.Asia.Dubai=Asia/Dubai
+pref.timezone.Asia.Dushanbe=Asia/Dushanbe
+pref.timezone.Asia.Gaza=Asia/Gaza
+pref.timezone.Asia.Harbin=Asia/Harbin
+pref.timezone.Asia.Hong_Kong=Asia/Hong Kong
+pref.timezone.Asia.Hovd=Asia/Hovd
+pref.timezone.Asia.Irkutsk=Asia/Irkutsk
+pref.timezone.Asia.Istanbul=Asia/Istanbul
+pref.timezone.Asia.Jakarta=Asia/Jakarta
+pref.timezone.Asia.Jayapura=Asia/Jayapura
+pref.timezone.Asia.Jerusalem=Asia/Jerusalem
+pref.timezone.Asia.Kabul=Asia/Kabul
+pref.timezone.Asia.Kamchatka=Asia/Kamchatka
+pref.timezone.Asia.Karachi=Asia/Karachi
+pref.timezone.Asia.Kashgar=Asia/Kashgar
+pref.timezone.Asia.Kathmandu=Asia/Kathmandu
+pref.timezone.Asia.Krasnoyarsk=Asia/Krasnoyarsk
+pref.timezone.Asia.Kuala_Lumpur=Asia/Kuala Lumpur
+pref.timezone.Asia.Kuching=Asia/Kuching
+pref.timezone.Asia.Kuwait=Asia/Kuwait
+pref.timezone.Asia.Macau=Asia/Macau
+pref.timezone.Asia.Magadan=Asia/Magadan
+pref.timezone.Asia.Makassar=Asia/Makassar
+pref.timezone.Asia.Manila=Asia/Manila
+pref.timezone.Asia.Muscat=Asia/Muscat
+pref.timezone.Asia.Nicosia=Asia/Nicosia
+pref.timezone.Asia.Novosibirsk=Asia/Novosibirsk
+pref.timezone.Asia.Omsk=Asia/Omsk
+pref.timezone.Asia.Oral=Asia/Oral
+pref.timezone.Asia.Phnom_Penh=Asia/Phnom Penh
+pref.timezone.Asia.Pontianak=Asia/Pontianak
+pref.timezone.Asia.Pyongyang=Asia/Pyongyang
+pref.timezone.Asia.Qatar=Asia/Qatar
+pref.timezone.Asia.Qyzylorda=Asia/Qyzylorda
+pref.timezone.Asia.Rangoon=Asia/Rangoon
+pref.timezone.Asia.Riyadh=Asia/Riyadh
+pref.timezone.Asia.Sakhalin=Asia/Sakhalin
+pref.timezone.Asia.Samarkand=Asia/Samarkand
+pref.timezone.Asia.Seoul=Asia/Seoul
+pref.timezone.Asia.Shanghai=Asia/Shanghai
+pref.timezone.Asia.Singapore=Asia/Singapore
+pref.timezone.Asia.Taipei=Asia/Taipei
+pref.timezone.Asia.Tashkent=Asia/Tashkent
+pref.timezone.Asia.Tbilisi=Asia/Tbilisi
+pref.timezone.Asia.Tehran=Asia/Tehran
+pref.timezone.Asia.Thimphu=Asia/Thimphu
+pref.timezone.Asia.Tokyo=Asia/Tokyo
+pref.timezone.Asia.Ulaanbaatar=Asia/Ulaanbaatar
+pref.timezone.Asia.Urumqi=Asia/Urumqi
+pref.timezone.Asia.Vientiane=Asia/Vientiane
+pref.timezone.Asia.Vladivostok=Asia/Vladivostok
+pref.timezone.Asia.Yakutsk=Asia/Yakutsk
+pref.timezone.Asia.Yekaterinburg=Asia/Yekaterinburg
+pref.timezone.Asia.Yerevan=Asia/Yerevan
+pref.timezone.Atlantic.Azores=Atlantic/Azores
+pref.timezone.Atlantic.Bermuda=Atlantic/Bermuda
+pref.timezone.Atlantic.Canary=Atlantic/Canary
+pref.timezone.Atlantic.Cape_Verde=Atlantic/Cape Verde
+pref.timezone.Atlantic.Faroe=Atlantic/Faroe
+pref.timezone.Atlantic.Madeira=Atlantic/Madeira
+pref.timezone.Atlantic.Reykjavik=Atlantic/Reykjavik
+pref.timezone.Atlantic.South_Georgia=Atlantic/South Georgia
+pref.timezone.Atlantic.St_Helena=Atlantic/St. Helena
+pref.timezone.Atlantic.Stanley=Atlantic/Stanley
+pref.timezone.Australia.Adelaide=Australia/Adelaide
+pref.timezone.Australia.Brisbane=Australia/Brisbane
+pref.timezone.Australia.Broken_Hill=Australia/Broken Hill
+pref.timezone.Australia.Currie=Australia/Currie
+pref.timezone.Australia.Darwin=Australia/Darwin
+pref.timezone.Australia.Eucla=Australia/Eucla
+pref.timezone.Australia.Hobart=Australia/Hobart
+pref.timezone.Australia.Lindeman=Australia/Lindeman
+pref.timezone.Australia.Lord_Howe=Australia/Lord Howe
+pref.timezone.Australia.Melbourne=Australia/Melbourne
+pref.timezone.Australia.Perth=Australia/Perth
+pref.timezone.Australia.Sydney=Australia/Sydney
+pref.timezone.Europe.Amsterdam=Europe/Amsterdam
+pref.timezone.Europe.Andorra=Europe/Andorra
+pref.timezone.Europe.Athens=Europe/Athens
+pref.timezone.Europe.Belgrade=Europe/Belgrade
+pref.timezone.Europe.Berlin=Europe/Berlin
+pref.timezone.Europe.Bratislava=Europe/Bratislava
+pref.timezone.Europe.Brussels=Europe/Brussels
+pref.timezone.Europe.Bucharest=Europe/Bucharest
+pref.timezone.Europe.Budapest=Europe/Budapest
+pref.timezone.Europe.Chisinau=Europe/Chisinau
+pref.timezone.Europe.Copenhagen=Europe/Copenhagen
+pref.timezone.Europe.Dublin=Europe/Dublin
+pref.timezone.Europe.Gibraltar=Europe/Gibraltar
+pref.timezone.Europe.Guernsey=Europe/Guernsey
+pref.timezone.Europe.Helsinki=Europe/Helsinki
+pref.timezone.Europe.Isle_of_Man=Europe/Isle of Man
+pref.timezone.Europe.Istanbul=Europe/Istanbul
+pref.timezone.Europe.Jersey=Europe/Jersey
+pref.timezone.Europe.Kaliningrad=Europe/Kaliningrad
+pref.timezone.Europe.Kiev=Europe/Kiev
+pref.timezone.Europe.Lisbon=Europe/Lisbon
+pref.timezone.Europe.Ljubljana=Europe/Ljubljana
+pref.timezone.Europe.London=Europe/London
+pref.timezone.Europe.Luxembourg=Europe/Luxembourg
+pref.timezone.Europe.Madrid=Europe/Madrid
+pref.timezone.Europe.Malta=Europe/Malta
+pref.timezone.Europe.Mariehamn=Europe/Mariehamn
+pref.timezone.Europe.Minsk=Europe/Minsk
+pref.timezone.Europe.Monaco=Europe/Monaco
+pref.timezone.Europe.Moscow=Europe/Moscow
+pref.timezone.Europe.Nicosia=Europe/Nicosia
+pref.timezone.Europe.Oslo=Europe/Oslo
+pref.timezone.Europe.Paris=Europe/Paris
+pref.timezone.Europe.Podgorica=Europe/Podgorica
+pref.timezone.Europe.Prague=Europe/Prague
+pref.timezone.Europe.Riga=Europe/Riga
+pref.timezone.Europe.Rome=Europe/Rome
+pref.timezone.Europe.Samara=Europe/Samara
+pref.timezone.Europe.San_Marino=Europe/San Marino
+pref.timezone.Europe.Sarajevo=Europe/Sarajevo
+pref.timezone.Europe.Simferopol=Europe/Simferopol
+pref.timezone.Europe.Skopje=Europe/Skopje
+pref.timezone.Europe.Sofia=Europe/Sofia
+pref.timezone.Europe.Stockholm=Europe/Stockholm
+pref.timezone.Europe.Tallinn=Europe/Tallinn
+pref.timezone.Europe.Tirane=Europe/Tirane
+pref.timezone.Europe.Uzhgorod=Europe/Uzhgorod
+pref.timezone.Europe.Vaduz=Europe/Vaduz
+pref.timezone.Europe.Vatican=Europe/Vatican
+pref.timezone.Europe.Vienna=Europe/Vienna
+pref.timezone.Europe.Vilnius=Europe/Vilnius
+pref.timezone.Europe.Volgograd=Europe/Volgograd
+pref.timezone.Europe.Warsaw=Europe/Warsaw
+pref.timezone.Europe.Zagreb=Europe/Zagreb
+pref.timezone.Europe.Zaporozhye=Europe/Zaporozhye
+pref.timezone.Europe.Zurich=Europe/Zurich
+pref.timezone.Indian.Antananarivo=Indian/Antananarivo
+pref.timezone.Indian.Chagos=Indian/Chagos
+pref.timezone.Indian.Christmas=Indian/Christmas
+pref.timezone.Indian.Cocos=Indian/Cocos
+pref.timezone.Indian.Comoro=Indian/Comoro
+pref.timezone.Indian.Kerguelen=Indian/Kerguelen
+pref.timezone.Indian.Mahe=Indian/Mahe
+pref.timezone.Indian.Maldives=Indian/Maldives
+pref.timezone.Indian.Mauritius=Indian/Mauritius
+pref.timezone.Indian.Mayotte=Indian/Mayotte
+pref.timezone.Indian.Reunion=Indian/Reunion
+pref.timezone.Pacific.Apia=Pacific/Apia
+pref.timezone.Pacific.Auckland=Pacific/Auckland
+pref.timezone.Pacific.Chatham=Pacific/Chatham
+pref.timezone.Pacific.Easter=Pacific/Easter
+pref.timezone.Pacific.Efate=Pacific/Efate
+pref.timezone.Pacific.Enderbury=Pacific/Enderbury
+pref.timezone.Pacific.Fakaofo=Pacific/Fakaofo
+pref.timezone.Pacific.Fiji=Pacific/Fiji
+pref.timezone.Pacific.Funafuti=Pacific/Funafuti
+pref.timezone.Pacific.Galapagos=Pacific/Galapagos
+pref.timezone.Pacific.Gambier=Pacific/Gambier
+pref.timezone.Pacific.Guadalcanal=Pacific/Guadalcanal
+pref.timezone.Pacific.Guam=Pacific/Guam
+pref.timezone.Pacific.Honolulu=Pacific/Honolulu
+pref.timezone.Pacific.Johnston=Pacific/Johnston
+pref.timezone.Pacific.Kiritimati=Pacific/Kiritimati
+pref.timezone.Pacific.Kosrae=Pacific/Kosrae
+pref.timezone.Pacific.Kwajalein=Pacific/Kwajalein
+pref.timezone.Pacific.Majuro=Pacific/Majuro
+pref.timezone.Pacific.Marquesas=Pacific/Marquesas
+pref.timezone.Pacific.Midway=Pacific/Midway
+pref.timezone.Pacific.Nauru=Pacific/Nauru
+pref.timezone.Pacific.Niue=Pacific/Niue
+pref.timezone.Pacific.Norfolk=Pacific/Norfolk
+pref.timezone.Pacific.Noumea=Pacific/Noumea
+pref.timezone.Pacific.Pago_Pago=Pacific/Pago Pago
+pref.timezone.Pacific.Palau=Pacific/Palau
+pref.timezone.Pacific.Pitcairn=Pacific/Pitcairn
+pref.timezone.Pacific.Ponape=Pacific/Ponape
+pref.timezone.Pacific.Port_Moresby=Pacific/Port Moresby
+pref.timezone.Pacific.Rarotonga=Pacific/Rarotonga
+pref.timezone.Pacific.Saipan=Pacific/Saipan
+pref.timezone.Pacific.Tahiti=Pacific/Tahiti
+pref.timezone.Pacific.Tarawa=Pacific/Tarawa
+pref.timezone.Pacific.Tongatapu=Pacific/Tongatapu
+pref.timezone.Pacific.Truk=Pacific/Truk
+pref.timezone.Pacific.Wake=Pacific/Wake
+pref.timezone.Pacific.Wallis=Pacific/Wallis
+
+# the following have been missing
+pref.timezone.America.Indiana.Tell_City=America/Indiana/Tell City
+pref.timezone.America.Indiana.Winamac=America/Indiana/Winamac
+pref.timezone.America.Marigot=America/Marigot
+pref.timezone.America.Resolute=America/Resolute
+pref.timezone.America.St_Barthelemy=America/St. Barthelemy
+
+# added with 2008d:
+pref.timezone.America.Argentina.San_Luis=America/Argentina/San Luis
+pref.timezone.America.Santarem=America/Santarem
+pref.timezone.Asia.Ho_Chi_Minh=Asia/Ho Chi Minh
+pref.timezone.Asia.Kolkata=Asia/Kolkata
+
+# added with 2008i:
+pref.timezone.America.Argentina.Salta=America/Argentina/Salta
+
+# added with 2010i
+pref.timezone.America.Matamoros=America/Matamoros
+pref.timezone.America.Ojinaga=America/Ojinaga
+pref.timezone.America.Santa_Isabel=America/Santa Isabel
+pref.timezone.Antarctica.Macquarie=Antarctica/Macquarie
+pref.timezone.Asia.Novokuznetsk=Asia/Novokuznetsk
+
+#added with 2011b
+pref.timezone.America.Bahia_Banderas=America/Bahia Banderas
+pref.timezone.America.North_Dakota.Beulah=America/North Dakota/Beulah
+pref.timezone.Pacific.Chuuk=Pacific/Chuuk
+pref.timezone.Pacific.Pohnpei=Pacific/Pohnpei
+
+#added with 2011n
+pref.timezone.Africa.Juba=Africa/Juba
+pref.timezone.America.Kralendijk=America/Kralendijk
+pref.timezone.America.Lower_Princes=America/Lower Princes
+pref.timezone.America.Metlakatla=America/Metlakatla
+pref.timezone.America.Sitka=America/Sitka
+pref.timezone.Asia.Hebron=Asia/Hebron
+
+#added with 2013a
+pref.timezone.America.Creston=America/Creston
+pref.timezone.Asia.Khandyga=Asia/Khandyga
+pref.timezone.Asia.Ust-Nera=Asia/Ust-Nera
+pref.timezone.Europe.Busingen=Europe/Busingen
+
+#added with 2014b
+pref.timezone.Antarctica.Troll=Antarctica/Troll
+
+#added with 2014j
+pref.timezone.Asia.Chita=Asia/Chita
+pref.timezone.Asia.Srednekolymsk=Asia/Srednekolymsk
+pref.timezone.Pacific.Bougainville=Pacific/Bougainville
+
+#added with 2.2015g
+pref.timezone.America.Fort_Nelson=America/Fort Nelson
+
+#added with 2.2016b
+pref.timezone.Europe.Ulyanovsk=Europe/Ulyanovsk
+pref.timezone.Europe.Astrakhan=Europe/Astrakhan
+pref.timezone.Asia.Barnaul=Asia/Barnaul
+
+#added with 2.2016i
+pref.timezone.Asia.Yangon=Asia/Yangon
+pref.timezone.Asia.Tomsk=Asia/Tomsk
+pref.timezone.Asia.Famagusta=Asia/Famagusta
+pref.timezone.Europe.Kirov=Europe/Kirov
+
+#added with 2.2016j
+pref.timezone.Europe.Saratov=Europe/Saratov
+pref.timezone.Asia.Atyrau=Asia/Atyrau
+
+#added with 2.2017b
+pref.timezone.America.Punta_Arenas=America/Punta Arenas
+
+#added with 2.2018i
+pref.timezone.Asia.Qostanay=Asia/Qostanay
+
+#added with 2.2020a
+pref.timezone.America.Nuuk=America/Nuuk
+
+#added with 2.2021c
+pref.timezone.Pacific.Kanton=Pacific/Kanton
+
+#added with 2.2022b
+pref.timezone.Europe.Kyiv=Europe/Kyiv
diff --git a/comm/calendar/locales/en-US/chrome/lightning/lightning-toolbar.dtd b/comm/calendar/locales/en-US/chrome/lightning/lightning-toolbar.dtd
new file mode 100644
index 0000000000..ed3b8d66b9
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/lightning/lightning-toolbar.dtd
@@ -0,0 +1,25 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- Mode Toolbar -->
+<!ENTITY lightning.toolbar.calendar.label "Calendar">
+<!ENTITY lightning.toolbar.calendar.tooltip "Switch to the calendar tab">
+<!ENTITY lightning.toolbar.calendar.accesskey "C">
+<!ENTITY lightning.toolbar.task.label "Tasks">
+<!ENTITY lightning.toolbar.task.tooltip "Switch to the tasks tab">
+<!ENTITY lightning.toolbar.task.accesskey "T">
+
+<!-- Calendar and Task Mode Toolbar -->
+<!ENTITY lightning.toolbar.day.label "Day">
+<!ENTITY lightning.toolbar.day.accesskey "D">
+<!ENTITY lightning.toolbar.week.label "Week">
+<!ENTITY lightning.toolbar.week.accesskey "W">
+<!ENTITY lightning.toolbar.multiweek.label "Multiweek">
+<!ENTITY lightning.toolbar.multiweek.accesskey "u">
+<!ENTITY lightning.toolbar.month.label "Month">
+<!ENTITY lightning.toolbar.month.accesskey "M">
+<!ENTITY lightning.toolbar.calendarmenu.label "Calendar Pane">
+<!ENTITY lightning.toolbar.calendarmenu.accesskey "P">
+<!ENTITY lightning.toolbar.calendarpane.label "Show Calendar Pane">
+<!ENTITY lightning.toolbar.calendarpane.accesskey "P">
diff --git a/comm/calendar/locales/en-US/chrome/lightning/lightning.dtd b/comm/calendar/locales/en-US/chrome/lightning/lightning.dtd
new file mode 100644
index 0000000000..66205f27b8
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/lightning/lightning.dtd
@@ -0,0 +1,112 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- WARNING! This file contains UTF-8 encoded characters!
+ - If this ==> … <== doesn't look like an ellipsis (three dots in a row),
+ - your editor isn't using UTF-8 encoding and may munge up the document!
+ -->
+
+<!-- Tools menu -->
+<!ENTITY lightning.preferencesLabel "Calendar">
+
+<!-- New menu popup in File menu -->
+<!ENTITY lightning.menupopup.new.event.label "Event…">
+<!ENTITY lightning.menupopup.new.event.accesskey "E">
+<!ENTITY lightning.menupopup.new.task.label "Task…">
+<!ENTITY lightning.menupopup.new.task.accesskey "T">
+<!ENTITY lightning.menupopup.new.calendar.label "Calendar…">
+<!ENTITY lightning.menupopup.new.calendar.accesskey "n">
+
+<!-- Open menu popup in File menu -->
+<!ENTITY lightning.menupopup.open.calendar.label "Calendar File…">
+<!ENTITY lightning.menupopup.open.calendar.accesskey "C">
+
+<!-- View Menu -->
+<!ENTITY lightning.menu.view.calendar.label "Calendar">
+<!ENTITY lightning.menu.view.calendar.accesskey "n">
+<!ENTITY lightning.menu.view.tasks.label "Tasks">
+<!ENTITY lightning.menu.view.tasks.accesskey "k">
+
+<!-- Events and Tasks menu -->
+<!ENTITY lightning.menu.eventtask.label "Events and Tasks">
+<!ENTITY lightning.menu.eventtask.accesskey "n">
+
+<!-- properties dialog, calendar creation wizard -->
+<!-- LOCALIZATON NOTE(lightning.calendarproperties.email.label,
+ lightning.calendarproperties.forceEmailScheduling.label)
+ These strings are used in the calendar wizard and the calendar properties dialog, but are only
+ displayed when setting/using a caldav calendar -->
+<!ENTITY lightning.calendarproperties.email.label "Email:">
+<!ENTITY lightning.calendarproperties.forceEmailScheduling.label "Prefer client-side email scheduling">
+<!-- LOCALIZATON NOTE(lightning.calendarproperties.forceEmailScheduling.tooltiptext1,
+ lightning.calendarproperties.forceEmailScheduling.tooltiptext2)
+ - tooltiptext1 is used in the calendar wizard when setting a new caldav calendar
+ - tooltiptext2 is used in the calendar properties dialog for caldav calendars -->
+<!ENTITY lightning.calendarproperties.forceEmailScheduling.tooltiptext1 "For now, you can only enable this after setting up this calendar in its property dialog if the calendar server takes care of scheduling.">
+<!ENTITY lightning.calendarproperties.forceEmailScheduling.tooltiptext2 "This option is only available if the calendar server handles scheduling. Enabling will allow to fall back to the standard email based scheduling instead of leaving it to the server.">
+
+<!-- The notifications settings in the properties dialog -->
+<!ENTITY lightning.calendarproperties.notifications.label "Notifications">
+<!ENTITY lightning.calendarproperties.globalNotifications.label "Global Notification Preferences…">
+
+<!-- iMIP Bar (meeting support) -->
+<!ENTITY lightning.imipbar.btnAccept.label "Accept">
+<!ENTITY lightning.imipbar.btnAccept2.tooltiptext "Accept event invitation">
+<!ENTITY lightning.imipbar.btnAcceptRecurrences.label "Accept all">
+<!ENTITY lightning.imipbar.btnAcceptRecurrences2.tooltiptext "Accept event invitation for all occurrences of the event">
+<!ENTITY lightning.imipbar.btnAdd.label "Add">
+<!ENTITY lightning.imipbar.btnAdd.tooltiptext "Add the event to the calendar">
+<!ENTITY lightning.imipbar.btnDecline.label "Decline">
+<!ENTITY lightning.imipbar.btnDecline2.tooltiptext "Decline event invitation">
+<!ENTITY lightning.imipbar.btnDeclineRecurrences.label "Decline all">
+<!ENTITY lightning.imipbar.btnDeclineRecurrences2.tooltiptext "Decline event invitation for all occurrences of the event">
+<!ENTITY lightning.imipbar.btnDeclineCounter.label "Decline">
+<!ENTITY lightning.imipbar.btnDeclineCounter.tooltiptext "Decline the counter proposal">
+<!ENTITY lightning.imipbar.btnDelete.label "Delete">
+<!ENTITY lightning.imipbar.btnDelete.tooltiptext "Delete from calendar">
+<!ENTITY lightning.imipbar.btnDetails.label "Details…">
+<!ENTITY lightning.imipbar.btnDetails.tooltiptext "Show event details">
+<!ENTITY lightning.imipbar.btnDoNotShowImipBar.label "Don't show me these messages">
+<!ENTITY lightning.imipbar.btnGoToCalendar.label "Calendar">
+<!ENTITY lightning.imipbar.btnGoToCalendar.tooltiptext "Go to the calendar tab">
+<!ENTITY lightning.imipbar.btnMore.label "More">
+<!ENTITY lightning.imipbar.btnMore.tooltiptext "Click to show more options">
+<!ENTITY lightning.imipbar.btnReconfirm2.label "Reconfirm">
+<!ENTITY lightning.imipbar.btnReconfirm.tooltiptext "Sends a reconfirmation to the organizer">
+<!ENTITY lightning.imipbar.btnReschedule.label "Reschedule">
+<!ENTITY lightning.imipbar.btnReschedule.tooltiptext "Reschedule the event">
+<!ENTITY lightning.imipbar.btnSaveCopy.label "Save a copy">
+<!ENTITY lightning.imipbar.btnSaveCopy.tooltiptext "Save a copy of the event to the calendar independently of replying to the organizer. The list of attendees will be cleared.">
+<!ENTITY lightning.imipbar.btnTentative.label "Tentative">
+<!ENTITY lightning.imipbar.btnTentative2.tooltiptext "Accept event invitation tentatively">
+<!ENTITY lightning.imipbar.btnTentativeRecurrences.label "Tentative all">
+<!ENTITY lightning.imipbar.btnTentativeRecurrences2.tooltiptext "Accept event invitation tentatively for all occurrences of the event">
+<!ENTITY lightning.imipbar.btnUpdate.label "Update">
+<!ENTITY lightning.imipbar.btnUpdate.tooltiptext "Update event in calendar">
+<!ENTITY lightning.imipbar.description "This message contains an invitation to an event.">
+
+<!ENTITY lightning.imipbar.btnSend.label "Send a response now">
+<!ENTITY lightning.imipbar.btnSend.tooltiptext "Send a response to the organizer">
+<!ENTITY lightning.imipbar.btnSendSeries.tooltiptext "Send a response for the entire series to the organizer">
+<!ENTITY lightning.imipbar.btnDontSend.label "Do not send a response">
+<!ENTITY lightning.imipbar.btnDontSend.tooltiptext "Change your participation status without sending a response to the organizer">
+<!ENTITY lightning.imipbar.btnDontSendSeries.tooltiptext "Change your participation status for the series without sending a response to the organizer">
+
+<!-- Lightning specific keybindings -->
+<!ENTITY lightning.keys.event.new "I">
+<!ENTITY lightning.keys.todo.new "D">
+
+<!-- Account Central page -->
+<!ENTITY lightning.acctCentral.newCalendar.label "Create a new calendar">
+
+<!-- today-pane-specific -->
+<!ENTITY todaypane.showMinimonth.label "Show Mini-Month">
+<!ENTITY todaypane.showMinimonth.accesskey "M">
+<!ENTITY todaypane.showMiniday.label "Show Mini-Day">
+<!ENTITY todaypane.showMiniday.accesskey "d">
+<!ENTITY todaypane.showNone.label "Show None">
+<!ENTITY todaypane.showNone.accesskey "N">
+<!ENTITY todaypane.showTodayPane.label "Show Today Pane">
+<!ENTITY todaypane.showTodayPane.accesskey "o">
+<!ENTITY todaypane.statusButton.label "Today Pane">
diff --git a/comm/calendar/locales/en-US/chrome/lightning/lightning.properties b/comm/calendar/locales/en-US/chrome/lightning/lightning.properties
new file mode 100644
index 0000000000..45933a9cb9
--- /dev/null
+++ b/comm/calendar/locales/en-US/chrome/lightning/lightning.properties
@@ -0,0 +1,165 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Task mode title
+taskModeApplicationTitle=Tasks
+
+# Tab titles
+tabTitleCalendar=Calendar
+tabTitleTasks=Tasks
+
+# Html event display in message
+imipHtml.header=Event Invitation
+imipHtml.summary=Title:
+imipHtml.location=Location:
+imipHtml.when=When:
+imipHtml.organizer=Organizer:
+imipHtml.description=Description:
+# LOCALIZATION_NOTE(imipHtml.attachments): This is a label for one or more (additional) links to
+# documents or websites attached to this event.
+imipHtml.attachments=Attachments:
+imipHtml.comment=Comment:
+imipHtml.attendees=Attendees:
+# LOCALIZATION_NOTE(imipHtml.url): This is a label for a reference to an (alternate) online
+# representation of the event (either directly human readable or not).
+imipHtml.url=Related Link:
+imipHtml.canceledOccurrences=Canceled Occurrences:
+imipHtml.modifiedOccurrences=Modified Occurrences:
+imipHtml.newLocation=New Location: %1$S
+# LOCALIZATION_NOTE(imipHtml.attendeeDelegatedFrom): this is appended behind an attendee name in the
+# email invitation preview - don't add leading/trailing whitespaces here
+# %1$S - a single delegator or a comma separated list of delegators
+imipHtml.attendeeDelegatedFrom=(delegated from %1$S)
+# LOCALIZATION_NOTE(imipHtml.attendeeDelegatedTo): this is appended behind an attendee name in the
+# email invitation preview - don't add leading/trailing whitespaces here
+# %1$S - a single delegatee or a comma separated list of delegatees
+imipHtml.attendeeDelegatedTo=(delegated to %1$S)
+
+# LOCALIZATION_NOTE(imipHtml.attendee.combined): tooltip for itip icon in email invitation preview.
+# Given an attendee loungeexample.org of type room is a mandatory participant and has accepted the
+# invitation, the tooltip would be:
+# lounge@example.org (room) is a required participant. lounge@example.org has confirmed attendance.
+# %1$S - value of imipHtml.attendeeRole2.*
+# %2$S - value of imipHtml.attendeePartStat2.*
+imipHtml.attendee.combined=%1$S %2$S
+
+# LOCALIZATION_NOTE(imipHtml.attendeeRole2.CHAIR): used to compose
+# imipHtml.attendee.combined
+# %1$S - value of imipHtml.attendeeUserType2.*
+imipHtml.attendeeRole2.CHAIR=%1$S chairs the event.
+# LOCALIZATION_NOTE(imipHtml.attendeeRole2.NON-PARTICIPANT): used to compose
+# imipHtml.attendee.combined
+# %1$S - value of imipHtml.attendeeUserType2.*
+imipHtml.attendeeRole2.NON-PARTICIPANT=%1$S is a non-participant.
+# LOCALIZATION_NOTE(imipHtml.attendeeRole2.OPT-PARTICIPANT): used to compose
+# imipHtml.attendee.combined
+# %1$S - value of imipHtml.attendeeUserType2.*
+imipHtml.attendeeRole2.OPT-PARTICIPANT=%1$S is an optional participant.
+# LOCALIZATION_NOTE(imipHtml.attendeeRole2.REQ-PARTICIPANT): used to compose
+# imipHtml.attendee.combined
+# %1$S - value of imipHtml.attendeeUserType2.*
+imipHtml.attendeeRole2.REQ-PARTICIPANT=%1$S is a required participant.
+
+# LOCALIZATION_NOTE(imipHtml.attendeePartStat2.ACCEPTED): used to compose
+# imipHtml.attendee.combined
+# %1$S - common name or email address of the attendee
+imipHtml.attendeePartStat2.ACCEPTED=%1$S has confirmed attendance.
+# LOCALIZATION_NOTE(imipHtml.attendeePartStat2.DECLINED): used to compose
+# imipHtml.attendee.combined
+# %1$S - common name or email address of the attendee
+imipHtml.attendeePartStat2.DECLINED=%1$S has declined attendance.
+# LOCALIZATION_NOTE(imipHtml.attendeePartStat2.DELEGATED): used to compose
+# imipHtml.attendee.combined
+# %1$S - common name or email address of the attendee
+# %2$S - single delegatee or comma separated list of delegatees
+# delegation is different from invitation forwarding - in case of the former the original attendee
+# is replaced, while on the latter the receiver may take part additionally
+imipHtml.attendeePartStat2.DELEGATED=%1$S has delegated attendance to %2$S.
+# LOCALIZATION_NOTE(imipHtml.attendeePartStat2.NEEDS-ACTION): used to compose
+# imipHtml.attendee.combined
+# %1$S - common name or email address of the attendee
+imipHtml.attendeePartStat2.NEEDS-ACTION=%1$S still needs to reply.
+# LOCALIZATION_NOTE(imipHtml.attendeePartStat2.TENTATIVE): used to compose
+# imipHtml.attendee.combined
+# %1$S - common name or email address of the attendee
+imipHtml.attendeePartStat2.TENTATIVE=%1$S has confirmed attendance tentatively.
+
+# LOCALIZATION_NOTE(imipHtml.attendeeUserType2.INDIVIDUAL): used to compose
+# imipHtml.attendeeRole2.*
+# %1$S - email address or common name <email address> representing an individual attendee
+imipHtml.attendeeUserType2.INDIVIDUAL=%1$S
+# LOCALIZATION_NOTE(imipHtml.attendeeUserType2.GROUP): used to compose
+# imipHtml.attendeeRole2.*
+# %1$S - email address or common name <email address> representing a group (e.g. a distribution list)
+imipHtml.attendeeUserType2.GROUP=%1$S (group)
+# LOCALIZATION_NOTE(imipHtml.attendeeUserType2.RESOURCE): used to compose
+# imipHtml.attendeeRole2.*
+# %1$S - email address or common name <email address> representing a resource (e.g. projector)
+imipHtml.attendeeUserType2.RESOURCE=%1$S (resource)
+# LOCALIZATION_NOTE(imipHtml.attendeeUserType2.ROOM): used to compose
+# imipHtml.attendeeRole2.*
+# %1$S - email address or common name <email address> representing a room
+imipHtml.attendeeUserType2.ROOM=%1$S (room)
+# LOCALIZATION_NOTE(imipHtml.attendeeUserType2.UNKNOWN): used to compose
+# imipHtml.attendeeRole2.*
+# %1$S - email address or common name <email address> representing an attendee of unknown type
+imipHtml.attendeeUserType2.UNKNOWN=%1$S
+
+imipAddedItemToCal2=The event has been added to your calendar.
+imipCanceledItem2=The event has been deleted from your calendar.
+imipUpdatedItem2=The event has been updated.
+imipBarCancelText=This message contains an event cancellation.
+imipBarCounterErrorText=This message contains a counterproposal to an invitation that cannot be processed.
+imipBarCounterPreviousVersionText=This message contains a counterproposal to a previous version of an invitation.
+imipBarCounterText=This message contains a counterproposal to an invitation.
+imipBarDisallowedCounterText=This message contains a counterproposal although you disallowed countering for this event.
+imipBarDeclineCounterText=This message contains a reply to your counterproposal.
+imipBarRefreshText=This message asks for an event update.
+imipBarPublishText=This message contains an event.
+imipBarRequestText=This message contains an invitation to an event.
+imipBarSentText=This message contains a sent event.
+imipBarSentButRemovedText=This message contains a sent out event that is not in your calendar anymore.
+imipBarUpdateText=This message contains an update to an existing event.
+imipBarUpdateMultipleText=This message contains updates to multiple existing events.
+imipBarUpdateSeriesText=This message contains an update to an existing series of events.
+imipBarAlreadyProcessedText=This message contains an event that has already been processed.
+imipBarProcessedNeedsAction=This message contains an event that you have not yet responded to.
+imipBarProcessedMultipleNeedsAction=This message contains multiple events that you have not yet responded to.
+imipBarProcessedSeriesNeedsAction=This message contains an event series that you have not yet responded to.
+imipBarReplyText=This message contains a reply to an invitation.
+imipBarReplyToNotExistingItem=This message contains a reply referring to an event that is not in your calendar.
+# LOCALIZATION_NOTE(imipBarReplyToRecentlyRemovedItem):
+# %1$S - time of deletion
+imipBarReplyToRecentlyRemovedItem=This message contains a reply referring to an event that was removed from your calendar at %1$S.
+imipBarUnsupportedText2=This message contains an event that this version of %1$S cannot process.
+imipBarProcessingFailed=Processing message failed. Status: %1$S.
+imipBarCalendarDeactivated=This message contains event information. Enable a calendar to handle it.
+imipBarNotWritable=No writable calendars are configured for invitations, please check the calendar properties.
+imipSendMail.title=Email Notification
+imipSendMail.text=Would you like to send out notification Email now?
+imipNoIdentity=None
+imipNoCalendarAvailable=There are no writable calendars available.
+
+itipReplySubject2=Invitation Reply: %1$S
+itipReplyBodyAccept=%1$S has accepted your event invitation.
+itipReplyBodyDecline=%1$S has declined your event invitation.
+itipReplySubjectAccept2=Accepted: %1$S
+itipReplySubjectDecline2=Invitation Declined: %1$S
+itipReplySubjectTentative2=Tentative: %1$S
+itipRequestSubject2=Invitation: %1$S
+itipRequestUpdatedSubject2=Updated: %1$S
+itipRequestBody=%1$S has invited you to %2$S
+itipCancelSubject2=Canceled: %1$S
+itipCancelBody=%1$S has canceled this event: %2$S
+itipCounterBody=%1$S has made a counterproposal for "%2$S":
+itipDeclineCounterBody=%1$S has declined your counterproposal for "%2$S".
+itipDeclineCounterSubject=Counterproposal Declined: %1$S
+
+confirmProcessInvitation=You have recently deleted this item, are you sure you want to process this invitation?
+confirmProcessInvitationTitle=Process Invitation?
+
+invitationsLink.label=Invitations: %1$S
+
+# LOCALIZATION NOTE(noIdentitySelectedNotification):
+noIdentitySelectedNotification=If you want to use this calendar to store invitations to or from other people you should assign an email identity below.
diff --git a/comm/calendar/locales/filter.py b/comm/calendar/locales/filter.py
new file mode 100644
index 0000000000..69741f7d75
--- /dev/null
+++ b/comm/calendar/locales/filter.py
@@ -0,0 +1,27 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+
+def test(mod, path, entity=None):
+ import re
+
+ # ignore anything but calendar stuff
+ if mod not in ("netwerk", "dom", "toolkit", "security/manager", "calendar"):
+ return False
+
+ # Timezone properties don't have to be translated
+ if path == "chrome/calendar/timezones.properties":
+ return "report"
+
+ # Noun class entries do not have to be translated
+ if path == "chrome/calendar/calendar-event-dialog.properties":
+ return not re.match(r".*Nounclass[1-9]", entity)
+
+ # most extraction related strings are not required
+ if path == "chrome/calendar/calendar-extract.properties":
+ if not re.match(r"from.today", entity):
+ return "report"
+
+ # Everything else should be taken into account
+ return True
diff --git a/comm/calendar/locales/jar.mn b/comm/calendar/locales/jar.mn
new file mode 100644
index 0000000000..d6cee4d1b8
--- /dev/null
+++ b/comm/calendar/locales/jar.mn
@@ -0,0 +1,38 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[localization] @AB_CD@.jar:
+ calendar (%calendar/**/*.ftl)
+
+@AB_CD@.jar:
+% locale calendar @AB_CD@ %locale/@AB_CD@/calendar/
+ locale/@AB_CD@/calendar/calendar-alarms.properties (%chrome/calendar/calendar-alarms.properties)
+ locale/@AB_CD@/calendar/calendar-event-dialog-attendees.properties (%chrome/calendar/calendar-event-dialog-attendees.properties)
+ locale/@AB_CD@/calendar/calendar-event-dialog.dtd (%chrome/calendar/calendar-event-dialog.dtd)
+ locale/@AB_CD@/calendar/calendar-event-dialog.properties (%chrome/calendar/calendar-event-dialog.properties)
+ locale/@AB_CD@/calendar/calendar-extract.properties (%chrome/calendar/calendar-extract.properties)
+ locale/@AB_CD@/calendar/calendar-invitations-dialog.dtd (%chrome/calendar/calendar-invitations-dialog.dtd)
+ locale/@AB_CD@/calendar/calendar-invitations-dialog.properties (%chrome/calendar/calendar-invitations-dialog.properties)
+ locale/@AB_CD@/calendar/calendar-occurrence-prompt.dtd (%chrome/calendar/calendar-occurrence-prompt.dtd)
+ locale/@AB_CD@/calendar/calendar-occurrence-prompt.properties (%chrome/calendar/calendar-occurrence-prompt.properties)
+ locale/@AB_CD@/calendar/calendar.dtd (%chrome/calendar/calendar.dtd)
+ locale/@AB_CD@/calendar/calendar.properties (%chrome/calendar/calendar.properties)
+ locale/@AB_CD@/calendar/calendarCreation.dtd (%chrome/calendar/calendarCreation.dtd)
+ locale/@AB_CD@/calendar/calendarCreation.properties (%chrome/calendar/calendarCreation.properties)
+ locale/@AB_CD@/calendar/categories.properties (%chrome/calendar/categories.properties)
+ locale/@AB_CD@/calendar/dateFormat.properties (%chrome/calendar/dateFormat.properties)
+ locale/@AB_CD@/calendar/dialogs/calendar-event-dialog-reminder.dtd (%chrome/calendar/dialogs/calendar-event-dialog-reminder.dtd)
+ locale/@AB_CD@/calendar/global.dtd (%chrome/calendar/global.dtd)
+ locale/@AB_CD@/calendar/menuOverlay.dtd (%chrome/calendar/menuOverlay.dtd)
+ locale/@AB_CD@/calendar/migration.dtd (%chrome/calendar/migration.dtd)
+ locale/@AB_CD@/calendar/migration.properties (%chrome/calendar/migration.properties)
+ locale/@AB_CD@/calendar/provider-uninstall.dtd (%chrome/calendar/provider-uninstall.dtd)
+ locale/@AB_CD@/calendar/timezones.properties (%chrome/calendar/timezones.properties)
+
+@AB_CD@.jar:
+% locale lightning @AB_CD@ %locale/@AB_CD@/lightning/
+ locale/@AB_CD@/lightning/lightning-toolbar.dtd (%chrome/lightning/lightning-toolbar.dtd)
+ locale/@AB_CD@/lightning/lightning.dtd (%chrome/lightning/lightning.dtd)
+ locale/@AB_CD@/lightning/lightning.properties (%chrome/lightning/lightning.properties)
diff --git a/comm/calendar/locales/l10n.ini b/comm/calendar/locales/l10n.ini
new file mode 100644
index 0000000000..f0238c63d8
--- /dev/null
+++ b/comm/calendar/locales/l10n.ini
@@ -0,0 +1,12 @@
+[general]
+depth = ../..
+all = calendar/locales/all-locales
+
+[compare]
+dirs = calendar
+# [includes]
+# # non-central apps might want to use %(topsrcdir)s here, or other vars
+# # RFE: that needs to be supported by compare-locales, too, though
+#
+# [extras]
+#
diff --git a/comm/calendar/locales/l10n.toml b/comm/calendar/locales/l10n.toml
new file mode 100644
index 0000000000..739e127863
--- /dev/null
+++ b/comm/calendar/locales/l10n.toml
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+basepath = "../.."
+
+[env]
+ l = "{l10n_base}/{locale}/"
+
+[[paths]]
+ reference = "calendar/locales/en-US/**"
+ l10n = "{l}calendar/**"
+
+# Timezone properties don't have to be translated
+[[filters]]
+ path = "{l}calendar/chrome/calendar/timezones.properties"
+ key = "re:."
+ action = "warning"
+
+# Noun class entries do not have to be translated
+[[filters]]
+ path = "{l}calendar/chrome/calendar/calendar-event-dialog.properties"
+ key = "re:.*Nounclass[1-9].*"
+ action = "ignore"
+
+# most extraction related strings are not required
+[[filters]]
+ path = "{l}calendar/chrome/calendar/calendar-extract.properties"
+ key = "re:.*from\\.today.*"
+ action = "warning"
diff --git a/comm/calendar/locales/moz.build b/comm/calendar/locales/moz.build
new file mode 100644
index 0000000000..de5cd1bf81
--- /dev/null
+++ b/comm/calendar/locales/moz.build
@@ -0,0 +1,6 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/comm/calendar/locales/shipped-locales b/comm/calendar/locales/shipped-locales
new file mode 100644
index 0000000000..827748b91e
--- /dev/null
+++ b/comm/calendar/locales/shipped-locales
@@ -0,0 +1,38 @@
+ca
+cs
+cy
+da
+de
+en-GB
+en-US
+es-AR
+es-ES
+et
+eu
+fi
+fr
+fy-NL
+ga-IE
+gd
+hu
+id
+is
+it
+ja
+ja-JP-mac
+lt
+nb-NO
+nl
+nn-NO
+pa-IN
+pl
+pt-BR
+pt-PT
+ru
+sk
+sq
+sv-SE
+tr
+uk
+zh-CN
+zh-TW
diff --git a/comm/calendar/moz.build b/comm/calendar/moz.build
new file mode 100644
index 0000000000..469223d42b
--- /dev/null
+++ b/comm/calendar/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "base",
+ "extract",
+ "import-export",
+ "itip",
+ "locales",
+ "providers",
+]
+
+TEST_DIRS += ["test"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "General")
+
+with Files("**/moz.build"):
+ BUG_COMPONENT = ("Calendar", "Build Config")
+ FINAL = True
+
+with Files("**/*.mk"):
+ BUG_COMPONENT = ("Calendar", "Build Config")
+ FINAL = True
+
+with Files("**/*manifest"):
+ BUG_COMPONENT = ("Calendar", "Build Config")
+ FINAL = True
+
+with Files("**/Makefile.in"):
+ BUG_COMPONENT = ("Calendar", "Build Config")
+ FINAL = True
diff --git a/comm/calendar/providers/caldav/CalDavCalendar.jsm b/comm/calendar/providers/caldav/CalDavCalendar.jsm
new file mode 100644
index 0000000000..a2bf7f0467
--- /dev/null
+++ b/comm/calendar/providers/caldav/CalDavCalendar.jsm
@@ -0,0 +1,2464 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDavCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var {
+ CalDavGenericRequest,
+ CalDavLegacySAXRequest,
+ CalDavItemRequest,
+ CalDavDeleteItemRequest,
+ CalDavPropfindRequest,
+ CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest,
+ CalDavOutboxRequest,
+ CalDavFreeBusyRequest,
+} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var { CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequestHandlers.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+var MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+var cIOL = Ci.calIOperationListener;
+
+function CalDavCalendar() {
+ this.initProviderBase();
+ this.unmappedProperties = [];
+ this.mUriParams = null;
+ this.mItemInfoCache = {};
+ this.mDisabledByDavError = false;
+ this.mCalHomeSet = null;
+ this.mInboxUrl = null;
+ this.mOutboxUrl = null;
+ this.mCalendarUserAddress = null;
+ this.mCheckedServerInfo = null;
+ this.mPrincipalUrl = null;
+ this.mSenderAddress = null;
+ this.mHrefIndex = {};
+ this.mAuthScheme = null;
+ this.mAuthRealm = null;
+ this.mObserver = null;
+ this.mFirstRefreshDone = false;
+ this.mOfflineStorage = null;
+ this.mQueuedQueries = [];
+ this.mCtag = null;
+ this.mProposedCtag = null;
+
+ // By default, support both events and todos.
+ this.mGenerallySupportedItemTypes = ["VEVENT", "VTODO"];
+ this.mSupportedItemTypes = this.mGenerallySupportedItemTypes.slice(0);
+ this.mACLProperties = {};
+}
+
+// used for etag checking
+var CALDAV_MODIFY_ITEM = "modify";
+var CALDAV_DELETE_ITEM = "delete";
+
+var calDavCalendarClassID = Components.ID("{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}");
+var calDavCalendarInterfaces = [
+ "calICalDavCalendar",
+ "calICalendar",
+ "calIChangeLog",
+ "calIFreeBusyProvider",
+ "calIItipTransport",
+ "calISchedulingSupport",
+ "nsIInterfaceRequestor",
+];
+CalDavCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calDavCalendarClassID,
+ QueryInterface: cal.generateQI(calDavCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calDavCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=caldav",
+ classDescription: "Calendar CalDAV back-end",
+ interfaces: calDavCalendarInterfaces,
+ }),
+
+ // An array of components that are supported by the server. The default is
+ // to support VEVENT and VTODO, if queries for these components return a 4xx
+ // error, then they will be removed from this array.
+ mGenerallySupportedItemTypes: null,
+ mSupportedItemTypes: null,
+ suportedItemTypes: null,
+ get supportedItemTypes() {
+ return this.mSupportedItemTypes;
+ },
+
+ get isCached() {
+ return this != this.superCalendar;
+ },
+
+ mLastRedirectStatus: null,
+
+ ensureTargetCalendar() {
+ if (!this.isCached && !this.mOfflineStorage) {
+ // If this is a cached calendar, the actual cache is taken care of
+ // by the calCachedCalendar facade. In any other case, we use a
+ // memory calendar to cache things.
+ this.mOfflineStorage = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+
+ this.mOfflineStorage.superCalendar = this;
+ this.mObserver = new calDavObserver(this);
+ this.mOfflineStorage.addObserver(this.mObserver);
+ this.mOfflineStorage.setProperty("relaxedMode", true);
+ }
+ },
+
+ get id() {
+ return this.mID;
+ },
+ set id(val) {
+ let setter = this.__proto__.__proto__.__lookupSetter__("id");
+ val = setter.call(this, val);
+
+ if (this.id) {
+ // Recreate the session ID that was used when discovering this calendar,
+ // as the password is stored with it. This only matters for OAuth
+ // calendars, in all other cases the password is stored by username.
+ this.session = new CalDavSession(
+ this.getProperty("username") || this.getProperty("sessionId") || this.id,
+ this.name
+ );
+ }
+ },
+
+ // calIChangeLog interface
+ get offlineStorage() {
+ return this.mOfflineStorage;
+ },
+
+ set offlineStorage(storage) {
+ this.mOfflineStorage = storage;
+ this.fetchCachedMetaData();
+ },
+
+ resetLog() {
+ if (this.isCached && this.mOfflineStorage) {
+ this.mOfflineStorage.startBatch();
+ try {
+ for (let itemId in this.mItemInfoCache) {
+ this.mOfflineStorage.deleteMetaData(itemId);
+ delete this.mItemInfoCache[itemId];
+ }
+ } finally {
+ this.mOfflineStorage.endBatch();
+ }
+ }
+ },
+
+ get offlineCachedProperties() {
+ return [
+ "mAuthScheme",
+ "mAuthRealm",
+ "mHasWebdavSyncSupport",
+ "mCtag",
+ "mWebdavSyncToken",
+ "mSupportedItemTypes",
+ "mPrincipalUrl",
+ "mCalHomeSet",
+ "mShouldPollInbox",
+ "mHasAutoScheduling",
+ "mHaveScheduling",
+ "mCalendarUserAddress",
+ "mOutboxUrl",
+ "hasFreeBusy",
+ ];
+ },
+
+ get checkedServerInfo() {
+ if (Services.io.offline) {
+ return true;
+ }
+ return this.mCheckedServerInfo;
+ },
+
+ set checkedServerInfo(val) {
+ this.mCheckedServerInfo = val;
+ },
+
+ saveCalendarProperties() {
+ let properties = {};
+ for (let property of this.offlineCachedProperties) {
+ if (this[property] !== undefined) {
+ properties[property] = this[property];
+ }
+ }
+ this.mOfflineStorage.setMetaData("calendar-properties", JSON.stringify(properties));
+ },
+ restoreCalendarProperties(data) {
+ let properties = JSON.parse(data);
+ for (let property of this.offlineCachedProperties) {
+ if (properties[property] !== undefined) {
+ this[property] = properties[property];
+ }
+ }
+ // migration code from bug 1299610
+ if ("hasAutoScheduling" in properties && properties.hasAutoScheduling !== undefined) {
+ this.mHasAutoScheduling = properties.hasAutoScheduling;
+ }
+ },
+
+ // in calIGenericOperationListener aListener
+ replayChangesOn(aChangeLogListener) {
+ if (this.checkedServerInfo) {
+ this.safeRefresh(aChangeLogListener);
+ } else {
+ // If we haven't refreshed yet, then we should check the resource
+ // type first. This will call refresh() again afterwards.
+ this.checkDavResourceType(aChangeLogListener);
+ }
+ },
+ setMetaData(id, path, etag, isInboxItem) {
+ if (this.mOfflineStorage.setMetaData) {
+ if (id) {
+ let dataString = [etag, path, isInboxItem ? "true" : "false"].join("\u001A");
+ this.mOfflineStorage.setMetaData(id, dataString);
+ } else {
+ cal.LOG("CalDAV: cannot store meta data without an id");
+ }
+ } else {
+ cal.ERROR("CalDAV: calendar storage does not support meta data");
+ }
+ },
+
+ /**
+ * Ensure that cached items have associated meta data, otherwise server side
+ * changes may not be reflected
+ */
+ async ensureMetaData() {
+ let refreshNeeded = false;
+
+ for await (let items of cal.iterate.streamValues(
+ this.mOfflineStorage.wrappedJSObject.getItems(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ null,
+ null
+ )
+ )) {
+ for (let item of items) {
+ if (!(item.id in this.mItemInfoCache)) {
+ let path = this.getItemLocationPath(item);
+ cal.LOG("Adding meta-data for cached item " + item.id);
+ this.mItemInfoCache[item.id] = {
+ etag: null,
+ isNew: false,
+ locationPath: path,
+ isInboxItem: false,
+ };
+ this.mHrefIndex[this.mLocationPath + path] = item.id;
+ refreshNeeded = true;
+ }
+ }
+ }
+
+ if (refreshNeeded) {
+ // resetting the cached ctag forces an item refresh when
+ // safeRefresh is called later
+ this.mCtag = null;
+ this.mProposedCtag = null;
+ }
+ },
+
+ fetchCachedMetaData() {
+ cal.LOG("CalDAV: Retrieving server info from cache for " + this.name);
+ let cacheIds = this.mOfflineStorage.getAllMetaDataIds();
+ let cacheValues = this.mOfflineStorage.getAllMetaDataValues();
+
+ for (let count = 0; count < cacheIds.length; count++) {
+ let itemId = cacheIds[count];
+ let itemData = cacheValues[count];
+ if (itemId == "ctag") {
+ this.mCtag = itemData;
+ this.mProposedCtag = null;
+ this.mOfflineStorage.deleteMetaData("ctag");
+ } else if (itemId == "webdav-sync-token") {
+ this.mWebdavSyncToken = itemData;
+ this.mOfflineStorage.deleteMetaData("sync-token");
+ } else if (itemId == "calendar-properties") {
+ this.restoreCalendarProperties(itemData);
+ this.setProperty("currentStatus", Cr.NS_OK);
+ if (this.mHaveScheduling || this.hasAutoScheduling || this.hasFreeBusy) {
+ cal.freeBusyService.addProvider(this);
+ }
+ } else {
+ let itemDataArray = itemData.split("\u001A");
+ let etag = itemDataArray[0];
+ let resourcePath = itemDataArray[1];
+ let isInboxItem = itemDataArray[2];
+ if (itemDataArray.length == 3) {
+ this.mHrefIndex[resourcePath] = itemId;
+ let locationPath = resourcePath.substr(this.mLocationPath.length);
+ let item = {
+ etag,
+ isNew: false,
+ locationPath,
+ isInboxItem: isInboxItem == "true",
+ };
+ this.mItemInfoCache[itemId] = item;
+ }
+ }
+ }
+
+ this.ensureMetaData();
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "caldav";
+ },
+
+ mDisabledByDavError: true,
+
+ mCalendarUserAddress: null,
+ get calendarUserAddress() {
+ return this.mCalendarUserAddress;
+ },
+
+ mPrincipalUrl: null,
+ get principalUrl() {
+ return this.mPrincipalUrl;
+ },
+
+ get canRefresh() {
+ // A cached calendar doesn't need to be refreshed.
+ return !this.isCached;
+ },
+
+ // mUriParams stores trailing ?parameters from the
+ // supplied calendar URI. Needed for (at least) Cosmo
+ // tickets
+ mUriParams: null,
+
+ get uri() {
+ return this.mUri;
+ },
+
+ set uri(aUri) {
+ this.mUri = aUri;
+ },
+
+ get calendarUri() {
+ let calSpec = this.mUri.spec;
+ let parts = calSpec.split("?");
+ if (parts.length > 1) {
+ calSpec = parts.shift();
+ this.mUriParams = "?" + parts.join("?");
+ }
+ if (!calSpec.endsWith("/")) {
+ calSpec += "/";
+ }
+ return Services.io.newURI(calSpec);
+ },
+
+ setCalHomeSet(removeLastPathSegment) {
+ if (removeLastPathSegment) {
+ let split1 = this.mUri.spec.split("?");
+ let baseUrl = split1[0];
+ if (baseUrl.charAt(baseUrl.length - 1) == "/") {
+ baseUrl = baseUrl.substring(0, baseUrl.length - 2);
+ }
+ let split2 = baseUrl.split("/");
+ split2.pop();
+ this.mCalHomeSet = Services.io.newURI(split2.join("/") + "/");
+ } else {
+ this.mCalHomeSet = this.calendarUri;
+ }
+ },
+
+ mOutboxUrl: null,
+ get outboxUrl() {
+ return this.mOutboxUrl;
+ },
+
+ mInboxUrl: null,
+ get inboxUrl() {
+ return this.mInboxUrl;
+ },
+
+ mHaveScheduling: false,
+ mShouldPollInbox: true,
+ get hasScheduling() {
+ // Whether to use inbox/outbox scheduling
+ return this.mHaveScheduling;
+ },
+ set hasScheduling(value) {
+ this.mHaveScheduling =
+ Services.prefs.getBoolPref("calendar.caldav.sched.enabled", false) && value;
+ },
+ mHasAutoScheduling: false, // Whether server automatically takes care of scheduling
+ get hasAutoScheduling() {
+ return this.mHasAutoScheduling;
+ },
+
+ hasFreebusy: false,
+
+ mAuthScheme: null,
+
+ mAuthRealm: null,
+
+ mFirstRefreshDone: false,
+
+ mQueuedQueries: null,
+
+ mCtag: null,
+ mProposedCtag: null,
+
+ mOfflineStorage: null,
+ // Contains the last valid synctoken returned
+ // from the server with Webdav Sync enabled servers
+ mWebdavSyncToken: null,
+ // Indicates that the server supports Webdav Sync
+ // see: http://tools.ietf.org/html/draft-daboo-webdav-sync
+ mHasWebdavSyncSupport: false,
+
+ get authRealm() {
+ return this.mAuthRealm;
+ },
+
+ /**
+ * Builds a correctly encoded nsIURI based on the baseUri and the insert
+ * string. The returned uri is basically the baseURI + aInsertString
+ *
+ * @param {string} aInsertString - String to append to the base uri, for example,
+ * when creating an event this would be the
+ * event file name (event.ics). If null, an empty
+ * string is used.
+ * @param {nsIURI} aBaseUri - Base uri, if null, this.calendarUri will be used.
+ */
+ makeUri(aInsertString, aBaseUri) {
+ let baseUri = aBaseUri || this.calendarUri;
+ // Build a string containing the full path, decoded, so it looks like
+ // this:
+ // /some path/insert string.ics
+ let decodedPath = this.ensureDecodedPath(baseUri.pathQueryRef + (aInsertString || ""));
+
+ // Build the nsIURI by specifying a string with a fully encoded path
+ // the end result will be something like this:
+ // http://caldav.example.com:8080/some%20path/insert%20string.ics
+ return Services.io.newURI(
+ baseUri.prePath + this.ensureEncodedPath(decodedPath) + (this.mUriParams || "")
+ );
+ },
+
+ get mLocationPath() {
+ return this.ensureDecodedPath(this.calendarUri.pathQueryRef);
+ },
+
+ getItemLocationPath(aItem) {
+ if (aItem.id && aItem.id in this.mItemInfoCache && this.mItemInfoCache[aItem.id].locationPath) {
+ // modifying items use the cached location path
+ return this.mItemInfoCache[aItem.id].locationPath;
+ }
+ // New items just use id.ics
+ return aItem.id + ".ics";
+ },
+
+ getProperty(aName) {
+ if (aName in this.mACLProperties && this.mACLProperties[aName]) {
+ return this.mACLProperties[aName];
+ }
+
+ switch (aName) {
+ case "organizerId":
+ if (this.calendarUserAddress) {
+ return this.calendarUserAddress;
+ } // else use configured email identity
+ break;
+ case "organizerCN":
+ return null; // xxx todo
+ case "itip.transport":
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ return this.QueryInterface(Ci.calIItipTransport);
+ } // else use outbound email-based iTIP (from cal.provider.BaseClass)
+ break;
+ case "capabilities.tasks.supported":
+ return this.supportedItemTypes.includes("VTODO");
+ case "capabilities.events.supported":
+ return this.supportedItemTypes.includes("VEVENT");
+ case "capabilities.autoschedule.supported":
+ return this.hasAutoScheduling;
+ case "capabilities.username.supported":
+ return true;
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ promptOverwrite(aMethod, aItem, aListener, aOldItem) {
+ let overwrite = cal.provider.promptOverwrite(aMethod, aItem, aListener, aOldItem);
+ if (overwrite) {
+ if (aMethod == CALDAV_MODIFY_ITEM) {
+ this.doModifyItem(aItem, aOldItem, aListener, true);
+ } else {
+ this.doDeleteItem(aItem, aListener, true, false, null);
+ }
+ } else {
+ this.getUpdatedItem(aItem, aListener);
+ }
+ },
+
+ mItemInfoCache: null,
+
+ mHrefIndex: null,
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ /**
+ * addItem()
+ * we actually use doAdoptItem()
+ *
+ * @param aItem item to add
+ */
+ async addItem(aItem) {
+ return this.adoptItem(aItem);
+ },
+
+ // Used to allow the cachedCalendar provider to hook into adoptItem() before
+ // it returns.
+ _cachedAdoptItemCallback: null,
+
+ /**
+ * adoptItem()
+ * we actually use doAdoptItem()
+ *
+ * @param aItem item to check
+ */
+ async adoptItem(aItem) {
+ let adoptCallback = this._cachedAdoptItemCallback;
+ return new Promise((resolve, reject) => {
+ this.doAdoptItem(aItem.clone(), {
+ get wrappedJSObject() {
+ return this;
+ },
+ async onOperationComplete(calendar, status, opType, id, detail) {
+ if (adoptCallback) {
+ await adoptCallback(calendar, status, opType, id, detail);
+ }
+ return Components.isSuccessCode(status) ? resolve(detail) : reject(detail);
+ },
+ });
+ });
+ },
+
+ /**
+ * Performs the actual addition of the item to CalDAV store
+ *
+ * @param aItem item to add
+ * @param aListener listener for method completion
+ * @param aIgnoreEtag flag to indicate ignoring of Etag
+ */
+ doAdoptItem(aItem, aListener, aIgnoreEtag) {
+ let notifyListener = (status, detail, pure = false) => {
+ let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete";
+ this[method](aListener, status, cIOL.ADD, aItem.id, detail);
+ };
+ if (aItem.id == null && aItem.isMutable) {
+ aItem.id = cal.getUUID();
+ }
+
+ if (aItem.id == null) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "Can't set ID on non-mutable item to addItem");
+ return;
+ }
+
+ if (!cal.item.isItemSupported(aItem, this)) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "Server does not support item type");
+ return;
+ }
+
+ let parentItem = aItem.parentItem;
+ parentItem.calendar = this.superCalendar;
+
+ let locationPath = this.getItemLocationPath(parentItem);
+ let itemUri = this.makeUri(locationPath);
+ cal.LOG("CalDAV: itemUri.spec = " + itemUri.spec);
+
+ let serializedItem = this.getSerializedItem(aItem);
+
+ let sendEtag = aIgnoreEtag ? null : "*";
+ let request = new CalDavItemRequest(this.session, this, itemUri, aItem, sendEtag);
+
+ request.commit().then(
+ response => {
+ let status = Cr.NS_OK;
+ let detail = parentItem;
+
+ // Translate the HTTP status code to a status and message for the listener
+ if (response.ok) {
+ cal.LOG(`CalDAV: Item added to ${this.name} successfully`);
+
+ let uriComponentParts = this.makeUri()
+ .pathQueryRef.replace(/\/{2,}/g, "/")
+ .split("/").length;
+ let targetParts = response.uri.pathQueryRef.split("/");
+ targetParts.splice(0, uriComponentParts - 1);
+
+ this.mItemInfoCache[parentItem.id] = { locationPath: targetParts.join("/") };
+ // TODO: onOpComplete adds the item to the cache, probably after getUpdatedItem!
+
+ // Some CalDAV servers will modify items on PUT (add X-props,
+ // for instance) so we'd best re-fetch in order to know
+ // the current state of the item
+ // Observers will be notified in getUpdatedItem()
+ this.getUpdatedItem(parentItem, aListener);
+ return;
+ } else if (response.serverError) {
+ status = Cr.NS_ERROR_NOT_AVAILABLE;
+ detail = "Server Replied with " + response.status;
+ } else if (response.status) {
+ // There is a response status, but we haven't handled it yet. Any
+ // error occurring here should consider being handled!
+ cal.ERROR(
+ "CalDAV: Unexpected status adding item to " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ serializedItem
+ );
+
+ status = Cr.NS_ERROR_FAILURE;
+ detail = "Server Replied with " + response.status;
+ }
+
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached && !Components.isSuccessCode(status)) {
+ this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
+ }
+
+ // Finally, notify listener.
+ notifyListener(status, detail, true);
+ },
+ e => {
+ notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel: " + e);
+ }
+ );
+ },
+
+ // Used to allow the cachedCalendar provider to hook into modifyItem() before
+ // it returns.
+ _cachedModifyItemCallback: null,
+
+ /**
+ * modifyItem(); required by calICalendar.idl
+ * we actually use doModifyItem()
+ *
+ * @param aItem item to check
+ */
+ async modifyItem(aNewItem, aOldItem) {
+ let modifyCallback = this._cachedModifyItemCallback;
+ return new Promise((resolve, reject) => {
+ this.doModifyItem(
+ aNewItem,
+ aOldItem,
+ {
+ get wrappedJSObject() {
+ return this;
+ },
+ async onOperationComplete(calendar, status, opType, id, detail) {
+ if (modifyCallback) {
+ await modifyCallback(calendar, status, opType, id, detail);
+ }
+ return Components.isSuccessCode(status) ? resolve(detail) : reject(detail);
+ },
+ },
+ false
+ );
+ });
+ },
+
+ /**
+ * Modifies existing item in CalDAV store.
+ *
+ * @param aItem item to check
+ * @param aOldItem previous version of item to be modified
+ * @param aListener listener from original request
+ * @param aIgnoreEtag ignore item etag
+ */
+ doModifyItem(aNewItem, aOldItem, aListener, aIgnoreEtag) {
+ let notifyListener = (status, detail, pure = false) => {
+ let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete";
+ this[method](aListener, status, cIOL.MODIFY, aNewItem.id, detail);
+ };
+ if (aNewItem.id == null) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "ID for modifyItem doesn't exist or is null");
+ return;
+ }
+
+ let wasInboxItem = this.mItemInfoCache[aNewItem.id].isInboxItem;
+
+ let newItem_ = aNewItem;
+ aNewItem = aNewItem.parentItem.clone();
+ if (newItem_.parentItem != newItem_) {
+ aNewItem.recurrenceInfo.modifyException(newItem_, false);
+ }
+ aNewItem.generation += 1;
+
+ let eventUri = this.makeUri(this.mItemInfoCache[aNewItem.id].locationPath);
+ let modifiedItemICS = this.getSerializedItem(aNewItem);
+
+ let sendEtag = aIgnoreEtag ? null : this.mItemInfoCache[aNewItem.id].etag;
+ let request = new CalDavItemRequest(this.session, this, eventUri, aNewItem, sendEtag);
+
+ request.commit().then(
+ response => {
+ let status = Cr.NS_OK;
+ let detail = aNewItem;
+
+ let shouldNotify = true;
+ if (response.ok) {
+ cal.LOG("CalDAV: Item modified successfully on " + this.name);
+
+ // Some CalDAV servers will modify items on PUT (add X-props, for instance) so we'd
+ // best re-fetch in order to know the current state of the item Observers will be
+ // notified in getUpdatedItem()
+ this.getUpdatedItem(aNewItem, aListener);
+
+ // SOGo has calendarUri == inboxUri so we need to be careful about deletions
+ if (wasInboxItem && this.mShouldPollInbox) {
+ this.doDeleteItem(aNewItem, null, true, true, null);
+ }
+ shouldNotify = false;
+ } else if (response.conflict) {
+ // promptOverwrite will ask the user and then re-request
+ this.promptOverwrite(CALDAV_MODIFY_ITEM, aNewItem, aListener, aOldItem);
+ shouldNotify = false;
+ } else if (response.serverError) {
+ status = Cr.NS_ERROR_NOT_AVAILABLE;
+ detail = "Server Replied with " + response.status;
+ } else if (response.status) {
+ // There is a response status, but we haven't handled it yet. Any error occurring
+ // here should consider being handled!
+ cal.ERROR(
+ "CalDAV: Unexpected status modifying item to " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ modifiedItemICS
+ );
+
+ status = Cr.NS_ERROR_FAILURE;
+ detail = "Server Replied with " + response.status;
+ }
+
+ if (shouldNotify) {
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached && !Components.isSuccessCode(status)) {
+ this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
+ }
+
+ notifyListener(status, detail, true);
+ }
+ },
+ () => {
+ notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+ }
+ );
+ },
+
+ /**
+ * deleteItem(); required by calICalendar.idl
+ * the actual deletion is done in doDeleteItem()
+ *
+ * @param {calIItemBase} item The item to delete
+ *
+ * @returns {Promise<void>}
+ */
+ async deleteItem(item) {
+ return this.doDeleteItem(item, false, null, null);
+ },
+
+ /**
+ * Deletes item from CalDAV store.
+ *
+ * @param {calIItemBase} item Item to delete.
+ * @param {boolean} ignoreEtag Ignore item etag.
+ * @param {boolean} fromInbox Delete from inbox rather than calendar.
+ * @param {string} uri Uri of item to delete.
+ *
+ * @returns {Promise<void>}
+ */
+ async doDeleteItem(item, ignoreEtag, fromInbox, uri) {
+ let onError = async (status, detail) => {
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached) {
+ this.reportDavError(Ci.calIErrors.DAV_REMOVE_ERROR, status, detail);
+ }
+ this.notifyOperationComplete(null, status, cIOL.DELETE, null, detail);
+ return Promise.reject(new Components.Exception(detail, status));
+ };
+
+ if (item.id == null) {
+ return onError(Cr.NS_ERROR_FAILURE, "ID doesn't exist for deleteItem");
+ }
+
+ let eventUri;
+ if (uri) {
+ eventUri = uri;
+ } else if (fromInbox || this.mItemInfoCache[item.id].isInboxItem) {
+ eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath, this.mInboxUrl);
+ } else {
+ eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath);
+ }
+
+ if (eventUri.pathQueryRef == this.calendarUri.pathQueryRef) {
+ return onError(
+ Cr.NS_ERROR_FAILURE,
+ "eventUri and calendarUri paths are the same, will not go on to delete entire calendar"
+ );
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: Deleting " + eventUri.spec);
+ }
+
+ let sendEtag = ignoreEtag ? null : this.mItemInfoCache[item.id].etag;
+ let request = new CalDavDeleteItemRequest(this.session, this, eventUri, sendEtag);
+
+ let response;
+ try {
+ response = await request.commit();
+ } catch (e) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+ }
+
+ if (response.ok) {
+ if (!fromInbox) {
+ let decodedPath = this.ensureDecodedPath(eventUri.pathQueryRef);
+ delete this.mHrefIndex[decodedPath];
+ delete this.mItemInfoCache[item.id];
+ cal.LOG("CalDAV: Item deleted successfully from calendar " + this.name);
+
+ if (this.isCached) {
+ this.notifyOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null);
+ return null;
+ }
+ // If the calendar is not cached, we need to remove
+ // the item from our memory calendar now. The
+ // listeners will be notified there.
+ return this.mOfflineStorage.deleteItem(item);
+ }
+ return null;
+ } else if (response.conflict) {
+ // item has either been modified or deleted by someone else check to see which
+ cal.LOG("CalDAV: Item has been modified on server, checking if it has been deleted");
+ let headRequest = new CalDavGenericRequest(this.session, this, "HEAD", eventUri);
+ let headResponse = await headRequest.commit();
+
+ if (headResponse.notFound) {
+ // Nothing to do. Someone else has already deleted it
+ this.notifyPureOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null);
+ return null;
+ } else if (headResponse.serverError) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + headResponse.status);
+ } else if (headResponse.status) {
+ // The item still exists. We need to ask the user if he
+ // really wants to delete the item. Remember, we only
+ // made this request since the actual delete gave 409/412
+ let item = await this.getItem(item.id);
+ return cal.provider.promptOverwrite(CALDAV_DELETE_ITEM, item)
+ ? this.doDeleteItem(item, true, false, null)
+ : null;
+ }
+ } else if (response.serverError) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + response.status);
+ } else if (response.status) {
+ cal.ERROR(
+ "CalDAV: Unexpected status deleting item from " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ "uri: " +
+ eventUri.spec
+ );
+ }
+ return onError(Cr.NS_ERROR_FAILURE, "Server Replied with status " + response.status);
+ },
+
+ /**
+ * Add an item to the target calendar
+ *
+ * @param path Item path MUST NOT BE ENCODED
+ * @param calData iCalendar string representation of the item
+ * @param aUri Base URI of the request
+ * @param aListener Listener
+ */
+ async addTargetCalendarItem(path, calData, aUri, etag, aListener) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ // aUri.pathQueryRef may contain double slashes whereas path does not
+ // this confuses our counting, so remove multiple successive slashes
+ let strippedUriPath = aUri.pathQueryRef.replace(/\/{2,}/g, "/");
+ let uriPathComponentLength = strippedUriPath.split("/").length;
+ try {
+ parser.parseString(calData);
+ } catch (e) {
+ // Warn and continue.
+ // TODO As soon as we have activity manager integration,
+ // this should be replace with logic to notify that a
+ // certain event failed.
+ cal.WARN("Failed to parse item: " + calData + "\n\nException:" + e);
+ return;
+ }
+ // with CalDAV there really should only be one item here
+ let items = parser.getItems();
+ let propertiesList = parser.getProperties();
+ let method;
+ for (let prop of propertiesList) {
+ if (prop.propertyName == "METHOD") {
+ method = prop.value;
+ break;
+ }
+ }
+ let isReply = method == "REPLY";
+ let item = items[0];
+
+ if (!item) {
+ cal.WARN("Failed to parse item: " + calData);
+ return;
+ }
+
+ item.calendar = this.superCalendar;
+ if (isReply && this.isInbox(aUri.spec)) {
+ if (this.hasScheduling) {
+ this.processItipReply(item, path);
+ }
+ cal.WARN("REPLY method but calendar does not support scheduling");
+ return;
+ }
+
+ // Strip of the same number of components as the request
+ // uri's path has. This way we make sure to handle servers
+ // that pass paths like /dav/user/Calendar while
+ // the request uri is like /dav/user@example.org/Calendar.
+ let resPathComponents = path.split("/");
+ resPathComponents.splice(0, uriPathComponentLength - 1);
+ let locationPath = resPathComponents.join("/");
+ let isInboxItem = this.isInbox(aUri.spec);
+
+ if (this.mHrefIndex[path] && !this.mItemInfoCache[item.id]) {
+ // If we get here it means a meeting has kept the same filename
+ // but changed its uid, which can happen server side.
+ // Delete the meeting before re-adding it
+ this.deleteTargetCalendarItem(path);
+ }
+
+ if (this.mItemInfoCache[item.id]) {
+ this.mItemInfoCache[item.id].isNew = false;
+ } else {
+ this.mItemInfoCache[item.id] = { isNew: true };
+ }
+ this.mItemInfoCache[item.id].locationPath = locationPath;
+ this.mItemInfoCache[item.id].isInboxItem = isInboxItem;
+
+ this.mHrefIndex[path] = item.id;
+ this.mItemInfoCache[item.id].etag = etag;
+
+ if (this.isCached) {
+ this.setMetaData(item.id, path, etag, isInboxItem);
+
+ // If we have a listener, then the caller will take care of adding the item
+ // Otherwise, we have to do it ourself
+ // XXX This is quite fragile, but saves us a double modify/add
+
+ if (aListener) {
+ await new Promise(resolve => {
+ let wrappedListener = {
+ onGetResult(...args) {
+ aListener.onGetResult(...args);
+ },
+ onOperationComplete(...args) {
+ // We must use wrappedJSObject to receive a returned Promise.
+ let promise = aListener.wrappedJSObject.onOperationComplete(...args);
+ if (promise) {
+ promise.then(resolve);
+ } else {
+ resolve();
+ }
+ },
+ };
+
+ // In the cached case, notifying operation complete will add the item to the cache
+ if (this.mItemInfoCache[item.id].isNew) {
+ this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.ADD, item.id, item);
+ } else {
+ this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.MODIFY, item.id, item);
+ }
+ });
+ return;
+ }
+ }
+
+ // Either there's no listener, or we're uncached.
+
+ if (this.mItemInfoCache[item.id].isNew) {
+ await this.mOfflineStorage.adoptItem(item).then(
+ () => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.ADD, item.id, item),
+ e => aListener?.onOperationComplete(null, e.result, null, null, e)
+ );
+ } else {
+ await this.mOfflineStorage.modifyItem(item, null).then(
+ item => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.MODIFY, item.id, item),
+ e => aListener?.onOperationComplete(null, e.result, null, null, e)
+ );
+ }
+ },
+
+ /**
+ * Deletes an item from the target calendar
+ *
+ * @param path Path of the item to delete, must not be encoded
+ */
+ async deleteTargetCalendarItem(path) {
+ let foundItem = await this.mOfflineStorage.getItem(this.mHrefIndex[path]);
+ let wasInboxItem = this.mItemInfoCache[foundItem.id].isInboxItem;
+ if ((wasInboxItem && this.isInbox(path)) || (wasInboxItem === false && !this.isInbox(path))) {
+ cal.LOG("CalDAV: deleting item: " + path + ", uid: " + foundItem.id);
+ delete this.mHrefIndex[path];
+ delete this.mItemInfoCache[foundItem.id];
+ if (this.isCached) {
+ this.mOfflineStorage.deleteMetaData(foundItem.id);
+ }
+ await this.mOfflineStorage.deleteItem(foundItem);
+ }
+ },
+
+ /**
+ * Perform tasks required after updating items in the calendar such as
+ * notifying the observers and listeners
+ *
+ * @param aChangeLogListener Change log listener
+ * @param calendarURI URI of the calendar whose items just got
+ * changed
+ */
+ finalizeUpdatedItems(aChangeLogListener, calendarURI) {
+ cal.LOG(
+ "aChangeLogListener=" +
+ aChangeLogListener +
+ "\n" +
+ "calendarURI=" +
+ (calendarURI ? calendarURI.spec : "undefined") +
+ " \n" +
+ "iscached=" +
+ this.isCached +
+ "\n" +
+ "this.mQueuedQueries.length=" +
+ this.mQueuedQueries.length
+ );
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK);
+ } else {
+ this.mObservers.notify("onLoad", [this]);
+ }
+
+ if (this.mProposedCtag) {
+ this.mCtag = this.mProposedCtag;
+ this.mProposedCtag = null;
+ }
+
+ this.mFirstRefreshDone = true;
+ while (this.mQueuedQueries.length) {
+ let query = this.mQueuedQueries.pop();
+ let { filter, count, rangeStart, rangeEnd } = query;
+ query.onStream(this.mOfflineStorage.getItems(filter, count, rangeStart, rangeEnd));
+ }
+ if (this.hasScheduling && !this.isInbox(calendarURI.spec)) {
+ this.pollInbox();
+ }
+ },
+
+ /**
+ * Notifies the caller that a get request has failed.
+ *
+ * @param errorMsg Error message
+ * @param aListener (optional) Listener of the request
+ * @param aChangeLogListener (optional)Listener for cached calendars
+ */
+ notifyGetFailed(errorMsg, aListener, aChangeLogListener) {
+ cal.WARN("CalDAV: Get failed: " + errorMsg);
+
+ // Notify changelog listener
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+
+ // Notify operation listener
+ this.notifyOperationComplete(aListener, Cr.NS_ERROR_FAILURE, cIOL.GET, null, errorMsg);
+ // If an error occurs here, we also need to unqueue the
+ // requests previously queued.
+ while (this.mQueuedQueries.length) {
+ this.mQueuedQueries.pop().onError(new Components.Exception(errorMsg, Cr.NS_ERROR_FAILURE));
+ }
+ },
+
+ /**
+ * Retrieves a specific item from the CalDAV store.
+ * Use when an outdated copy of the item is in hand.
+ *
+ * @param aItem item to fetch
+ * @param aListener listener for method completion
+ */
+ getUpdatedItem(aItem, aListener, aChangeLogListener) {
+ if (aItem == null) {
+ this.notifyOperationComplete(
+ aListener,
+ Cr.NS_ERROR_FAILURE,
+ cIOL.GET,
+ null,
+ "passed in null item"
+ );
+ return;
+ }
+
+ let locationPath = this.getItemLocationPath(aItem);
+ let itemUri = this.makeUri(locationPath);
+
+ let multiget = new CalDavMultigetSyncHandler(
+ [this.ensureDecodedPath(itemUri.pathQueryRef)],
+ this,
+ this.makeUri(),
+ null,
+ false,
+ aListener,
+ aChangeLogListener
+ );
+ multiget.doMultiGet();
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mOfflineStorage.getItem(aId);
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long filter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(filter, count, rangeStart, rangeEnd) {
+ if (this.isCached) {
+ if (this.mOfflineStorage) {
+ return this.mOfflineStorage.getItems(...arguments);
+ }
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ } else if (
+ this.checkedServerInfo ||
+ this.getProperty("currentStatus") == Ci.calIErrors.READ_FAILED
+ ) {
+ return this.mOfflineStorage.getItems(...arguments);
+ }
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ return new Promise((resolve, reject) => {
+ self.mQueuedQueries.push({
+ filter,
+ count,
+ rangeStart,
+ rangeEnd,
+ failed: false,
+ onError(e) {
+ this.failed = true;
+ reject(e);
+ },
+ async onStream(stream) {
+ for await (let items of cal.iterate.streamValues(stream)) {
+ if (this.failed) {
+ break;
+ }
+ controller.enqueue(items);
+ }
+ if (!this.failed) {
+ controller.close();
+ resolve();
+ }
+ },
+ });
+ });
+ },
+ }
+ );
+ },
+
+ fillACLProperties() {
+ let orgId = this.calendarUserAddress;
+ if (orgId) {
+ this.mACLProperties.organizerId = orgId;
+ }
+
+ if (this.mACLEntry && this.mACLEntry.hasAccessControl) {
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ if (ownerIdentities.length > 0) {
+ let identity = ownerIdentities[0];
+ this.mACLProperties.organizerId = identity.email;
+ this.mACLProperties.organizerCN = identity.fullName;
+ this.mACLProperties["imip.identity"] = identity;
+ }
+ }
+ },
+
+ safeRefresh(aChangeLogListener) {
+ let notifyListener = status => {
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status }, status);
+ }
+ };
+
+ if (!this.mACLEntry) {
+ let self = this;
+ let opListener = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ onGetResult(calendar, status, itemType, detail, items) {
+ cal.ASSERT(false, "unexpected!");
+ },
+ onOperationComplete(opCalendar, opStatus, opType, opId, opDetail) {
+ self.mACLEntry = opDetail;
+ self.fillACLProperties();
+ self.safeRefresh(aChangeLogListener);
+ },
+ };
+
+ this.aclManager.getCalendarEntry(this, opListener);
+ return;
+ }
+
+ this.ensureTargetCalendar();
+
+ if (this.mAuthScheme == "Digest") {
+ // the auth could have timed out and be in need of renegotiation we can't risk several
+ // calendars doing this simultaneously so we'll force the renegotiation in a sync query,
+ // using OPTIONS to keep it quick
+ let headchannel = cal.provider.prepHttpChannel(this.makeUri(), null, null, this);
+ headchannel.requestMethod = "OPTIONS";
+ headchannel.open();
+ headchannel.QueryInterface(Ci.nsIHttpChannel);
+ try {
+ if (headchannel.responseStatus != 200) {
+ throw new Error("OPTIONS returned unexpected status code: " + headchannel.responseStatus);
+ }
+ } catch (e) {
+ cal.WARN("CalDAV: Exception: " + e);
+ notifyListener(Cr.NS_ERROR_FAILURE);
+ }
+ }
+
+ // Call getUpdatedItems right away if its the first refresh *OR* if webdav Sync is enabled
+ // (It is redundant to send a request to get the collection tag (getctag) on a calendar if
+ // it supports webdav sync, the sync request will only return data if something changed).
+ if (!this.mCtag || !this.mFirstRefreshDone || this.mHasWebdavSyncSupport) {
+ this.getUpdatedItems(this.calendarUri, aChangeLogListener);
+ return;
+ }
+ let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), ["CS:getctag"]);
+
+ request.commit().then(response => {
+ cal.LOG(`CalDAV: Status ${response.status} checking ctag for calendar ${this.name}`);
+
+ if (response.status == -1) {
+ notifyListener(Cr.NS_OK);
+ return;
+ } else if (response.notFound) {
+ cal.LOG(`CalDAV: Disabling calendar ${this.name} due to 404`);
+ notifyListener(Cr.NS_ERROR_FAILURE);
+ return;
+ } else if (response.ok && this.mDisabledByDavError) {
+ // Looks like the calendar is there again, check its resource
+ // type first.
+ this.checkDavResourceType(aChangeLogListener);
+ return;
+ } else if (!response.ok) {
+ cal.LOG("CalDAV: Failed to get ctag from server for calendar " + this.name);
+ notifyListener(Cr.NS_OK);
+ return;
+ }
+
+ let ctag = response.firstProps["CS:getctag"];
+ if (!ctag || ctag != this.mCtag) {
+ // ctag mismatch, need to fetch calendar-data
+ this.mProposedCtag = ctag;
+ this.getUpdatedItems(this.calendarUri, aChangeLogListener);
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: ctag mismatch on refresh, fetching data for calendar " + this.name);
+ }
+ } else {
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: ctag matches, no need to fetch data for calendar " + this.name);
+ }
+
+ // Notify the listener, but don't return just yet...
+ notifyListener(Cr.NS_OK);
+
+ // ...we may still need to poll the inbox
+ if (this.firstInRealm()) {
+ this.pollInbox();
+ }
+ }
+ });
+ },
+
+ refresh() {
+ this.replayChangesOn(null);
+ },
+
+ firstInRealm() {
+ let calendars = cal.manager.getCalendars();
+ for (let i = 0; i < calendars.length; i++) {
+ if (calendars[i].type != "caldav" || calendars[i].getProperty("disabled")) {
+ continue;
+ }
+ // XXX We should probably expose the inner calendar via an
+ // interface, but for now use wrappedJSObject.
+ let calendar = calendars[i].wrappedJSObject;
+ if (calendar.mUncachedCalendar) {
+ calendar = calendar.mUncachedCalendar;
+ }
+ if (calendar.uri.prePath == this.uri.prePath && calendar.authRealm == this.mAuthRealm) {
+ if (calendar.id == this.id) {
+ return true;
+ }
+ break;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Get updated items
+ *
+ * @param {nsIURI} aUri - The uri to request the items from.
+ * NOTE: This must be the uri without any uri
+ * params. They will be appended in this function.
+ * @param aChangeLogListener - (optional) The listener to notify for cached
+ * calendars.
+ */
+ getUpdatedItems(aUri, aChangeLogListener) {
+ if (this.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.checkDavResourceType(aChangeLogListener);
+ return;
+ }
+
+ if (this.mHasWebdavSyncSupport) {
+ let webDavSync = new CalDavWebDavSyncHandler(this, aUri, aChangeLogListener);
+ webDavSync.doWebDAVSync();
+ return;
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<D:propfind xmlns:D="DAV:">' +
+ "<D:prop>" +
+ "<D:getcontenttype/>" +
+ "<D:resourcetype/>" +
+ "<D:getetag/>" +
+ "</D:prop>" +
+ "</D:propfind>";
+
+ let requestUri = this.makeUri(null, aUri);
+ let handler = new CalDavEtagsHandler(this, aUri, aChangeLogListener);
+
+ let onSetupChannel = channel => {
+ channel.requestMethod = "PROPFIND";
+ channel.setRequestHeader("Depth", "1", false);
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.session,
+ this,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ handler,
+ onSetupChannel
+ );
+
+ request.commit().catch(() => {
+ if (aChangeLogListener && this.isCached) {
+ aChangeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ },
+
+ /**
+ * @see nsIInterfaceRequestor
+ * @see calProviderUtils.jsm
+ */
+ getInterface: cal.provider.InterfaceRequestor_getInterface,
+
+ //
+ // Helper functions
+ //
+
+ oauthConnect(authSuccessCb, authFailureCb, aRefresh = false) {
+ // Use the async prompter to avoid multiple primary password prompts
+ let self = this;
+ let promptlistener = {
+ onPromptStartAsync(callback) {
+ this.onPromptAuthAvailable(callback);
+ },
+ onPromptAuthAvailable(callback) {
+ self.oauth.connect(
+ () => {
+ authSuccessCb();
+ if (callback) {
+ callback.onAuthResult(true);
+ }
+ },
+ () => {
+ authFailureCb();
+ if (callback) {
+ callback.onAuthResult(false);
+ }
+ },
+ true,
+ aRefresh
+ );
+ },
+ onPromptCanceled: authFailureCb,
+ onPromptStart() {},
+ };
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(self.uri.spec, false, promptlistener);
+ },
+
+ /**
+ * Called when a response has had its URL redirected. Shows a dialog
+ * to allow the user to accept or reject the redirect. If they accept,
+ * change the calendar's URI to the target URI of the redirect.
+ *
+ * @param {PropfindResponse} response - Response to handle. Typically a
+ * PropfindResponse but could be any
+ * subclass of CalDavResponseBase.
+ * @returns {boolean} True if the user accepted the redirect.
+ * False, if the calendar should be disabled.
+ */
+ openUriRedirectDialog(response) {
+ let args = {
+ calendarName: this.name,
+ originalURI: response.nsirequest.originalURI.spec,
+ targetURI: response.uri.spec,
+ returnValue: false,
+ };
+
+ cal.window
+ .getCalendarWindow()
+ .openDialog(
+ "chrome://calendar/content/calendar-uri-redirect-dialog.xhtml",
+ "Calendar:URIRedirectDialog",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if (args.returnValue) {
+ this.uri = response.uri;
+ this.setProperty("uri", response.uri.spec);
+ }
+
+ return args.returnValue;
+ },
+
+ /**
+ * Checks that the calendar URI exists and is a CalDAV calendar. This is the beginning of a
+ * chain of asynchronous calls. This function will, when done, call the next function related to
+ * checking resource type, server capabilities, etc.
+ *
+ * checkDavResourceType * You are here
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ checkDavResourceType(aChangeLogListener) {
+ this.ensureTargetCalendar();
+
+ let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), [
+ "D:resourcetype",
+ "D:owner",
+ "D:current-user-principal",
+ "D:current-user-privilege-set",
+ "D:supported-report-set",
+ "C:supported-calendar-component-set",
+ "CS:getctag",
+ ]);
+
+ request.commit().then(
+ response => {
+ cal.LOG(`CalDAV: Status ${response.status} on initial PROPFIND for calendar ${this.name}`);
+
+ // If the URI was redirected, and the user rejects the redirect, disable the calendar.
+ if (response.redirected && !this.openUriRedirectDialog(response)) {
+ this.setProperty("disabled", "true");
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ }
+
+ if (response.clientError) {
+ // 4xx codes, which is either an authentication failure or something like method not
+ // allowed. This is a failure worth disabling the calendar.
+ this.setProperty("disabled", "true");
+ this.setProperty("auto-enabled", "true");
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ } else if (response.serverError) {
+ // 5xx codes, a server error. This could be a temporary failure, i.e a backend
+ // server being disabled.
+ cal.LOG(
+ "CalDAV: Server not available " +
+ request.responseStatus +
+ ", abort sync for calendar " +
+ this.name
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ }
+
+ let wwwauth = request.getHeader("Authorization");
+ this.mAuthScheme = wwwauth ? wwwauth.split(" ")[0] : "none";
+
+ if (this.mUriParams) {
+ this.mAuthScheme = "Ticket";
+ }
+ cal.LOG(`CalDAV: Authentication scheme for ${this.name} is ${this.mAuthScheme}`);
+
+ // We only really need the authrealm for Digest auth since only Digest is going to time
+ // out on us
+ if (this.mAuthScheme == "Digest") {
+ let realmChop = wwwauth.split('realm="')[1];
+ this.mAuthRealm = realmChop.split('", ')[0];
+ cal.LOG("CalDAV: realm " + this.mAuthRealm);
+ }
+
+ if (!response.text || response.notFound) {
+ // No response, or the calendar no longer exists.
+ cal.LOG("CalDAV: Failed to determine resource type for" + this.name);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ return;
+ }
+
+ let multistatus = response.xml;
+ if (!multistatus) {
+ cal.LOG(`CalDAV: Failed to determine resource type for ${this.name}`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ return;
+ }
+
+ // check for webdav-sync capability
+ // http://tools.ietf.org/html/draft-daboo-webdav-sync
+ if (response.firstProps["D:supported-report-set"]?.has("D:sync-collection")) {
+ cal.LOG("CalDAV: Collection has webdav sync support");
+ this.mHasWebdavSyncSupport = true;
+ }
+
+ // check for server-side ctag support only if webdav sync is not available
+ let ctag = response.firstProps["CS:getctag"];
+ if (!this.mHasWebdavSyncSupport && ctag) {
+ // We compare the stored ctag with the one we just got, if
+ // they don't match, we update the items in safeRefresh.
+ if (ctag == this.mCtag) {
+ this.mFirstRefreshDone = true;
+ }
+
+ this.mProposedCtag = ctag;
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: initial ctag ${ctag} for calendar ${this.name}`);
+ }
+ }
+
+ // Use supported-calendar-component-set if the server supports it; some do not.
+ let supportedComponents = response.firstProps["C:supported-calendar-component-set"];
+ if (supportedComponents?.size) {
+ this.mSupportedItemTypes = [...this.mGenerallySupportedItemTypes].filter(itype => {
+ return supportedComponents.has(itype);
+ });
+ cal.LOG(
+ `Adding supported items: ${this.mSupportedItemTypes.join(",")} for calendar: ${
+ this.name
+ }`
+ );
+ }
+
+ // check if current-user-principal or owner is specified; might save some work finding
+ // the principal URL.
+ let owner = response.firstProps["D:owner"];
+ let cuprincipal = response.firstProps["D:current-user-principal"];
+ if (cuprincipal) {
+ this.mPrincipalUrl = cuprincipal;
+ cal.LOG(
+ "CalDAV: Found principal url from DAV:current-user-principal " + this.mPrincipalUrl
+ );
+ } else if (owner) {
+ this.mPrincipalUrl = owner;
+ cal.LOG("CalDAV: Found principal url from DAV:owner " + this.mPrincipalUrl);
+ }
+
+ let resourceType = response.firstProps["D:resourcetype"] || new Set();
+ if (resourceType.has("C:calendar")) {
+ // This is a valid calendar resource
+ if (this.mDisabledByDavError) {
+ this.mDisabledByDavError = false;
+ }
+
+ let privs = response.firstProps["D:current-user-privilege-set"];
+ // Don't clear this.readOnly, only set it. The user may have write
+ // privileges but not want to use them.
+ if (!this.readOnly && privs && privs instanceof Set) {
+ this.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+
+ this.setCalHomeSet(true);
+ this.checkServerCaps(aChangeLogListener);
+ } else if (resourceType.has("D:collection")) {
+ // Not a CalDAV calendar
+ cal.LOG(`CalDAV: ${this.name} points to a DAV resource, but not a CalDAV calendar`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_DAV_NOT_CALDAV);
+ } else {
+ // Something else?
+ cal.LOG(
+ `CalDAV: No resource type received, ${this.name} doesn't seem to point to a DAV resource`
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Error during initial PROPFIND for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ }
+ );
+ },
+
+ /**
+ * Checks server capabilities.
+ *
+ * checkDavResourceType
+ * checkServerCaps * You are here
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ checkServerCaps(aChangeLogListener, calHomeSetUrlRetry) {
+ let request = new CalDavHeaderRequest(this.session, this, this.makeUri(null, this.mCalHomeSet));
+
+ request.commit().then(
+ response => {
+ if (!response.ok) {
+ if (!calHomeSetUrlRetry && response.notFound) {
+ // try again with calendar URL, see https://bugzilla.mozilla.org/show_bug.cgi?id=588799
+ cal.LOG(
+ "CalDAV: Calendar homeset was not found at parent url of calendar URL" +
+ ` while querying options ${this.name}, will try calendar URL itself now`
+ );
+ this.setCalHomeSet(false);
+ this.checkServerCaps(aChangeLogListener, true);
+ } else {
+ cal.LOG(
+ `CalDAV: Unexpected status ${response.status} while querying options ${this.name}`
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+
+ // No further processing needed, we have called subsequent (async) functions above.
+ return;
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: DAV features: " + [...response.features.values()].join(", "));
+ }
+
+ if (response.features.has("calendar-auto-schedule")) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: Calendar ${this.name} supports calendar-auto-schedule`);
+ }
+ this.mHasAutoScheduling = true;
+ // leave outbound inbox/outbox scheduling off
+ } else if (response.features.has("calendar-schedule")) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: Calendar ${this.name} generally supports calendar-schedule`);
+ }
+ this.hasScheduling = true;
+ }
+
+ if (this.hasAutoScheduling || response.features.has("calendar-schedule")) {
+ // XXX - we really shouldn't register with the fb service if another calendar with
+ // the same principal-URL has already done so. We also shouldn't register with the
+ // fb service if we don't have an outbox.
+ if (!this.hasFreeBusy) {
+ // This may have already been set by fetchCachedMetaData, we only want to add
+ // the freebusy provider once.
+ this.hasFreeBusy = true;
+ cal.freeBusyService.addProvider(this);
+ }
+ this.findPrincipalNS(aChangeLogListener);
+ } else {
+ cal.LOG("CalDAV: Server does not support CalDAV scheduling.");
+ this.completeCheckServerInfo(aChangeLogListener);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Error checking server capabilities for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ );
+ },
+
+ /**
+ * Locates the principal namespace. This function should solely be called
+ * from checkServerCaps to find the principal namespace.
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS * You are here
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ findPrincipalNS(aChangeLogListener) {
+ if (this.principalUrl) {
+ // We already have a principal namespace, use it.
+ this.checkPrincipalsNameSpace([this.principalUrl], aChangeLogListener);
+ return;
+ }
+
+ let homeSet = this.makeUri(null, this.mCalHomeSet);
+ let request = new CalDavPropfindRequest(this.session, this, homeSet, [
+ "D:principal-collection-set",
+ ]);
+
+ request.commit().then(
+ response => {
+ if (response.ok) {
+ let pcs = response.firstProps["D:principal-collection-set"];
+ let nsList = pcs ? pcs.map(path => this.ensureDecodedPath(path)) : [];
+
+ this.checkPrincipalsNameSpace(nsList, aChangeLogListener);
+ } else {
+ cal.LOG(
+ "CalDAV: Unexpected status " +
+ response.status +
+ " while querying principal namespace for " +
+ this.name
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed to propstat principal namespace for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ );
+ },
+
+ /**
+ * Checks the principals namespace for scheduling info. This function should
+ * solely be called from findPrincipalNS
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace * You are here
+ * completeCheckServerInfo
+ *
+ * @param aNameSpaceList List of available namespaces
+ */
+ checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener) {
+ let doesntSupportScheduling = () => {
+ this.hasScheduling = false;
+ this.mInboxUrl = null;
+ this.mOutboxUrl = null;
+ this.completeCheckServerInfo(aChangeLogListener);
+ };
+
+ if (!aNameSpaceList.length) {
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: principal namespace list empty, calendar " +
+ this.name +
+ " doesn't support scheduling"
+ );
+ }
+ doesntSupportScheduling();
+ return;
+ }
+
+ // We want a trailing slash, ensure it.
+ let nextNS = aNameSpaceList.pop().replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
+ let requestUri = Services.io.newURI(this.calendarUri.prePath + this.ensureEncodedPath(nextNS));
+ let requestProps = [
+ "C:calendar-home-set",
+ "C:calendar-user-address-set",
+ "C:schedule-inbox-URL",
+ "C:schedule-outbox-URL",
+ ];
+
+ let request;
+ if (this.mPrincipalUrl) {
+ request = new CalDavPropfindRequest(this.session, this, requestUri, requestProps);
+ } else {
+ let homePath = this.ensureEncodedPath(this.mCalHomeSet.spec.replace(/\/$/, ""));
+ request = new CalDavPrincipalPropertySearchRequest(
+ this.session,
+ this,
+ requestUri,
+ homePath,
+ "C:calendar-home-set",
+ requestProps
+ );
+ }
+
+ request.commit().then(
+ response => {
+ let homeSetMatches = homeSet => {
+ let normalized = homeSet.replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
+ let chs = this.mCalHomeSet;
+ return normalized == chs.path || normalized == chs.spec;
+ };
+ let createBoxUrl = path => {
+ if (!path) {
+ return null;
+ }
+ let newPath = this.ensureDecodedPath(path);
+ // Make sure the uri has a / at the end, as we do with the calendarUri.
+ if (newPath.charAt(newPath.length - 1) != "/") {
+ newPath += "/";
+ }
+ return this.mUri.mutate().setPathQueryRef(newPath).finalize();
+ };
+
+ if (!response.ok) {
+ cal.LOG(
+ `CalDAV: Bad response to in/outbox query, status ${response.status} for ${this.name}`
+ );
+ doesntSupportScheduling();
+ return;
+ }
+
+ // If there are multiple home sets, we need to match the email addresses for scheduling.
+ // If there is only one, assume its the right one.
+ // TODO with multiple address sets, we should just use the ACL manager.
+ let homeSets = response.firstProps["C:calendar-home-set"];
+ if (homeSets.length == 1 || homeSets.some(homeSetMatches)) {
+ for (let addr of response.firstProps["C:calendar-user-address-set"]) {
+ if (addr.match(/^mailto:/i)) {
+ this.mCalendarUserAddress = addr;
+ }
+ }
+
+ this.mInboxUrl = createBoxUrl(response.firstProps["C:schedule-inbox-URL"]);
+ this.mOutboxUrl = createBoxUrl(response.firstProps["C:schedule-outbox-URL"]);
+
+ if (!this.mInboxUrl || this.calendarUri.spec == this.mInboxUrl.spec) {
+ // If the inbox matches the calendar uri (i.e SOGo), then we
+ // don't need to poll the inbox.
+ this.mShouldPollInbox = false;
+ }
+ }
+
+ if (!this.calendarUserAddress || !this.mInboxUrl || !this.mOutboxUrl) {
+ if (aNameSpaceList.length) {
+ // Check the next namespace to find the info we need.
+ this.checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener);
+ } else {
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: principal namespace list empty, calendar " +
+ this.name +
+ " doesn't support scheduling"
+ );
+ }
+ doesntSupportScheduling();
+ }
+ } else {
+ // We have everything, complete.
+ this.completeCheckServerInfo(aChangeLogListener);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failure checking principal namespace for calendar ${this.name}: ${e}`);
+ doesntSupportScheduling();
+ }
+ );
+ },
+
+ /**
+ * This is called to complete checking the server info. It should be the
+ * final call when checking server options. This will either report the
+ * error or if it is a success then refresh the calendar.
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo * You are here
+ */
+ completeCheckServerInfo(aChangeLogListener, aError = Cr.NS_OK) {
+ if (Components.isSuccessCode(aError)) {
+ this.saveCalendarProperties();
+ this.checkedServerInfo = true;
+ this.setProperty("currentStatus", Cr.NS_OK);
+ if (this.isCached) {
+ this.safeRefresh(aChangeLogListener);
+ } else {
+ this.refresh();
+ }
+ } else {
+ this.reportDavError(aError);
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ },
+
+ /**
+ * Called to report a certain DAV error. Strings and modification type are
+ * handled here.
+ */
+ reportDavError(aErrNo, status, extraInfo) {
+ let mapError = {};
+ mapError[Ci.calIErrors.DAV_NOT_DAV] = "dav_notDav";
+ mapError[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = "dav_davNotCaldav";
+ mapError[Ci.calIErrors.DAV_PUT_ERROR] = "itemPutError";
+ mapError[Ci.calIErrors.DAV_REMOVE_ERROR] = "itemDeleteError";
+ mapError[Ci.calIErrors.DAV_REPORT_ERROR] = "disabledMode";
+
+ let mapModification = {};
+ mapModification[Ci.calIErrors.DAV_NOT_DAV] = false;
+ mapModification[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = false;
+ mapModification[Ci.calIErrors.DAV_PUT_ERROR] = true;
+ mapModification[Ci.calIErrors.DAV_REMOVE_ERROR] = true;
+ mapModification[Ci.calIErrors.DAV_REPORT_ERROR] = false;
+
+ let message = mapError[aErrNo];
+ let localizedMessage;
+ let modificationError = mapModification[aErrNo];
+
+ if (!message) {
+ // Only notify if there is a message for this error
+ return;
+ }
+ localizedMessage = cal.l10n.getCalString(message, [this.mUri.spec]);
+ this.mDisabledByDavError = true;
+ this.notifyError(aErrNo, localizedMessage);
+ this.notifyError(
+ modificationError ? Ci.calIErrors.MODIFICATION_FAILED : Ci.calIErrors.READ_FAILED,
+ this.buildDetailedMessage(status, extraInfo)
+ );
+ },
+
+ buildDetailedMessage(status, extraInfo) {
+ if (!status) {
+ return "";
+ }
+
+ let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties");
+ let statusString;
+ try {
+ statusString = props.GetStringFromName("caldavRequestStatusCodeString" + status);
+ } catch (e) {
+ // Fallback on generic string if no string is defined for the status code
+ statusString = props.GetStringFromName("caldavRequestStatusCodeStringGeneric");
+ }
+ return (
+ props.formatStringFromName("caldavRequestStatusCode", [status]) +
+ ", " +
+ statusString +
+ "\n\n" +
+ (extraInfo ? extraInfo : "")
+ );
+ },
+
+ //
+ // calIFreeBusyProvider interface
+ //
+
+ getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) {
+ // We explicitly don't check for hasScheduling here to allow free-busy queries
+ // even in case sched is turned off.
+ if (!this.outboxUrl || !this.calendarUserAddress) {
+ cal.LOG(
+ "CalDAV: Calendar " +
+ this.name +
+ " doesn't support scheduling;" +
+ " freebusy query not possible"
+ );
+ aListener.onResult(null, null);
+ return;
+ }
+
+ if (!this.firstInRealm()) {
+ // don't spam every known outbox with freebusy queries
+ aListener.onResult(null, null);
+ return;
+ }
+
+ // We tweak the organizer lookup here: If e.g. scheduling is turned off, then the
+ // configured email takes place being the organizerId for scheduling which need
+ // not match against the calendar-user-address:
+ let orgId = this.getProperty("organizerId");
+ if (orgId && orgId.toLowerCase() == aCalId.toLowerCase()) {
+ aCalId = this.calendarUserAddress; // continue with calendar-user-address
+ }
+
+ // the caller prepends MAILTO: to calid strings containing @
+ // but apple needs that to be mailto:
+ let aCalIdParts = aCalId.split(":");
+ aCalIdParts[0] = aCalIdParts[0].toLowerCase();
+ if (aCalIdParts[0] != "mailto" && aCalIdParts[0] != "http" && aCalIdParts[0] != "https") {
+ aListener.onResult(null, null);
+ return;
+ }
+
+ let organizer = this.calendarUserAddress;
+ let recipient = aCalIdParts.join(":");
+ let fbUri = this.makeUri(null, this.outboxUrl);
+
+ let request = new CalDavFreeBusyRequest(
+ this.session,
+ this,
+ fbUri,
+ organizer,
+ recipient,
+ aRangeStart,
+ aRangeEnd
+ );
+
+ request.commit().then(
+ response => {
+ if (!response.xml || response.status != 200) {
+ cal.LOG(
+ "CalDAV: Received status " + response.status + " from freebusy query for " + this.name
+ );
+ aListener.onResult(null, null);
+ return;
+ }
+
+ let fbTypeMap = {
+ UNKNOWN: Ci.calIFreeBusyInterval.UNKNOWN,
+ FREE: Ci.calIFreeBusyInterval.FREE,
+ BUSY: Ci.calIFreeBusyInterval.BUSY,
+ "BUSY-UNAVAILABLE": Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE,
+ "BUSY-TENTATIVE": Ci.calIFreeBusyInterval.BUSY_TENTATIVE,
+ };
+
+ let status = response.firstRecipient.status;
+ if (!status || !status.startsWith("2")) {
+ cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
+ aListener.onResult(null, null);
+ return;
+ }
+
+ if (!status.startsWith("2.0")) {
+ cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
+ }
+
+ let intervals = response.firstRecipient.intervals.map(data => {
+ let fbType = fbTypeMap[data.type] || Ci.calIFreeBusyInterval.UNKNOWN;
+ return new cal.provider.FreeBusyInterval(aCalId, fbType, data.begin, data.end);
+ });
+
+ aListener.onResult(null, intervals);
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed freebusy request for ${this.name}: ${e}`);
+ aListener.onResult(null, null);
+ }
+ );
+ },
+
+ /**
+ * Extract the path from the full spec, if the regexp failed, log
+ * warning and return unaltered path.
+ */
+ extractPathFromSpec(aSpec) {
+ // The parsed array should look like this:
+ // a[0] = full string
+ // a[1] = scheme
+ // a[2] = everything between the scheme and the start of the path
+ // a[3] = extracted path
+ let a = aSpec.match("(https?)(://[^/]*)([^#?]*)");
+ if (a && a[3]) {
+ return a[3];
+ }
+ cal.WARN("CalDAV: Spec could not be parsed, returning as-is: " + aSpec);
+ return aSpec;
+ },
+ /**
+ * This is called to create an encoded path from a unencoded path OR
+ * encoded full url
+ *
+ * @param aString {string} un-encoded path OR encoded uri spec.
+ */
+ ensureEncodedPath(aString) {
+ if (aString.charAt(0) != "/") {
+ aString = this.ensureDecodedPath(aString);
+ }
+ let uriComponents = aString.split("/");
+ uriComponents = uriComponents.map(encodeURIComponent);
+ return uriComponents.join("/");
+ },
+
+ /**
+ * This is called to get a decoded path from an encoded path or uri spec.
+ *
+ * @param {string} aString - Represents either a path
+ * or a full uri that needs to be decoded.
+ * @returns {string} A decoded path.
+ */
+ ensureDecodedPath(aString) {
+ if (aString.charAt(0) != "/") {
+ aString = this.extractPathFromSpec(aString);
+ }
+
+ let uriComponents = aString.split("/");
+ for (let i = 0; i < uriComponents.length; i++) {
+ try {
+ uriComponents[i] = decodeURIComponent(uriComponents[i]);
+ } catch (e) {
+ cal.WARN("CalDAV: Exception decoding path " + aString + ", segment: " + uriComponents[i]);
+ }
+ }
+ return uriComponents.join("/");
+ },
+ isInbox(aString) {
+ // Note: If you change this, make sure it really returns a boolean
+ // value and not null!
+ return (
+ (this.hasScheduling || this.hasAutoScheduling) &&
+ this.mInboxUrl != null &&
+ aString.startsWith(this.mInboxUrl.spec)
+ );
+ },
+
+ /**
+ * Query contents of scheduling inbox
+ *
+ */
+ pollInbox() {
+ // If polling the inbox was switched off, no need to poll the inbox.
+ // Also, if we have more than one calendar in this CalDAV account, we
+ // want only one of them to be checking the inbox.
+ if (
+ (!this.hasScheduling && !this.hasAutoScheduling) ||
+ !this.mShouldPollInbox ||
+ !this.firstInRealm()
+ ) {
+ return;
+ }
+
+ this.getUpdatedItems(this.mInboxUrl, null);
+ },
+
+ //
+ // take calISchedulingSupport interface base implementation (cal.provider.BaseClass)
+ //
+
+ async processItipReply(aItem, aPath) {
+ // modify partstat for in-calendar item
+ // delete item from inbox
+ let self = this;
+ let modListener = {};
+ modListener.QueryInterface = ChromeUtils.generateQI(["calIOperationListener"]);
+ modListener.onOperationComplete = function (
+ aCalendar,
+ aStatus,
+ aOperationType,
+ aItemId,
+ aDetail
+ ) {
+ cal.LOG(`CalDAV: status ${aStatus} while processing iTIP REPLY for ${self.name}`);
+ // don't delete the REPLY item from inbox unless modifying the master
+ // item was successful
+ if (aStatus == 0) {
+ // aStatus undocumented; 0 seems to indicate no error
+ let delUri = self.calendarUri
+ .mutate()
+ .setPathQueryRef(self.ensureEncodedPath(aPath))
+ .finalize();
+ self.doDeleteItem(aItem, null, true, true, delUri);
+ }
+ };
+
+ let itemToUpdate = await this.mOfflineStorage.getItem(aItem.id);
+
+ if (aItem.recurrenceId && itemToUpdate.recurrenceInfo) {
+ itemToUpdate = itemToUpdate.recurrenceInfo.getOccurrenceFor(aItem.recurrenceId);
+ }
+ let newItem = itemToUpdate.clone();
+
+ for (let attendee of aItem.getAttendees()) {
+ let att = newItem.getAttendeeById(attendee.id);
+ if (att) {
+ newItem.removeAttendee(att);
+ att = att.clone();
+ att.participationStatus = attendee.participationStatus;
+ newItem.addAttendee(att);
+ }
+ }
+ self.doModifyItem(
+ newItem,
+ itemToUpdate.parentItem /* related to bug 396182 */,
+ modListener,
+ true
+ );
+ },
+
+ canNotify(aMethod, aItem) {
+ // canNotify should return false if the imip transport should takes care of notifying cal
+ // users
+ if (this.getProperty("forceEmailScheduling")) {
+ return false;
+ }
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ // we go with server's scheduling capabilities here - we take care for exceptions if
+ // schedule agent is set to CLIENT in sendItems()
+ switch (aMethod) {
+ // supported methods as per RfC 6638
+ case "REPLY":
+ case "REQUEST":
+ case "CANCEL":
+ case "ADD":
+ return true;
+ default:
+ cal.LOG(
+ "Not supported method " +
+ aMethod +
+ " detected - falling back to email based scheduling."
+ );
+ }
+ }
+ return false; // use outbound iTIP for all
+ },
+
+ //
+ // calIItipTransport interface
+ //
+
+ get scheme() {
+ return "mailto";
+ },
+
+ mSenderAddress: null,
+ get senderAddress() {
+ return this.mSenderAddress || this.calendarUserAddress;
+ },
+ set senderAddress(aString) {
+ this.mSenderAddress = aString;
+ },
+
+ sendItems(aRecipients, aItipItem, aFromAttendee) {
+ function doImipScheduling(aCalendar, aRecipientList) {
+ let result = false;
+ let imipTransport = cal.provider.getImipTransport(aCalendar);
+ let recipients = [];
+ aRecipientList.forEach(rec => recipients.push(rec.toString()));
+ if (imipTransport) {
+ cal.LOG(
+ "Enforcing client-side email scheduling instead of server-side scheduling" +
+ " for " +
+ recipients.join()
+ );
+ result = imipTransport.sendItems(aRecipientList, aItipItem, aFromAttendee);
+ } else {
+ cal.ERROR(
+ "No imip transport available for " +
+ aCalendar.id +
+ ", failed to notify" +
+ recipients.join()
+ );
+ }
+ return result;
+ }
+
+ if (this.getProperty("forceEmailScheduling")) {
+ return doImipScheduling(this, aRecipients);
+ }
+
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ // let's make sure we notify calendar users marked for client-side scheduling by email
+ let recipients = [];
+ for (let item of aItipItem.getItemList()) {
+ if (aItipItem.receivedMethod == "REPLY") {
+ if (item.organizer.getProperty("SCHEDULE-AGENT") == "CLIENT") {
+ recipients.push(item.organizer);
+ }
+ } else {
+ let atts = item.getAttendees().filter(att => {
+ return att.getProperty("SCHEDULE-AGENT") == "CLIENT";
+ });
+ for (let att of atts) {
+ recipients.push(att);
+ }
+ }
+ }
+ if (recipients.length) {
+ // We return the imip scheduling status here as any remaining calendar user will be
+ // notified by the server without receiving a status in the first place.
+ // We maybe could inspect the scheduling status of those attendees when
+ // re-retriving the modified event and try to do imip schedule on any status code
+ // other then 1.0, 1.1 or 1.2 - but I leave without that for now.
+ return doImipScheduling(this, recipients);
+ }
+ return true;
+ }
+
+ // from here on this code for explicit caldav scheduling
+ if (aItipItem.responseMethod == "REPLY") {
+ // Get my participation status
+ let attendee = aItipItem.getItemList()[0].getAttendeeById(this.calendarUserAddress);
+ if (!attendee) {
+ return false;
+ }
+ // work around BUG 351589, the below just removes RSVP:
+ aItipItem.setAttendeeStatus(attendee.id, attendee.participationStatus);
+ }
+
+ for (let item of aItipItem.getItemList()) {
+ let requestUri = this.makeUri(null, this.outboxUrl);
+ let request = new CalDavOutboxRequest(
+ this.session,
+ this,
+ requestUri,
+ this.calendarUserAddress,
+ aRecipients,
+ item
+ );
+
+ request.commit().then(
+ response => {
+ if (!response.ok) {
+ cal.LOG(`CalDAV: Sending iTIP failed with status ${response.status} for ${this.name}`);
+ }
+
+ let lowerRecipients = new Map(aRecipients.map(recip => [recip.id.toLowerCase(), recip]));
+ let remainingAttendees = [];
+ for (let [recipient, status] of Object.entries(response.data)) {
+ if (status.startsWith("2")) {
+ continue;
+ }
+
+ let att = lowerRecipients.get(recipient.toLowerCase());
+ if (att) {
+ remainingAttendees.push(att);
+ }
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: Failed scheduling delivery to " +
+ remainingAttendees.map(att => att.id).join(", ")
+ );
+ }
+
+ if (remainingAttendees.length) {
+ // try to fall back to email delivery if CalDAV-sched didn't work
+ let imipTransport = cal.provider.getImipTransport(this);
+ if (imipTransport) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: sending email to ${remainingAttendees.length} recipients`);
+ }
+ imipTransport.sendItems(remainingAttendees, aItipItem, aFromAttendee);
+ } else {
+ cal.LOG("CalDAV: no fallback to iTIP/iMIP transport for " + this.name);
+ }
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed itip request for ${this.name}: ${e}`);
+ }
+ );
+ }
+ return true;
+ },
+
+ mVerboseLogging: undefined,
+ verboseLogging() {
+ if (this.mVerboseLogging === undefined) {
+ this.mVerboseLogging = Services.prefs.getBoolPref("calendar.debug.log.verbose", false);
+ }
+ return this.mVerboseLogging;
+ },
+
+ getSerializedItem(aItem) {
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem]);
+ let serializedItem = serializer.serializeToString();
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: send: " + serializedItem);
+ }
+ return serializedItem;
+ },
+};
+
+function calDavObserver(aCalendar) {
+ this.mCalendar = aCalendar;
+}
+
+calDavObserver.prototype = {
+ mCalendar: null,
+ mInBatch: false,
+
+ // calIObserver:
+ onStartBatch(calendar) {
+ this.mCalendar.observers.notify("onStartBatch", [calendar]);
+ this.mInBatch = true;
+ },
+ onEndBatch(calendar) {
+ this.mCalendar.observers.notify("onEndBatch", [calendar]);
+ this.mInBatch = false;
+ },
+ onLoad(calendar) {
+ this.mCalendar.observers.notify("onLoad", [calendar]);
+ },
+ onAddItem(aItem) {
+ this.mCalendar.observers.notify("onAddItem", [aItem]);
+ },
+ onModifyItem(aNewItem, aOldItem) {
+ this.mCalendar.observers.notify("onModifyItem", [aNewItem, aOldItem]);
+ },
+ onDeleteItem(aDeletedItem) {
+ this.mCalendar.observers.notify("onDeleteItem", [aDeletedItem]);
+ },
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.mCalendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]);
+ },
+ onPropertyDeleting(aCalendar, aName) {
+ this.mCalendar.observers.notify("onPropertyDeleting", [aCalendar, aName]);
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.mCalendar.readOnly = true;
+ this.mCalendar.notifyError(aErrNo, aMessage);
+ },
+};
diff --git a/comm/calendar/providers/caldav/CalDavProvider.jsm b/comm/calendar/providers/caldav/CalDavProvider.jsm
new file mode 100644
index 0000000000..940e64337d
--- /dev/null
+++ b/comm/calendar/providers/caldav/CalDavProvider.jsm
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalDavProvider"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+var { CalDavPropfindRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var { CalDavDetectionSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.caldav namespace.
+
+/**
+ * @implements {calICalendarProvider}
+ */
+var CalDavProvider = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]),
+
+ get type() {
+ return "caldav";
+ },
+
+ get displayName() {
+ return cal.l10n.getCalString("caldavName");
+ },
+
+ get shortName() {
+ return "CalDAV";
+ },
+
+ deleteCalendar(aCalendar, aListener) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async detectCalendars(
+ username,
+ password,
+ location = null,
+ savePassword = false,
+ extraProperties = {}
+ ) {
+ let uri = cal.provider.detection.locationToUri(location);
+ if (!uri) {
+ throw new Error("Could not infer location from username");
+ }
+
+ let detector = new CalDavDetector(username, password, savePassword);
+
+ for (let method of [
+ "attemptGoogleOauth",
+ "attemptLocation",
+ "dnsSRV",
+ "wellKnown",
+ "attemptRoot",
+ ]) {
+ try {
+ cal.LOG(`[CalDavProvider] Trying to detect calendar using ${method} method`);
+ let calendars = await detector[method](uri);
+ if (calendars) {
+ return calendars;
+ }
+ } catch (e) {
+ // e may be an Error object or a response object like CalDavSimpleResponse.
+ // It can even be a string, as with the OAuth2 error below.
+ let message = `[CalDavProvider] Could not detect calendar using method ${method}`;
+
+ let errorDetails = err =>
+ ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`;
+
+ let responseDetails = response => ` - HTTP response status ${response.status}`;
+
+ // A special thing the OAuth2 code throws.
+ if (e == '{ "error": "cancelled"}') {
+ cal.WARN(message + ` - OAuth2 '${e}'`);
+ throw new cal.provider.detection.CanceledError("OAuth2 prompt canceled");
+ }
+
+ // We want to pass on any autodetect errors that will become results.
+ if (e instanceof cal.provider.detection.Error) {
+ cal.WARN(message + errorDetails(e));
+ throw e;
+ }
+
+ // Sometimes e is a CalDavResponseBase that is an auth error, so throw it.
+ if (e.authError) {
+ cal.WARN(message + responseDetails(e));
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ if (e instanceof Error) {
+ cal.WARN(message + errorDetails(e));
+ } else if (typeof e.status == "number") {
+ cal.WARN(message + responseDetails(e));
+ } else {
+ cal.WARN(message);
+ }
+ }
+ }
+ return [];
+ },
+};
+
+/**
+ * Used by the CalDavProvider to detect CalDAV calendars for a given username,
+ * password, location, etc.
+ */
+class CalDavDetector {
+ /**
+ * Create a new caldav detector.
+ *
+ * @param {string} username - A username.
+ * @param {string} password - A password.
+ * @param {boolean} savePassword - Whether to save the password or not.
+ */
+ constructor(username, password, savePassword) {
+ this.username = username;
+ this.session = new CalDavDetectionSession(username, password, savePassword);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ attemptLocation(location) {
+ if (location.filePath == "/") {
+ // The location is the root, don't try to detect the collection, let the
+ // other handlers take care of it.
+ return Promise.resolve(null);
+ }
+ return this.detectCollection(location);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using DNS lookups.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async dnsSRV(location) {
+ if (location.filePath != "/") {
+ // If there is already a path specified, then no need to use DNS lookups.
+ return null;
+ }
+
+ let dnshost = location.host;
+ let secure = location.schemeIs("http") ? "" : "s";
+ let dnsres = await DNS.srv(`_caldav${secure}._tcp.${dnshost}`);
+
+ if (!dnsres.length) {
+ let basedomain;
+ try {
+ basedomain = Services.eTLD.getBaseDomain(location);
+ } catch (e) {
+ // If we can't get a base domain just skip it.
+ }
+
+ if (basedomain && basedomain != location.host) {
+ cal.LOG(`[CalDavProvider] ${location.host} has no SRV entry, trying ${basedomain}`);
+ dnsres = await DNS.srv(`_caldav${secure}._tcp.${basedomain}`);
+ dnshost = basedomain;
+ }
+ }
+
+ if (!dnsres.length) {
+ return null;
+ }
+ dnsres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+
+ // Determine path from TXT, if available.
+ let pathres = await DNS.txt(`_caldav${secure}._tcp.${dnshost}`);
+ pathres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+ pathres = pathres.filter(result => result.data.startsWith("path="));
+ // Get the string after `path=`.
+ let path = pathres.length ? pathres[0].data.substr(5) : "";
+
+ let calendars;
+ if (path) {
+ // If the server has SRV and TXT entries, we already have a full context path to test.
+ let uri = `http${secure}://${dnsres[0].host}:${dnsres[0].port}${path}`;
+ cal.LOG(`[CalDavProvider] Trying ${uri} from SRV and TXT response`);
+ calendars = await this.detectCollection(Services.io.newURI(uri));
+ }
+
+ if (!calendars) {
+ // Either the txt record doesn't point to a path (in which case we need to repeat with
+ // well-known), or no calendars could be detected at that location (in which case we
+ // need to repeat with well-known).
+
+ let baseloc = Services.io.newURI(
+ `http${secure}://${dnsres[0].host}:${dnsres[0].port}/.well-known/caldav`
+ );
+ cal.LOG(`[CalDavProvider] Trying ${baseloc.spec} from SRV response with .well-known`);
+
+ calendars = await this.detectCollection(baseloc);
+ }
+
+ return calendars;
+ }
+
+ /**
+ * Attempt to detect calendars using a `.well-known` URI.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async wellKnown(location) {
+ let wellKnownUri = Services.io.newURI("/.well-known/caldav", null, location);
+ cal.LOG(`[CalDavProvider] Trying .well-known URI without dns at ${wellKnownUri.spec}`);
+ return this.detectCollection(wellKnownUri);
+ }
+
+ /**
+ * Attempt to detect calendars using a root ("/") URI.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ attemptRoot(location) {
+ let rootUri = Services.io.newURI("/", null, location);
+ return this.detectCollection(rootUri);
+ }
+
+ /**
+ * Attempt to detect calendars using Google OAuth.
+ *
+ * @param {nsIURI} calURI - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptGoogleOauth(calURI) {
+ let usesGoogleOAuth = cal.provider.detection.googleOAuthDomains.has(calURI.host);
+ if (!usesGoogleOAuth) {
+ // Not using Google OAuth that we know of, but we could check the mx entry.
+ // If mail is handled by Google then this is likely a Google Apps domain.
+ let mxRecords = await DNS.mx(calURI.host);
+ usesGoogleOAuth = mxRecords.some(r => /\bgoogle\.com$/.test(r.host));
+ }
+
+ if (usesGoogleOAuth) {
+ // If we were given a full URL to a calendar, try to use it.
+ let spec = this.username
+ ? `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent(
+ this.username
+ )}/user`
+ : calURI.spec;
+ let uri = Services.io.newURI(spec);
+ return this.handlePrincipal(uri);
+ }
+ return null;
+ }
+
+ /**
+ * Utility function to detect whether a calendar collection exists at a given
+ * location and return it if it exists.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async detectCollection(location) {
+ let props = [
+ "D:resourcetype",
+ "D:owner",
+ "D:displayname",
+ "D:current-user-principal",
+ "D:current-user-privilege-set",
+ "A:calendar-color",
+ "C:calendar-home-set",
+ ];
+
+ cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.ok) {
+ cal.LOG(`[CalDavProvider] ${target.spec} did not respond properly to PROPFIND`);
+ return null;
+ }
+
+ let resprops = response.firstProps;
+ let resourceType = resprops["D:resourcetype"];
+
+ if (resourceType.has("C:calendar")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is a calendar`);
+ return [this.handleCalendar(target, resprops)];
+ } else if (resourceType.has("D:principal")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is a principal, looking at home set`);
+ let homeSet = resprops["C:calendar-home-set"];
+ let homeSetUrl = Services.io.newURI(homeSet, null, target);
+ return this.handleHomeSet(homeSetUrl);
+ } else if (resprops["D:current-user-principal"]) {
+ cal.LOG(
+ `[CalDavProvider] ${target.spec} is something else, looking at current-user-principal`
+ );
+ let principalUrl = Services.io.newURI(resprops["D:current-user-principal"], null, target);
+ return this.handlePrincipal(principalUrl);
+ } else if (resprops["D:owner"]) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is something else, looking at collection owner`);
+ let principalUrl = Services.io.newURI(resprops["D:owner"], null, target);
+ return this.handlePrincipal(principalUrl);
+ }
+
+ return null;
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained either "D:current-user-principal"
+ * or "D:owner" props.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handlePrincipal(location) {
+ let props = ["D:resourcetype", "C:calendar-home-set"];
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+ cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let homeSets = response.firstProps["C:calendar-home-set"];
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.firstProps["D:resourcetype"].has("D:principal")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is not a principal collection`);
+ return null;
+ } else if (homeSets) {
+ let calendars = [];
+ for (let homeSet of homeSets) {
+ cal.LOG(`[CalDavProvider] ${target.spec} has a home set at ${homeSet}, checking that`);
+ let homeSetUrl = Services.io.newURI(homeSet, null, target);
+ let discoveredCalendars = await this.handleHomeSet(homeSetUrl);
+ if (discoveredCalendars) {
+ calendars.push(...discoveredCalendars);
+ }
+ }
+ return calendars.length ? calendars : null;
+ } else {
+ cal.LOG(`[CalDavProvider] ${target.spec} doesn't have a home set`);
+ return null;
+ }
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained a "C:calendar-home-set" prop.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handleHomeSet(location) {
+ let props = [
+ "D:resourcetype",
+ "D:displayname",
+ "D:current-user-privilege-set",
+ "A:calendar-color",
+ ];
+ let request = new CalDavPropfindRequest(this.session, null, location, props, 1);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ let calendars = [];
+ for (let [href, resprops] of Object.entries(response.data)) {
+ if (resprops["D:resourcetype"].has("C:calendar")) {
+ let hrefUri = Services.io.newURI(href, null, target);
+ calendars.push(this.handleCalendar(hrefUri, resprops));
+ }
+ }
+ cal.LOG(`[CalDavProvider] ${target.spec} is a home set, found ${calendars.length} calendars`);
+
+ return calendars.length ? calendars : null;
+ }
+
+ /**
+ * Set up and return a new caldav calendar object.
+ *
+ * @param {nsIURI} uri - The location of the calendar.
+ * @param {Set} props - The calendar properties parsed from the
+ * response.
+ * @returns {calICalendar} A new calendar.
+ */
+ handleCalendar(uri, props) {
+ let displayName = props["D:displayname"];
+ let color = props["A:calendar-color"];
+ if (!displayName) {
+ let fileName = decodeURI(uri.spec).split("/").filter(Boolean).pop();
+ displayName = fileName || uri.spec;
+ }
+
+ // Some servers provide colors as an 8-character hex string. Strip the alpha component.
+ color = color?.replace(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/, "$1");
+
+ let calendar = cal.manager.createCalendar("caldav", uri);
+ calendar.setProperty("color", color || cal.view.hashColor(uri.spec));
+ calendar.name = displayName;
+ calendar.id = cal.getUUID();
+ calendar.setProperty("username", this.username);
+ calendar.wrappedJSObject.session = this.session.toBaseSession();
+
+ // Attempt to discover if the user is allowed to write to this calendar.
+ let privs = props["D:current-user-privilege-set"];
+ if (privs && privs instanceof Set) {
+ calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+ return calendar;
+ }
+}
diff --git a/comm/calendar/providers/caldav/components.conf b/comm/calendar/providers/caldav/components.conf
new file mode 100644
index 0000000000..118aaa065c
--- /dev/null
+++ b/comm/calendar/providers/caldav/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=caldav'],
+ 'jsm': 'resource:///modules/CalDavCalendar.jsm',
+ 'constructor': 'CalDavCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/caldav/modules/CalDavRequest.jsm b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm
new file mode 100644
index 0000000000..7778e42953
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm
@@ -0,0 +1,1211 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalDavTagsToXmlns, CalDavNsUnresolver } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavUtils.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+
+/* exported CalDavGenericRequest, CalDavLegacySAXRequest, CalDavItemRequest,
+ CalDavDeleteItemRequest, CalDavPropfindRequest, CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest, CalDavOutboxRequest, CalDavFreeBusyRequest */
+
+const EXPORTED_SYMBOLS = [
+ "CalDavGenericRequest",
+ "CalDavLegacySAXRequest",
+ "CalDavItemRequest",
+ "CalDavDeleteItemRequest",
+ "CalDavPropfindRequest",
+ "CalDavHeaderRequest",
+ "CalDavPrincipalPropertySearchRequest",
+ "CalDavOutboxRequest",
+ "CalDavFreeBusyRequest",
+];
+
+const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+const MIME_TEXT_CALENDAR = "text/calendar; charset=utf-8";
+const MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+/**
+ * Base class for a caldav request.
+ *
+ * @implements {nsIChannelEventSink}
+ * @implements {nsIInterfaceRequestor}
+ */
+class CalDavRequestBase {
+ QueryInterface = ChromeUtils.generateQI(["nsIChannelEventSink", "nsIInterfaceRequestor"]);
+
+ /**
+ * Creates a new base response, this should mainly be done using the subclass constructor
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {?calICalendar} aCalendar - The calendar this request belongs to (can be null)
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aUploadData - The data to upload
+ * @param {?string} aContentType - The MIME content type for the upload data
+ * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aUri,
+ aUploadData = null,
+ aContentType = null,
+ aOnSetupChannel = null
+ ) {
+ if (typeof aUploadData == "function") {
+ aOnSetupChannel = aUploadData;
+ aUploadData = null;
+ aContentType = null;
+ }
+
+ this.session = aSession;
+ this.calendar = aCalendar;
+ this.uri = aUri;
+ this.uploadData = aUploadData;
+ this.contentType = aContentType;
+ this.onSetupChannel = aOnSetupChannel;
+ this.response = null;
+ this.reset();
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return CalDavSimpleResponse;
+ }
+
+ /**
+ * Resets the channel for this request
+ */
+ reset() {
+ this.channel = cal.provider.prepHttpChannel(
+ this.uri,
+ this.uploadData,
+ this.contentType,
+ this,
+ null,
+ this.session.isDetectionSession
+ );
+ }
+
+ /**
+ * Retrieves the given request header. Requires the request to be committed.
+ *
+ * @param {string} aHeader - The header to retrieve
+ * @returns {?string} The requested header, or null if unavailable
+ */
+ getHeader(aHeader) {
+ try {
+ return this.response.nsirequest.getRequestHeader(aHeader);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /**
+ * Executes the request with the configuration set up in the constructor
+ *
+ * @returns {Promise} A promise that resolves with a subclass of CalDavResponseBase
+ * which is based on |responseClass|.
+ */
+ async commit() {
+ await this.session.prepareRequest(this.channel);
+
+ if (this.onSetupChannel) {
+ this.onSetupChannel(this.channel);
+ }
+
+ if (cal.verboseLogEnabled && this.uploadData) {
+ let method = this.channel.requestMethod;
+ cal.LOGverbose(`CalDAV: send (${method} ${this.uri.spec}): ${this.uploadData}`);
+ }
+
+ let ResponseClass = this.responseClass;
+ this.response = new ResponseClass(this);
+ this.response.lastRedirectStatus = null;
+ this.channel.asyncOpen(this.response.listener, this.channel);
+
+ await this.response.responded;
+
+ let action = await this.session.completeRequest(this.response);
+ if (action == CalDavSession.RESTART_REQUEST) {
+ this.reset();
+ return this.commit();
+ }
+
+ if (cal.verboseLogEnabled) {
+ let text = this.response.text;
+ if (text) {
+ cal.LOGverbose("CalDAV: recv: " + text);
+ }
+ }
+
+ return this.response;
+ }
+
+ /** Implement nsIInterfaceRequestor */
+ getInterface(aIID) {
+ /**
+ * Attempt to call nsIInterfaceRequestor::getInterface on the given object, and return null
+ * if it fails.
+ *
+ * @param {object} aObj - The object to call on.
+ * @returns {?*} The requested interface object, or null.
+ */
+ function tryGetInterface(aObj) {
+ try {
+ let requestor = aObj.QueryInterface(Ci.nsIInterfaceRequestor);
+ return requestor.getInterface(aIID);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ // Special case our nsIChannelEventSink, can't use tryGetInterface due to recursion errors
+ if (aIID.equals(Ci.nsIChannelEventSink)) {
+ return this.QueryInterface(Ci.nsIChannelEventSink);
+ }
+
+ // First check if the session has what we need. It may have an auth prompt implementation
+ // that should go first. Ideally we should move the auth prompt to the session anyway, but
+ // this is a task for another day (tm).
+ let iface = tryGetInterface(this.session) || tryGetInterface(this.calendar);
+ if (iface) {
+ return iface;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ /** Implement nsIChannelEventSink */
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ /**
+ * Copy the given header from the old channel to the new one, ignoring missing headers
+ *
+ * @param {string} aHdr - The header to copy
+ */
+ function copyHeader(aHdr) {
+ try {
+ let hdrValue = aOldChannel.getRequestHeader(aHdr);
+ if (hdrValue) {
+ aNewChannel.setRequestHeader(aHdr, hdrValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ let uploadData, uploadContent;
+ let oldUploadChannel = cal.wrapInstance(aOldChannel, Ci.nsIUploadChannel);
+ let oldHttpChannel = cal.wrapInstance(aOldChannel, Ci.nsIHttpChannel);
+ if (oldUploadChannel && oldHttpChannel && oldUploadChannel.uploadStream) {
+ uploadData = oldUploadChannel.uploadStream;
+ uploadContent = oldHttpChannel.getRequestHeader("Content-Type");
+ }
+
+ cal.provider.prepHttpChannel(null, uploadData, uploadContent, this, aNewChannel);
+
+ // Make sure we can get/set headers on both channels.
+ aNewChannel.QueryInterface(Ci.nsIHttpChannel);
+ aOldChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ try {
+ this.response.lastRedirectStatus = oldHttpChannel.responseStatus;
+ } catch (e) {
+ this.response.lastRedirectStatus = null;
+ }
+
+ // If any other header is used, it should be added here. We might want
+ // to just copy all headers over to the new channel.
+ copyHeader("Depth");
+ copyHeader("Originator");
+ copyHeader("Recipient");
+ copyHeader("If-None-Match");
+ copyHeader("If-Match");
+ copyHeader("Accept");
+
+ aNewChannel.requestMethod = oldHttpChannel.requestMethod;
+ this.session.prepareRedirect(aOldChannel, aNewChannel).then(() => {
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ });
+ }
+}
+
+/**
+ * The caldav response base class. Should be subclassed, and works with xpcom network code that uses
+ * nsIRequest.
+ */
+class CalDavResponseBase {
+ /**
+ * Constructs a new caldav response
+ *
+ * @param {CalDavRequestBase} aRequest - The request that initiated the response
+ */
+ constructor(aRequest) {
+ this.request = aRequest;
+
+ this.responded = new Promise((resolve, reject) => {
+ this._onresponded = resolve;
+ this._onrespondederror = reject;
+ });
+ this.completed = new Promise((resolve, reject) => {
+ this._oncompleted = resolve;
+ this._oncompletederror = reject;
+ });
+ }
+
+ /** The listener passed to the channel's asyncOpen */
+ get listener() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /** @returns {nsIURI} The request URI */
+ get uri() {
+ return this.nsirequest.URI;
+ }
+
+ /** @returns {boolean} True, if the request was redirected */
+ get redirected() {
+ return this.uri.spec != this.nsirequest.originalURI.spec;
+ }
+
+ /** @returns {number} The http response status of the request */
+ get status() {
+ try {
+ return this.nsirequest.responseStatus;
+ } catch (e) {
+ return -1;
+ }
+ }
+
+ /** The http status category, i.e. the first digit */
+ get statusCategory() {
+ return (this.status / 100) | 0;
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.statusCategory == 2;
+ }
+
+ /** If the response has a client error (4xx) */
+ get clientError() {
+ return this.statusCategory == 4;
+ }
+
+ /** If the response had an auth error */
+ get authError() {
+ // 403 is technically "Forbidden", but for our terms it is the same
+ return this.status == 401 || this.status == 403;
+ }
+
+ /** If the response has a conflict code */
+ get conflict() {
+ return this.status == 409 || this.status == 412;
+ }
+
+ /** If the response indicates the resource was not found */
+ get notFound() {
+ return this.status == 404;
+ }
+
+ /** If the response has a server error (5xx) */
+ get serverError() {
+ return this.statusCategory == 5;
+ }
+
+ /**
+ * Raise an exception if one of the handled 4xx and 5xx occurred.
+ */
+ raiseForStatus() {
+ if (this.authError) {
+ throw new HttpUnauthorizedError(this);
+ } else if (this.conflict) {
+ throw new HttpConflictError(this);
+ } else if (this.notFound) {
+ throw new HttpNotFoundError(this);
+ } else if (this.serverError) {
+ throw new HttpServerError(this);
+ }
+ }
+
+ /** The text response of the request */
+ get text() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /** @returns {DOMDocument} A DOM document with the response xml */
+ get xml() {
+ if (this.text && !this._responseXml) {
+ try {
+ this._responseXml = cal.xml.parseString(this.text);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ return this._responseXml;
+ }
+
+ /**
+ * Retrieve a request header
+ *
+ * @param {string} aHeader - The header to retrieve
+ * @returns {string} The header value
+ */
+ getHeader(aHeader) {
+ try {
+ return this.nsirequest.getResponseHeader(aHeader);
+ } catch (e) {
+ return null;
+ }
+ }
+}
+
+/**
+ * Thrown when the response had an authorization error (status 401 or 403).
+ */
+class HttpUnauthorizedError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpUnauthorizedError";
+ }
+}
+
+/**
+ * Thrown when the response has a conflict code (status 409 or 412).
+ */
+class HttpConflictError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpConflictError";
+ }
+}
+
+/**
+ * Thrown when the response indicates the resource was not found (status 404).
+ */
+class HttpNotFoundError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpNotFoundError";
+ }
+}
+
+/**
+ * Thrown when the response has a server error (status 5xx).
+ */
+class HttpServerError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpServerError";
+ }
+}
+
+/**
+ * A simple caldav response using nsIStreamLoader
+ */
+class CalDavSimpleResponse extends CalDavResponseBase {
+ QueryInterface = ChromeUtils.generateQI(["nsIStreamLoaderObserver"]);
+
+ get listener() {
+ if (!this._listener) {
+ this._listener = cal.provider.createStreamLoader();
+ this._listener.init(this);
+ }
+ return this._listener;
+ }
+
+ get text() {
+ if (!this._responseText) {
+ this._responseText = new TextDecoder().decode(Uint8Array.from(this.result)) || "";
+ }
+ return this._responseText;
+ }
+
+ /** Implement nsIStreamLoaderObserver */
+ onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
+ this.resultLength = aResultLength;
+ this.result = aResult;
+
+ this.nsirequest = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
+
+ if (Components.isSuccessCode(aStatus)) {
+ this._onresponded(this);
+ } else {
+ // Check for bad server certificates on SSL/TLS connections.
+ // this.request is CalDavRequestBase instance and it contains calICalendar property
+ // which is needed for checkBadCertStatus. CalDavRequestBase.calendar can be null,
+ // this possibility is handled in BadCertHandler.
+ cal.provider.checkBadCertStatus(aLoader.request, aStatus, this.request.calendar);
+ this._onrespondederror(this);
+ }
+ }
+}
+
+/**
+ * A generic request method that uses the CalDavRequest/CalDavResponse infrastructure
+ */
+class CalDavGenericRequest extends CalDavRequestBase {
+ /**
+ * Constructs the generic caldav request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {string} aMethod - The HTTP method to use
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?object} aHeaders - An object with headers to set
+ * @param {?string} aUploadData - Optional data to upload
+ * @param {?string} aUploadType - Content type for upload data
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aMethod,
+ aUri,
+ aHeaders = {},
+ aUploadData = null,
+ aUploadType = null
+ ) {
+ super(aSession, aCalendar, aUri, aUploadData, aUploadType, channel => {
+ channel.requestMethod = aMethod;
+
+ for (let [name, value] of Object.entries(aHeaders)) {
+ channel.setRequestHeader(name, value, false);
+ }
+ });
+ }
+}
+
+/**
+ * Legacy request handlers request that uses an external request listener. Used for transitioning
+ * because once I started refactoring calDavRequestHandlers.js I was on the verge of refactoring the
+ * whole caldav provider. Too risky right now.
+ */
+class CalDavLegacySAXRequest extends CalDavRequestBase {
+ /**
+ * Constructs the legacy caldav request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aUploadData - Optional data to upload
+ * @param {?string} aUploadType - Content type for upload data
+ * @param {?object} aHandler - The external request handler, e.g.
+ * CalDavEtagsHandler,
+ * CalDavMultigetSyncHandler,
+ * CalDavWebDavSyncHandler.
+ * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aUri,
+ aUploadData = null,
+ aUploadType = null,
+ aHandler = null,
+ aOnSetupChannel = null
+ ) {
+ super(aSession, aCalendar, aUri, aUploadData, aUploadType, aOnSetupChannel);
+ this._handler = aHandler;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return LegacySAXResponse;
+ }
+}
+
+/**
+ * Response class for legacy requests. Contains a listener that proxies the
+ * external request handler object (e.g. CalDavMultigetSyncHandler,
+ * CalDavWebDavSyncHandler, CalDavEtagsHandler) in order to resolve or reject
+ * the promises for the response's "responded" and "completed" status.
+ */
+class LegacySAXResponse extends CalDavResponseBase {
+ /** @returns {nsIStreamListener} The listener passed to the channel's asyncOpen */
+ get listener() {
+ if (!this._listener) {
+ this._listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]),
+
+ onStartRequest: aRequest => {
+ try {
+ let result = this.request._handler.onStartRequest(aRequest);
+ this._onresponded();
+ return result;
+ } catch (e) {
+ this._onrespondederror(e);
+ return null;
+ }
+ },
+ onStopRequest: (aRequest, aStatusCode) => {
+ try {
+ let result = this.request._handler.onStopRequest(aRequest, aStatusCode);
+ this._onresponded();
+ return result;
+ } catch (e) {
+ this._onrespondederror(e);
+ return null;
+ }
+ },
+ onDataAvailable: this.request._handler.onDataAvailable.bind(this.request._handler),
+ };
+ }
+ return this._listener;
+ }
+
+ /** @returns {string} The text response of the request */
+ get text() {
+ return this.request._handler.logXML;
+ }
+}
+
+/**
+ * Upload an item to the caldav server
+ */
+class CalDavItemRequest extends CalDavRequestBase {
+ /**
+ * Constructs an item request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {calIItemBase} aItem - The item to send
+ * @param {?string} aEtag - The etag to check. The special value "*"
+ * sets the If-None-Match header, otherwise
+ * If-Match is set to the etag.
+ */
+ constructor(aSession, aCalendar, aUri, aItem, aEtag = null) {
+ aItem = fixGoogleDescription(aItem, aUri);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem], 1);
+ let serializedItem = serializer.serializeToString();
+
+ super(aSession, aCalendar, aUri, serializedItem, MIME_TEXT_CALENDAR, channel => {
+ if (aEtag == "*") {
+ channel.setRequestHeader("If-None-Match", "*", false);
+ } else if (aEtag) {
+ channel.setRequestHeader("If-Match", aEtag, false);
+ }
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return ItemResponse;
+ }
+}
+
+/**
+ * The response for uploading an item to the server
+ */
+class ItemResponse extends CalDavSimpleResponse {
+ /** If the response has a success code */
+ get ok() {
+ // We should not accept a 201 status here indefinitely: it indicates a server error of some
+ // kind that we want to know about. It's convenient to accept it for now since a number of
+ // server impls don't get this right yet.
+ return this.status == 204 || this.status == 201 || this.status == 200;
+ }
+}
+
+/**
+ * A request for deleting an item from the server
+ */
+class CalDavDeleteItemRequest extends CalDavRequestBase {
+ /**
+ * Constructs an delete item request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aEtag - The etag to check, or null to
+ * unconditionally delete
+ */
+ constructor(aSession, aCalendar, aUri, aEtag = null) {
+ super(aSession, aCalendar, aUri, channel => {
+ if (aEtag) {
+ channel.setRequestHeader("If-Match", aEtag, false);
+ }
+ channel.requestMethod = "DELETE";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return DeleteItemResponse;
+ }
+}
+
+/**
+ * The response class to deleting an item
+ */
+class DeleteItemResponse extends ItemResponse {
+ /** If the response has a success code */
+ get ok() {
+ // Accepting 404 as success because then the item is already deleted
+ return this.status == 204 || this.status == 200 || this.status == 404;
+ }
+}
+
+/**
+ * A dav PROPFIND request to retrieve specific properties of a dav resource.
+ */
+class CalDavPropfindRequest extends CalDavRequestBase {
+ /**
+ * Constructs a propfind request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string[]} aProps - The properties to request, including
+ * namespace prefix.
+ * @param {number} aDepth - The depth for the request, defaults to 0
+ */
+ constructor(aSession, aCalendar, aUri, aProps, aDepth = 0) {
+ let xml =
+ XML_HEADER +
+ `<D:propfind ${CalDavTagsToXmlns("D", ...aProps)}><D:prop>` +
+ aProps.map(prop => `<${prop}/>`).join("") +
+ "</D:prop></D:propfind>";
+
+ super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+ channel.setRequestHeader("Depth", aDepth, false);
+ channel.requestMethod = "PROPFIND";
+ });
+
+ this.depth = aDepth;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return PropfindResponse;
+ }
+}
+
+/**
+ * The response for a PROPFIND request
+ */
+class PropfindResponse extends CalDavSimpleResponse {
+ get decorators() {
+ /**
+ * Retrieves the trimmed text content of the node, or null if empty
+ *
+ * @param {Element} node - The node to get the text content of
+ * @returns {?string} The text content, or null if empty
+ */
+ function textContent(node) {
+ let text = node.textContent;
+ return text ? text.trim() : null;
+ }
+
+ /**
+ * Returns an array of string with each href value within the node scope
+ *
+ * @param {Element} parent - The node to get the href values in
+ * @returns {string[]} The array with trimmed text content values
+ */
+ function href(parent) {
+ return [...parent.querySelectorAll(":scope > href")].map(node => node.textContent.trim());
+ }
+
+ /**
+ * Returns the single href value within the node scope
+ *
+ * @param {Element} node - The node to get the href value in
+ * @returns {?string} The trimmed text content
+ */
+ function singleHref(node) {
+ let hrefval = node.querySelector(":scope > href");
+ return hrefval ? hrefval.textContent.trim() : null;
+ }
+
+ /**
+ * Returns a Set with the respective element local names in the path
+ *
+ * @param {string} path - The css path to search
+ * @param {Element} parent - The parent element to search in
+ * @returns {Set<string>} A set with the element names
+ */
+ function nodeNames(path, parent) {
+ return new Set(
+ [...parent.querySelectorAll(path)].map(node => {
+ let prefix = CalDavNsUnresolver(node.namespaceURI) || node.prefix;
+ return prefix + ":" + node.localName;
+ })
+ );
+ }
+
+ /**
+ * Returns a Set for the "current-user-privilege-set" properties. If a 404
+ * status is detected, null is returned indicating the server does not
+ * support this directive.
+ *
+ * @param {string} path - The css path to search
+ * @param {Element} parent - The parent element to search in
+ * @param {string} status - The status of the enclosing <propstat>
+ * @returns {Set<string>}
+ */
+ function privSet(path, parent, status = "") {
+ return status.includes("404") ? null : nodeNames(path, parent);
+ }
+
+ /**
+ * Returns a Set with the respective attribute values in the path
+ *
+ * @param {string} path - The css path to search
+ * @param {string} attribute - The attribute name to retrieve for each node
+ * @param {Element} parent - The parent element to search in
+ * @returns {Set<string>} A set with the attribute values
+ */
+ function attributeValue(path, attribute, parent) {
+ return new Set(
+ [...parent.querySelectorAll(path)].map(node => {
+ return node.getAttribute(attribute);
+ })
+ );
+ }
+
+ /**
+ * Return the result of either function a or function b, passing the node
+ *
+ * @param {Function} a - The first function to call
+ * @param {Function} b - The second function to call
+ * @param {Element} node - The node to call the functions with
+ * @returns {*} The return value of either a() or b()
+ */
+ function either(a, b, node) {
+ return a(node) || b(node);
+ }
+
+ return {
+ "D:principal-collection-set": href,
+ "C:calendar-home-set": href,
+ "C:calendar-user-address-set": href,
+ "D:current-user-principal": singleHref,
+ "D:current-user-privilege-set": privSet.bind(null, ":scope > privilege > *"),
+ "D:owner": singleHref,
+ "D:supported-report-set": nodeNames.bind(null, ":scope > supported-report > report > *"),
+ "D:resourcetype": nodeNames.bind(null, ":scope > *"),
+ "C:supported-calendar-component-set": attributeValue.bind(null, ":scope > comp", "name"),
+ "C:schedule-inbox-URL": either.bind(null, singleHref, textContent),
+ "C:schedule-outbox-URL": either.bind(null, singleHref, textContent),
+ };
+ }
+ /**
+ * Quick access to the properties of the PROPFIND request. Returns an object with the hrefs as
+ * keys, and an object with the normalized properties as the value.
+ *
+ * @returns {object} The object
+ */
+ get data() {
+ if (!this._data) {
+ this._data = {};
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let href = response.querySelector(":scope > href").textContent;
+ this._data[href] = {};
+
+ // This will throw 200's and 400's in one pot, but since 400's are empty that is ok
+ // for our needs.
+ for (let propStat of response.querySelectorAll(":scope > propstat")) {
+ let status = propStat.querySelector(":scope > status").textContent;
+ for (let prop of propStat.querySelectorAll(":scope > prop > *")) {
+ let prefix = CalDavNsUnresolver(prop.namespaceURI) || prop.prefix;
+ let qname = prefix + ":" + prop.localName;
+ if (qname in this.decorators) {
+ this._data[href][qname] = this.decorators[qname](prop, status) || null;
+ } else {
+ this._data[href][qname] = prop.textContent.trim() || null;
+ }
+ }
+ }
+ }
+ }
+ return this._data;
+ }
+
+ /**
+ * Shortcut for the properties of the first response, useful for depth=0
+ */
+ get firstProps() {
+ return Object.values(this.data)[0];
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.status == 207 && this.xml;
+ }
+}
+
+/**
+ * An OPTIONS request for retrieving the DAV header
+ */
+class CalDavHeaderRequest extends CalDavRequestBase {
+ /**
+ * Constructs the options request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ */
+ constructor(aSession, aCalendar, aUri) {
+ super(aSession, aCalendar, aUri, channel => {
+ channel.requestMethod = "OPTIONS";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return DAVHeaderResponse;
+ }
+}
+
+/**
+ * The response class for the dav header request
+ */
+class DAVHeaderResponse extends CalDavSimpleResponse {
+ /**
+ * Returns a Set with the DAV features, not including the version
+ */
+ get features() {
+ if (!this._features) {
+ let dav = this.getHeader("dav") || "";
+ let features = dav.split(/,\s*/);
+ features.shift();
+ this._features = new Set(features);
+ }
+ return this._features;
+ }
+
+ /**
+ * The version from the DAV header
+ */
+ get version() {
+ let dav = this.getHeader("dav");
+ return parseInt(dav.substr(0, dav.indexOf(",")), 10);
+ }
+}
+
+/**
+ * Request class for principal-property-search queries
+ */
+class CalDavPrincipalPropertySearchRequest extends CalDavRequestBase {
+ /**
+ * Constructs a principal-property-search query.
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aMatch - The href to search in
+ * @param {string} aSearchProp - The property to search for
+ * @param {string[]} aProps - The properties to retrieve
+ * @param {number} aDepth - The depth of the query, defaults to 1
+ */
+ constructor(aSession, aCalendar, aUri, aMatch, aSearchProp, aProps, aDepth = 1) {
+ let xml =
+ XML_HEADER +
+ `<D:principal-property-search ${CalDavTagsToXmlns("D", aSearchProp, ...aProps)}>` +
+ "<D:property-search>" +
+ "<D:prop>" +
+ `<${aSearchProp}/>` +
+ "</D:prop>" +
+ `<D:match>${cal.xml.escapeString(aMatch)}</D:match>` +
+ "</D:property-search>" +
+ "<D:prop>" +
+ aProps.map(prop => `<${prop}/>`).join("") +
+ "</D:prop>" +
+ "</D:principal-property-search>";
+
+ super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+ channel.setRequestHeader("Depth", aDepth, false);
+ channel.requestMethod = "REPORT";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return PropfindResponse;
+ }
+}
+
+/**
+ * Request class for calendar outbox queries, to send or respond to invitations
+ */
+class CalDavOutboxRequest extends CalDavRequestBase {
+ /**
+ * Constructs an outbox request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aOrganizer - The organizer of the request
+ * @param {string} aRecipients - The recipients of the request
+ * @param {string} aResponseMethod - The itip response method, e.g. REQUEST,REPLY
+ * @param {calIItemBase} aItem - The item to send
+ */
+ constructor(aSession, aCalendar, aUri, aOrganizer, aRecipients, aResponseMethod, aItem) {
+ aItem = fixGoogleDescription(aItem, aUri);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem], 1);
+
+ let method = cal.icsService.createIcalProperty("METHOD");
+ method.value = aResponseMethod;
+ serializer.addProperty(method);
+
+ super(
+ aSession,
+ aCalendar,
+ aUri,
+ serializer.serializeToString(),
+ MIME_TEXT_CALENDAR,
+ channel => {
+ channel.requestMethod = "POST";
+ channel.setRequestHeader("Originator", aOrganizer, false);
+ for (let recipient of aRecipients) {
+ channel.setRequestHeader("Recipient", recipient, true);
+ }
+ }
+ );
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return OutboxResponse;
+ }
+}
+
+/**
+ * Response class for the caldav outbox request
+ */
+class OutboxResponse extends CalDavSimpleResponse {
+ /**
+ * An object with the recipients as keys, and the request status as values
+ */
+ get data() {
+ if (!this._data) {
+ this._data = {};
+ // TODO The following queries are currently untested code, as I don't have
+ // a caldav-sched server available. If you find someone who does, please test!
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let recipient = response.querySelector(":scope > recipient > href").textContent;
+ let status = response.querySelector(":scope > request-status").textContent;
+ this.data[recipient] = status;
+ }
+ }
+ return this._data;
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.status == 200 && this.xml;
+ }
+}
+
+/**
+ * Request class for freebusy queries
+ */
+class CalDavFreeBusyRequest extends CalDavRequestBase {
+ /**
+ * Creates a freebusy request, for the specified range
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aOrganizer - The organizer of the request
+ * @param {string} aRecipient - The attendee to look up
+ * @param {calIDateTime} aRangeStart - The start of the range
+ * @param {calIDateTime} aRangeEnd - The end of the range
+ */
+ constructor(aSession, aCalendar, aUri, aOrganizer, aRecipient, aRangeStart, aRangeEnd) {
+ let vcalendar = cal.icsService.createIcalComponent("VCALENDAR");
+ cal.item.setStaticProps(vcalendar);
+
+ let method = cal.icsService.createIcalProperty("METHOD");
+ method.value = "REQUEST";
+ vcalendar.addProperty(method);
+
+ let freebusy = cal.icsService.createIcalComponent("VFREEBUSY");
+ freebusy.uid = cal.getUUID();
+ freebusy.stampTime = cal.dtz.now().getInTimezone(cal.dtz.UTC);
+ freebusy.startTime = aRangeStart.getInTimezone(cal.dtz.UTC);
+ freebusy.endTime = aRangeEnd.getInTimezone(cal.dtz.UTC);
+ vcalendar.addSubcomponent(freebusy);
+
+ let organizer = cal.icsService.createIcalProperty("ORGANIZER");
+ organizer.value = aOrganizer;
+ freebusy.addProperty(organizer);
+
+ let attendee = cal.icsService.createIcalProperty("ATTENDEE");
+ attendee.setParameter("PARTSTAT", "NEEDS-ACTION");
+ attendee.setParameter("ROLE", "REQ-PARTICIPANT");
+ attendee.setParameter("CUTYPE", "INDIVIDUAL");
+ attendee.value = aRecipient;
+ freebusy.addProperty(attendee);
+
+ super(aSession, aCalendar, aUri, vcalendar.serializeToICS(), MIME_TEXT_CALENDAR, channel => {
+ channel.requestMethod = "POST";
+ channel.setRequestHeader("Originator", aOrganizer, false);
+ channel.setRequestHeader("Recipient", aRecipient, false);
+ });
+
+ this._rangeStart = aRangeStart;
+ this._rangeEnd = aRangeEnd;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return FreeBusyResponse;
+ }
+}
+
+/**
+ * Response class for the freebusy request
+ */
+class FreeBusyResponse extends CalDavSimpleResponse {
+ /**
+ * Quick access to the freebusy response data. An object is returned with the keys being
+ * recipients:
+ *
+ * {
+ * "mailto:user@example.com": {
+ * status: "HTTP/1.1 200 OK",
+ * intervals: [
+ * { type: "BUSY", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) },
+ * { type: "FREE", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) }
+ * ]
+ * }
+ * }
+ */
+ get data() {
+ /**
+ * Helper to get the trimmed text content
+ *
+ * @param {Element} aParent - The parent node to search in
+ * @param {string} aPath - The css query path to serch
+ * @returns {string} The trimmed text content
+ */
+ function querySelectorText(aParent, aPath) {
+ let node = aParent.querySelector(aPath);
+ return node ? node.textContent.trim() : "";
+ }
+
+ if (!this._data) {
+ this._data = {};
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let recipient = querySelectorText(response, ":scope > recipient > href");
+ let status = querySelectorText(response, ":scope > request-status");
+ let caldata = querySelectorText(response, ":scope > calendar-data");
+ let intervals = [];
+ if (caldata) {
+ let component;
+ try {
+ component = cal.icsService.parseICS(caldata);
+ } catch (e) {
+ cal.LOG("CalDAV: Could not parse freebusy data: " + e);
+ continue;
+ }
+
+ for (let fbcomp of cal.iterate.icalComponent(component, "VFREEBUSY")) {
+ let fbstart = fbcomp.startTime;
+ if (fbstart && this.request._rangeStart.compare(fbstart) < 0) {
+ intervals.push({
+ type: "UNKNOWN",
+ begin: this.request._rangeStart,
+ end: fbstart,
+ });
+ }
+
+ for (let fbprop of cal.iterate.icalProperty(fbcomp, "FREEBUSY")) {
+ let type = fbprop.getParameter("FBTYPE");
+
+ let parts = fbprop.value.split("/");
+ let begin = cal.createDateTime(parts[0]);
+ let end;
+ if (parts[1].startsWith("P")) {
+ // this is a duration
+ end = begin.clone();
+ end.addDuration(cal.createDuration(parts[1]));
+ } else {
+ // This is a date string
+ end = cal.createDateTime(parts[1]);
+ }
+
+ intervals.push({ type, begin, end });
+ }
+
+ let fbend = fbcomp.endTime;
+ if (fbend && this.request._rangeEnd.compare(fbend) > 0) {
+ intervals.push({
+ type: "UNKNOWN",
+ begin: fbend,
+ end: this.request._rangeEnd,
+ });
+ }
+ }
+ }
+ this._data[recipient] = { status, intervals };
+ }
+ }
+ return this._data;
+ }
+
+ /**
+ * The data for the first recipient, useful if just one recipient was requested
+ */
+ get firstRecipient() {
+ return Object.values(this.data)[0];
+ }
+}
+
+/**
+ * Set item description to a format Google Calendar understands if the item
+ * will be uploaded to Google Calendar.
+ *
+ * @param {calIItemBase} aItem - The item we may want to modify.
+ * @param {nsIURI} aUri - The URI the item will be uploaded to.
+ * @returns {calItemBase} - A calendar item with appropriately-set description.
+ */
+function fixGoogleDescription(aItem, aUri) {
+ if (aUri.spec.startsWith("https://apidata.googleusercontent.com/caldav/")) {
+ // Google expects item descriptions to be bare HTML in violation of spec,
+ // rather than using the standard Alternate Text Representation.
+ aItem = aItem.clone();
+ aItem.descriptionText = aItem.descriptionHTML;
+
+ // Mark items we've modified for Google compatibility for informational
+ // purposes.
+ aItem.setProperty("X-MOZ-GOOGLE-HTML-DESCRIPTION", true);
+ }
+
+ return aItem;
+}
diff --git a/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm
new file mode 100644
index 0000000000..c5055d1a1f
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm
@@ -0,0 +1,1091 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalDavLegacySAXRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+/* exported CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler */
+
+const EXPORTED_SYMBOLS = [
+ "CalDavEtagsHandler",
+ "CalDavWebDavSyncHandler",
+ "CalDavMultigetSyncHandler",
+];
+
+const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+const MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+/**
+ * Accumulate all XML response, then parse with DOMParser. This class imitates
+ * nsISAXXMLReader by calling startDocument/endDocument and startElement/endElement.
+ */
+class XMLResponseHandler {
+ constructor() {
+ this._inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ this._xmlString = "";
+ }
+
+ /**
+ * @see nsIStreamListener
+ */
+ onDataAvailable(request, inputStream, offset, count) {
+ this._inStream.init(inputStream);
+ // What we get from inputStream is BinaryString, decode it to UTF-8.
+ this._xmlString += new TextDecoder("UTF-8").decode(
+ this._binaryStringToTypedArray(this._inStream.read(count))
+ );
+ }
+
+ /**
+ * Log the response code and body.
+ *
+ * @param {number} responseStatus
+ */
+ logResponse(responseStatus) {
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: recv (${responseStatus}): ${this._xmlString}`);
+ }
+ }
+
+ /**
+ * Parse this._xmlString with DOMParser, then create a TreeWalker and start
+ * walking the node tree.
+ */
+ async handleResponse() {
+ let parser = new DOMParser();
+ let doc;
+ try {
+ doc = parser.parseFromString(this._xmlString, "application/xml");
+ } catch (e) {
+ cal.ERROR("CALDAV: DOMParser parse error: ", e);
+ this.fatalError();
+ }
+
+ let treeWalker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT);
+ this.startDocument();
+ await this._walk(treeWalker);
+ await this.endDocument();
+ }
+
+ /**
+ * Reset this._xmlString.
+ */
+ resetXMLResponseHandler() {
+ this._xmlString = "";
+ }
+
+ /**
+ * Converts a binary string into a Uint8Array.
+ *
+ * @param {BinaryString} str - The string to convert.
+ * @returns {Uint8Array}.
+ */
+ _binaryStringToTypedArray(str) {
+ let arr = new Uint8Array(str.length);
+ for (let i = 0; i < str.length; i++) {
+ arr[i] = str.charCodeAt(i);
+ }
+ return arr;
+ }
+
+ /**
+ * Walk the tree node by node, call startElement and endElement when appropriate.
+ */
+ async _walk(treeWalker) {
+ let currentNode = treeWalker.currentNode;
+ if (currentNode) {
+ this.startElement("", currentNode.localName, currentNode.nodeName, "");
+
+ // Traverse children first.
+ let firstChild = treeWalker.firstChild();
+ if (firstChild) {
+ await this._walk(treeWalker);
+ // TreeWalker has reached a leaf node, reset the cursor to continue the traversal.
+ treeWalker.currentNode = firstChild;
+ } else {
+ this.characters(currentNode.textContent);
+ await this.endElement("", currentNode.localName, currentNode.nodeName);
+ return;
+ }
+
+ // Traverse siblings next.
+ let nextSibling = treeWalker.nextSibling();
+ while (nextSibling) {
+ await this._walk(treeWalker);
+ // TreeWalker has reached a leaf node, reset the cursor to continue the traversal.
+ treeWalker.currentNode = nextSibling;
+ nextSibling = treeWalker.nextSibling();
+ }
+
+ await this.endElement("", currentNode.localName, currentNode.nodeName);
+ }
+ }
+}
+
+/**
+ * This is a handler for the etag request in calDavCalendar.js' getUpdatedItem.
+ * It uses XMLResponseHandler to parse the items and compose the resulting
+ * multiget.
+ */
+class CalDavEtagsHandler extends XMLResponseHandler {
+ /**
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify.
+ */
+ constructor(aCalendar, aBaseUri, aChangeLogListener) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.changeLogListener = aChangeLogListener;
+
+ this.itemsReported = {};
+ this.itemsNeedFetching = [];
+ }
+
+ skipIndex = -1;
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ changeLogListener = null;
+ logXML = "";
+
+ itemsReported = null;
+ itemsNeedFetching = null;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ } else {
+ cal.LOG("CalDAV: Error fetching item etags");
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus != 207) {
+ // Not a successful response, do nothing.
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ await this.handleResponse();
+
+ // Now that we are done, check which items need fetching.
+ this.calendar.superCalendar.startBatch();
+
+ let needsRefresh = false;
+ try {
+ for (let path in this.calendar.mHrefIndex) {
+ if (path in this.itemsReported || path.substr(0, this.baseUri.length) == this.baseUri) {
+ // If the item is also on the server, check the next.
+ continue;
+ }
+ // If an item has been deleted from the server, delete it here too.
+ // Since the target calendar's operations are synchronous, we can
+ // safely set variables from this function.
+ let foundItem = await this.calendar.mOfflineStorage.getItem(this.calendar.mHrefIndex[path]);
+
+ if (foundItem) {
+ let wasInboxItem = this.calendar.mItemInfoCache[foundItem.id].isInboxItem;
+ if (
+ (wasInboxItem && this.calendar.isInbox(this.baseUri.spec)) ||
+ (wasInboxItem === false && !this.calendar.isInbox(this.baseUri.spec))
+ ) {
+ cal.LOG("Deleting local href: " + path);
+ delete this.calendar.mHrefIndex[path];
+ await this.calendar.mOfflineStorage.deleteItem(foundItem);
+ needsRefresh = true;
+ }
+ }
+ }
+ } finally {
+ this.calendar.superCalendar.endBatch();
+ }
+
+ // Avoid sending empty multiget requests update views if something has
+ // been deleted server-side.
+ if (this.itemsNeedFetching.length) {
+ let multiget = new CalDavMultigetSyncHandler(
+ this.itemsNeedFetching,
+ this.calendar,
+ this.baseUri,
+ null,
+ false,
+ null,
+ this.changeLogListener
+ );
+ multiget.doMultiGet();
+ } else {
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK);
+ }
+
+ if (needsRefresh) {
+ this.calendar.mObservers.notify("onLoad", [this.calendar]);
+ }
+
+ // but do poll the inbox
+ if (this.calendar.mShouldPollInbox && !this.calendar.isInbox(this.baseUri.spec)) {
+ this.calendar.pollInbox();
+ }
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError() {
+ cal.WARN("CalDAV: Fatal Error parsing etags for " + this.calendar.name);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ if (this.tag) {
+ this.currentResponse[this.tag] += aValue;
+ }
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ }
+
+ endDocument() {}
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response":
+ this.currentResponse = {};
+ this.currentResponse.isCollection = false;
+ this.tag = null;
+ break;
+ case "collection":
+ this.currentResponse.isCollection = true;
+ // falls through
+ case "href":
+ case "getetag":
+ case "getcontenttype":
+ this.tag = aLocalName;
+ this.currentResponse[aLocalName] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": {
+ this.tag = null;
+ let resp = this.currentResponse;
+ if (
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ resp.getcontenttype &&
+ resp.getcontenttype.length &&
+ !resp.isCollection
+ ) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+
+ if (resp.getcontenttype.substr(0, 14) == "message/rfc822") {
+ // workaround for a Scalix bug which causes incorrect
+ // contenttype to be returned.
+ resp.getcontenttype = "text/calendar";
+ }
+ if (resp.getcontenttype == "text/vtodo") {
+ // workaround Kerio weirdness
+ resp.getcontenttype = "text/calendar";
+ }
+
+ // Only handle calendar items
+ if (resp.getcontenttype.substr(0, 13) == "text/calendar") {
+ if (resp.href && resp.href.length) {
+ this.itemsReported[resp.href] = resp.getetag;
+
+ let itemUid = this.calendar.mHrefIndex[resp.href];
+ if (!itemUid || resp.getetag != this.calendar.mItemInfoCache[itemUid].etag) {
+ this.itemsNeedFetching.push(resp.href);
+ }
+ }
+ }
+ }
+ break;
+ }
+ case "href":
+ case "getetag":
+ case "getcontenttype": {
+ this.tag = null;
+ break;
+ }
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
+
+/**
+ * This is a handler for the webdav sync request in calDavCalendar.js'
+ * getUpdatedItem. It uses XMLResponseHandler to parse the items and compose the
+ * resulting multiget.
+ */
+class CalDavWebDavSyncHandler extends XMLResponseHandler {
+ /**
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify.
+ */
+ constructor(aCalendar, aBaseUri, aChangeLogListener) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.changeLogListener = aChangeLogListener;
+
+ this.itemsReported = {};
+ this.itemsNeedFetching = [];
+ }
+
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ newSyncToken = null;
+ changeLogListener = null;
+ logXML = "";
+ isInPropStat = false;
+ changeCount = 0;
+ unhandledErrors = 0;
+ itemsReported = null;
+ itemsNeedFetching = null;
+ additionalSyncNeeded = false;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ async doWebDAVSync() {
+ if (this.calendar.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.calendar.checkDavResourceType(this.changeLogListener);
+ return;
+ }
+
+ let syncTokenString = "<sync-token/>";
+ if (this.calendar.mWebdavSyncToken && this.calendar.mWebdavSyncToken.length > 0) {
+ let syncToken = cal.xml.escapeString(this.calendar.mWebdavSyncToken);
+ syncTokenString = "<sync-token>" + syncToken + "</sync-token>";
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<sync-collection xmlns="DAV:">' +
+ syncTokenString +
+ "<sync-level>1</sync-level>" +
+ "<prop>" +
+ "<getcontenttype/>" +
+ "<getetag/>" +
+ "</prop>" +
+ "</sync-collection>";
+
+ let requestUri = this.calendar.makeUri(null, this.baseUri);
+
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`);
+ }
+ cal.LOG("CalDAV: webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+
+ let onSetupChannel = channel => {
+ // The depth header adheres to an older version of the webdav-sync
+ // spec and has been replaced by the <sync-level> tag above.
+ // Unfortunately some servers still depend on the depth header,
+ // therefore we send both (yuck).
+ channel.setRequestHeader("Depth", "1", false);
+ channel.requestMethod = "REPORT";
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.calendar.session,
+ this.calendar,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ this,
+ onSetupChannel
+ );
+
+ await request.commit().catch(() => {
+ // Something went wrong with the OAuth token, notify failure
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ }
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus == 207) {
+ await this.handleResponse();
+ } else if (
+ (responseStatus == 403 && this._xmlString.includes(`<D:error xmlns:D="DAV:"/>`)) ||
+ responseStatus == 429
+ ) {
+ // We're hitting the rate limit. Don't attempt to refresh now.
+ cal.WARN("CalDAV: rate limit reached, server returned status code: " + responseStatus);
+ if (this.calendar.isCached && this.changeLogListener) {
+ // Not really okay, but we have to return something and an error code puts us in a bad state.
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ } else if (
+ this.calendar.mWebdavSyncToken != null &&
+ responseStatus >= 400 &&
+ responseStatus <= 499
+ ) {
+ // Invalidate sync token with 4xx errors that could indicate the
+ // sync token has become invalid and do a refresh.
+ cal.LOG(
+ "CalDAV: Resetting sync token because server returned status code: " + responseStatus
+ );
+ this.calendar.mWebdavSyncToken = null;
+ this.calendar.saveCalendarProperties();
+ this.calendar.safeRefresh(this.changeLogListener);
+ } else {
+ cal.WARN("CalDAV: Error doing webdav sync: " + responseStatus);
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError() {
+ cal.WARN("CalDAV: Fatal Error doing webdav sync for " + this.calendar.name);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ this.currentResponse[this.tag] += aValue;
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ this.calendar.superCalendar.startBatch();
+ }
+
+ async endDocument() {
+ if (this.unhandledErrors) {
+ this.calendar.superCalendar.endBatch();
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ if (this.calendar.mWebdavSyncToken == null && !this.additionalSyncNeeded) {
+ // null token means reset or first refresh indicating we did
+ // a full sync; remove local items that were not returned in this full
+ // sync
+ for (let path in this.calendar.mHrefIndex) {
+ if (!this.itemsReported[path]) {
+ await this.calendar.deleteTargetCalendarItem(path);
+ }
+ }
+ }
+ this.calendar.superCalendar.endBatch();
+
+ if (this.itemsNeedFetching.length) {
+ let multiget = new CalDavMultigetSyncHandler(
+ this.itemsNeedFetching,
+ this.calendar,
+ this.baseUri,
+ this.newSyncToken,
+ this.additionalSyncNeeded,
+ null,
+ this.changeLogListener
+ );
+ multiget.doMultiGet();
+ } else {
+ if (this.newSyncToken) {
+ this.calendar.mWebdavSyncToken = this.newSyncToken;
+ this.calendar.saveCalendarProperties();
+ cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+
+ if (this.additionalSyncNeeded) {
+ let wds = new CalDavWebDavSyncHandler(
+ this.calendar,
+ this.baseUri,
+ this.changeLogListener
+ );
+ wds.doWebDAVSync();
+ return;
+ }
+ }
+ this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri);
+ }
+ }
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response": // WebDAV Sync draft 3
+ this.currentResponse = {};
+ this.tag = null;
+ this.isInPropStat = false;
+ break;
+ case "propstat":
+ this.isInPropStat = true;
+ break;
+ case "status":
+ if (this.isInPropStat) {
+ this.tag = "propstat_" + aLocalName;
+ } else {
+ this.tag = aLocalName;
+ }
+ this.currentResponse[this.tag] = "";
+ break;
+ case "href":
+ case "getetag":
+ case "getcontenttype":
+ case "sync-token":
+ this.tag = aLocalName.replace(/-/g, "");
+ this.currentResponse[this.tag] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ async endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": // WebDAV Sync draft 3
+ case "sync-response": {
+ // WebDAV Sync draft 0,1,2
+ let resp = this.currentResponse;
+ if (resp.href && resp.href.length) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+ }
+
+ if (
+ (!resp.getcontenttype || resp.getcontenttype == "text/plain") &&
+ resp.href &&
+ resp.href.endsWith(".ics")
+ ) {
+ // If there is no content-type (iCloud) or text/plain was passed
+ // (iCal Server) for the resource but its name ends with ".ics"
+ // assume the content type to be text/calendar. Apple
+ // iCloud/iCal Server interoperability fix.
+ resp.getcontenttype = "text/calendar";
+ }
+
+ // Deleted item
+ if (
+ resp.href &&
+ resp.href.length &&
+ resp.status &&
+ resp.status.length &&
+ resp.status.indexOf(" 404") > 0
+ ) {
+ if (this.calendar.mHrefIndex[resp.href]) {
+ this.changeCount++;
+ await this.calendar.deleteTargetCalendarItem(resp.href);
+ } else {
+ cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href);
+ }
+ // Only handle Created or Updated calendar items
+ } else if (
+ resp.getcontenttype &&
+ resp.getcontenttype.substr(0, 13) == "text/calendar" &&
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ (!resp.status || // Draft 3 does not require
+ resp.status.length == 0 || // a status for created or updated items but
+ resp.status.indexOf(" 204") || // draft 0, 1 and 2 needed it so treat no status
+ resp.status.indexOf(" 200") || // Apple iCloud returns 200 status for each item
+ resp.status.indexOf(" 201"))
+ ) {
+ // and status 201 and 204 the same
+ this.itemsReported[resp.href] = resp.getetag;
+ let itemId = this.calendar.mHrefIndex[resp.href];
+ let oldEtag = itemId && this.calendar.mItemInfoCache[itemId].etag;
+
+ if (!oldEtag || oldEtag != resp.getetag) {
+ // Etag mismatch, getting new/updated item.
+ this.itemsNeedFetching.push(resp.href);
+ }
+ } else if (resp.status && resp.status.includes(" 507")) {
+ // webdav-sync says that if a 507 is encountered and the
+ // url matches the request, the current token should be
+ // saved and another request should be made. We don't
+ // actually compare the URL, its too easy to get this
+ // wrong.
+
+ // The 507 doesn't mean the data received is invalid, so
+ // continue processing.
+ this.additionalSyncNeeded = true;
+ } else if (
+ resp.status &&
+ resp.status.indexOf(" 200") &&
+ resp.href &&
+ resp.href.endsWith("/")
+ ) {
+ // iCloud returns status responses for directories too
+ // so we just ignore them if they have status code 200. We
+ // want to make sure these are not counted as unhandled
+ // errors in the next block
+ } else if (
+ (resp.getcontenttype && resp.getcontenttype.startsWith("text/calendar")) ||
+ (resp.status && !resp.status.includes(" 404"))
+ ) {
+ // If the response element is still not handled, log an
+ // error only if the content-type is text/calendar or the
+ // response status is different than 404 not found. We
+ // don't care about response elements on non-calendar
+ // resources or whose status is not indicating a deleted
+ // resource.
+ cal.WARN("CalDAV: Unexpected response, status: " + resp.status + ", href: " + resp.href);
+ this.unhandledErrors++;
+ } else {
+ cal.LOG(
+ "CalDAV: Unhandled response element, status: " +
+ resp.status +
+ ", href: " +
+ resp.href +
+ " contenttype:" +
+ resp.getcontenttype
+ );
+ }
+ break;
+ }
+ case "sync-token": {
+ this.newSyncToken = this.currentResponse[this.tag];
+ break;
+ }
+ case "propstat": {
+ this.isInPropStat = false;
+ break;
+ }
+ }
+ this.tag = null;
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
+
+/**
+ * This is a handler for the multiget request. It uses XMLResponseHandler to
+ * parse the items and compose the resulting multiget.
+ */
+class CalDavMultigetSyncHandler extends XMLResponseHandler {
+ /**
+ * @param {string[]} aItemsNeedFetching - Array of items to fetch, an array of
+ * un-encoded paths.
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aNewSyncToken - (optional) New Sync token to set if operation successful.
+ * @param {boolean=} aAdditionalSyncNeeded - (optional) If true, the passed sync token is not the
+ * latest, another webdav sync run should be
+ * done after completion.
+ * @param {*=} aListener - (optional) The listener to notify.
+ * @param {*=} aChangeLogListener - (optional) For cached calendars, the listener to
+ * notify.
+ */
+ constructor(
+ aItemsNeedFetching,
+ aCalendar,
+ aBaseUri,
+ aNewSyncToken,
+ aAdditionalSyncNeeded,
+ aListener,
+ aChangeLogListener
+ ) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.listener = aListener;
+ this.newSyncToken = aNewSyncToken;
+ this.changeLogListener = aChangeLogListener;
+ this.itemsNeedFetching = aItemsNeedFetching;
+ this.additionalSyncNeeded = aAdditionalSyncNeeded;
+ }
+
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ newSyncToken = null;
+ listener = null;
+ changeLogListener = null;
+ logXML = null;
+ unhandledErrors = 0;
+ itemsNeedFetching = null;
+ additionalSyncNeeded = false;
+ timer = null;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ doMultiGet() {
+ if (this.calendar.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.calendar.checkDavResourceType(this.changeLogListener);
+ return;
+ }
+
+ let batchSize = Services.prefs.getIntPref("calendar.caldav.multigetBatchSize", 100);
+ let hrefString = "";
+ while (this.itemsNeedFetching.length && batchSize > 0) {
+ batchSize--;
+ // ensureEncodedPath extracts only the path component of the item and
+ // encodes it before it is sent to the server
+ let locpath = this.calendar.ensureEncodedPath(this.itemsNeedFetching.pop());
+ hrefString += "<D:href>" + cal.xml.escapeString(locpath) + "</D:href>";
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
+ "<D:prop>" +
+ "<D:getetag/>" +
+ "<C:calendar-data/>" +
+ "</D:prop>" +
+ hrefString +
+ "</C:calendar-multiget>";
+
+ let requestUri = this.calendar.makeUri(null, this.baseUri);
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`);
+ }
+
+ let onSetupChannel = channel => {
+ channel.requestMethod = "REPORT";
+ channel.setRequestHeader("Depth", "1", false);
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.calendar.session,
+ this.calendar,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ this,
+ onSetupChannel
+ );
+
+ request.commit().catch(() => {
+ // Something went wrong with the OAuth token, notify failure
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ }
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ } else {
+ let errorMsg =
+ "CalDAV: Error: got status " +
+ responseStatus +
+ " fetching calendar data for " +
+ this.calendar.name +
+ ", " +
+ this.listener;
+ this.calendar.notifyGetFailed(errorMsg, this.listener, this.changeLogListener);
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus != 207) {
+ // Not a successful response, do nothing.
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ if (this.unhandledErrors) {
+ this.calendar.superCalendar.endBatch();
+ this.calendar.notifyGetFailed("multiget error", this.listener, this.changeLogListener);
+ return;
+ }
+ if (this.itemsNeedFetching.length == 0) {
+ if (this.newSyncToken) {
+ this.calendar.mWebdavSyncToken = this.newSyncToken;
+ this.calendar.saveCalendarProperties();
+ cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+ }
+ }
+ await this.handleResponse();
+ if (this.itemsNeedFetching.length > 0) {
+ cal.LOG("CalDAV: Still need to fetch " + this.itemsNeedFetching.length + " elements.");
+ this.resetXMLResponseHandler();
+ let timerCallback = {
+ requestHandler: this,
+ notify(timer) {
+ // Call multiget again to get another batch
+ this.requestHandler.doMultiGet();
+ },
+ };
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this.timer.initWithCallback(timerCallback, 0, Ci.nsITimer.TYPE_ONE_SHOT);
+ } else if (this.additionalSyncNeeded) {
+ let wds = new CalDavWebDavSyncHandler(this.calendar, this.baseUri, this.changeLogListener);
+ wds.doWebDAVSync();
+ } else {
+ this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri);
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError(error) {
+ cal.WARN("CalDAV: Fatal Error doing multiget for " + this.calendar.name + ": " + error);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ if (this.tag) {
+ this.currentResponse[this.tag] += aValue;
+ }
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ this.logXML = "";
+ this.calendar.superCalendar.startBatch();
+ }
+
+ endDocument() {
+ this.calendar.superCalendar.endBatch();
+ }
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response":
+ this.currentResponse = {};
+ this.tag = null;
+ this.isInPropStat = false;
+ break;
+ case "propstat":
+ this.isInPropStat = true;
+ break;
+ case "status":
+ if (this.isInPropStat) {
+ this.tag = "propstat_" + aLocalName;
+ } else {
+ this.tag = aLocalName;
+ }
+ this.currentResponse[this.tag] = "";
+ break;
+ case "calendar-data":
+ case "href":
+ case "getetag":
+ this.tag = aLocalName.replace(/-/g, "");
+ this.currentResponse[this.tag] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ async endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": {
+ let resp = this.currentResponse;
+ if (resp.href && resp.href.length) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+ }
+ if (
+ resp.href &&
+ resp.href.length &&
+ resp.status &&
+ resp.status.length &&
+ resp.status.indexOf(" 404") > 0
+ ) {
+ if (this.calendar.mHrefIndex[resp.href]) {
+ await this.calendar.deleteTargetCalendarItem(resp.href);
+ } else {
+ cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href);
+ }
+ // Created or Updated item
+ } else if (
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ resp.calendardata &&
+ resp.calendardata.length
+ ) {
+ let oldEtag;
+ let itemId = this.calendar.mHrefIndex[resp.href];
+ if (itemId) {
+ oldEtag = this.calendar.mItemInfoCache[itemId].etag;
+ } else {
+ oldEtag = null;
+ }
+ if (!oldEtag || oldEtag != resp.getetag || this.listener) {
+ await this.calendar.addTargetCalendarItem(
+ resp.href,
+ resp.calendardata,
+ this.baseUri,
+ resp.getetag,
+ this.listener
+ );
+ } else {
+ cal.LOG("CalDAV: skipping item with unmodified etag : " + oldEtag);
+ }
+ } else {
+ cal.WARN(
+ "CalDAV: Unexpected response, status: " +
+ resp.status +
+ ", href: " +
+ resp.href +
+ " calendar-data:\n" +
+ resp.calendardata
+ );
+ this.unhandledErrors++;
+ }
+ break;
+ }
+ case "propstat": {
+ this.isInPropStat = false;
+ break;
+ }
+ }
+ this.tag = null;
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
diff --git a/comm/calendar/providers/caldav/modules/CalDavSession.jsm b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
new file mode 100644
index 0000000000..c94bfdaff7
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
@@ -0,0 +1,573 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm");
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "OAuth2Providers", "resource:///modules/OAuth2Providers.jsm");
+
+/**
+ * Session and authentication tools for the caldav provider
+ */
+
+const EXPORTED_SYMBOLS = ["CalDavDetectionSession", "CalDavSession"];
+/* exported CalDavDetectionSession, CalDavSession */
+
+const OAUTH_GRACE_TIME = 30 * 1000;
+
+class CalDavOAuth extends OAuth2 {
+ /**
+ * Returns true if the token has expired, or will expire within the grace time.
+ */
+ get tokenExpired() {
+ let now = new Date().getTime();
+ return this.tokenExpires - OAUTH_GRACE_TIME < now;
+ }
+
+ /**
+ * Retrieves the refresh token from the password manager. The token is cached.
+ */
+ get refreshToken() {
+ cal.ASSERT(this.id, `This ${this.constructor.name} object has no id.`);
+ if (!this._refreshToken) {
+ let pass = { value: null };
+ try {
+ cal.auth.passwordManagerGet(this.id, pass, this.origin, this.pwMgrId);
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = pass.value;
+ }
+ return this._refreshToken;
+ }
+
+ /**
+ * Saves the refresh token in the password manager
+ *
+ * @param {string} aVal - The value to set
+ */
+ set refreshToken(aVal) {
+ try {
+ if (aVal) {
+ cal.auth.passwordManagerSave(this.id, aVal, this.origin, this.pwMgrId);
+ } else {
+ cal.auth.passwordManagerRemove(this.id, this.origin, this.pwMgrId);
+ }
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = aVal;
+ }
+
+ /**
+ * Wait for the calendar window to appear.
+ *
+ * This is a workaround for bug 901329: If the calendar window isn't loaded yet the master
+ * password prompt will show just the buttons and possibly hang. If we postpone until the window
+ * is loaded, all is well.
+ *
+ * @returns {Promise} A promise resolved without value when the window is loaded
+ */
+ waitForCalendarWindow() {
+ return new Promise(resolve => {
+ // eslint-disable-next-line func-names, require-jsdoc
+ function postpone() {
+ let win = cal.window.getCalendarWindow();
+ if (!win || win.document.readyState != "complete") {
+ setTimeout(postpone, 0);
+ } else {
+ resolve();
+ }
+ }
+ setTimeout(postpone, 0);
+ });
+ }
+
+ /**
+ * Promisified version of |connect|, using all means necessary to gracefully display the
+ * authentication prompt.
+ *
+ * @param {boolean} aWithUI - If UI should be shown for authentication
+ * @param {boolean} aRefresh - Force refresh the token TODO default false
+ * @returns {Promise} A promise resolved when the OAuth process is completed
+ */
+ promiseConnect(aWithUI = true, aRefresh = true) {
+ return this.waitForCalendarWindow().then(() => {
+ return new Promise((resolve, reject) => {
+ let self = this;
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(this.id, false, {
+ onPromptStartAsync(callback) {
+ this.onPromptAuthAvailable(callback);
+ },
+
+ onPromptAuthAvailable(callback) {
+ self.connect(
+ () => {
+ if (callback) {
+ callback.onAuthResult(true);
+ }
+ resolve();
+ },
+ () => {
+ if (callback) {
+ callback.onAuthResult(false);
+ }
+ reject();
+ },
+ aWithUI,
+ aRefresh
+ );
+ },
+ onPromptCanceled: reject,
+ onPromptStart() {},
+ });
+ });
+ });
+ }
+
+ /**
+ * Prepare the given channel for an OAuth request
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ */
+ async prepareRequest(aChannel) {
+ if (!this.accessToken || this.tokenExpired) {
+ // The token has expired, we need to reauthenticate first
+ cal.LOG("CalDAV: OAuth token expired or empty, refreshing");
+ await this.promiseConnect();
+ }
+
+ let hdr = "Bearer " + this.accessToken;
+ aChannel.setRequestHeader("Authorization", hdr, false);
+ }
+
+ /**
+ * Prepare the redirect, copying the auth header to the new channel
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ try {
+ let hdrValue = aOldChannel.getRequestHeader("Authorization");
+ if (hdrValue) {
+ aNewChannel.setRequestHeader("Authorization", hdrValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Check for OAuth auth errors and restart the request without a token if necessary
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ // Check for OAuth errors
+ let wwwauth = aResponse.getHeader("WWW-Authenticate");
+ if (this.oauth && wwwauth && wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) {
+ this.oauth.accessToken = null;
+
+ return CalDavSession.RESTART_REQUEST;
+ }
+ return null;
+ }
+}
+
+/**
+ * Authentication provider for Google's OAuth.
+ */
+class CalDavGoogleOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Google OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.googleapis.com/auth/calendar", {
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/auth",
+ tokenEndpoint: "https://www.googleapis.com/oauth2/v3/token",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Google CalDAV v2";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Google CalDAV v2", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("accounts.google.com");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://accounts.google.com";
+ this.pwMgrId = "https://www.googleapis.com/auth/calendar";
+ }
+ }
+}
+
+/**
+ * Authentication provider for Fastmail's OAuth.
+ */
+class CalDavFastmailOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Fastmail OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.fastmail.com/dev/protocol-caldav", {
+ authorizationEndpoint: "https://api.fastmail.com/oauth/authorize",
+ tokenEndpoint: "https://api.fastmail.com/oauth/refresh",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ usePKCE: true,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Fastmail CalDAV";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Fastmail CalDAV", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("www.fastmail.com");
+ this.clientId = issuerDetails.clientId;
+
+ this.origin = "oauth://www.fastmail.com";
+ this.pwMgrId = "https://www.fastmail.com/dev/protocol-caldav";
+ }
+ }
+}
+
+/**
+ * A modified version of CalDavGoogleOAuth for testing. This class mimics the
+ * real class as closely as possible.
+ */
+class CalDavTestOAuth extends CalDavGoogleOAuth {
+ constructor(sessionId, name) {
+ super(sessionId, name);
+
+ // Override these values with test values.
+ this.authorizationEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs";
+ this.tokenEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/token.sjs";
+ this.scope = "test_scope";
+ this.clientId = "test_client_id";
+ this.consumerSecret = "test_scope";
+
+ // I don't know why, but tests refuse to work with a plain HTTP endpoint
+ // (the request is redirected to HTTPS, which we're not listening to).
+ // Just use an HTTPS endpoint.
+ this.redirectionEndpoint = "https://localhost";
+ }
+
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("mochi.test");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://mochi.test";
+ this.pwMgrId = "test_scope";
+ }
+ }
+}
+
+/**
+ * A session for the caldav provider. Two or more calendars can share a session if they have the
+ * same auth credentials.
+ */
+class CalDavSession {
+ QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]);
+
+ /**
+ * Dictionary of hostname => auth adapter. Before a request is made to a hostname
+ * in the dictionary, the auth adapter will be called to modify the request.
+ */
+ authAdapters = {};
+
+ /**
+ * Constant returned by |completeRequest| when the request should be restarted
+ *
+ * @returns {number} The constant
+ */
+ static get RESTART_REQUEST() {
+ return 1;
+ }
+
+ /**
+ * Creates a new caldav session
+ *
+ * @param {string} aSessionId - The session id, used in the password manager
+ * @param {string} aName - The user-readable description of this session
+ */
+ constructor(aSessionId, aName) {
+ this.id = aSessionId;
+ this.name = aName;
+
+ // Only create an auth adapter if we're going to use it.
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "apidata.googleusercontent.com",
+ () => new CalDavGoogleOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "caldav.fastmail.com",
+ () => new CalDavFastmailOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "mochi.test",
+ () => new CalDavTestOAuth(aSessionId, aName)
+ );
+ }
+
+ /**
+ * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of
+ * the session may.
+ *
+ * @param {nsIIDRef} aIID - The IID of the interface being requested
+ * @returns {?*} Either this object QI'd to the IID, or null.
+ * Components.returnCode is set accordingly.
+ */
+ getInterface(aIID) {
+ try {
+ // Try to query the this object for the requested interface but don't
+ // throw if it fails since that borks the network code.
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ Components.returnCode = e;
+ }
+
+ return null;
+ }
+
+ /**
+ * Calls the auth adapter for the given host in case it exists. This allows delegating auth
+ * preparation based on the host, e.g. for OAuth.
+ *
+ * @param {string} aHost - The host to check the auth adapter for
+ * @param {string} aMethod - The method to call
+ * @param {...*} aArgs - Remaining args specific to the adapted method
+ * @returns {*} Return value specific to the adapter method
+ */
+ async _callAdapter(aHost, aMethod, ...aArgs) {
+ let adapter = this.authAdapters[aHost] || null;
+ if (adapter) {
+ return adapter[aMethod](...aArgs);
+ }
+ return null;
+ }
+
+ /**
+ * Prepare the channel for a request, e.g. setting custom authentication headers
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRequest(aChannel) {
+ return this._callAdapter(aChannel.URI.host, "prepareRequest", aChannel);
+ }
+
+ /**
+ * Prepare the given new channel for a redirect, e.g. copying headers.
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ return this._callAdapter(aNewChannel.URI.host, "prepareRedirect", aOldChannel, aNewChannel);
+ }
+
+ /**
+ * Complete the request based on the results from the response. Allows restarting the session if
+ * |CalDavSession.RESTART_REQUEST| is returned.
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ return this._callAdapter(aResponse.request.uri.host, "completeRequest", aResponse);
+ }
+}
+
+/**
+ * A session used to detect a caldav provider when subscribing to a network calendar.
+ *
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIAuthPromptProvider}
+ * @implements {nsIInterfaceRequestor}
+ */
+class CalDavDetectionSession extends CalDavSession {
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIAuthPrompt2,
+ Ci.nsIAuthPromptProvider,
+ Ci.nsIInterfaceRequestor,
+ ]);
+
+ isDetectionSession = true;
+
+ /**
+ * Create a new caldav detection session.
+ *
+ * @param {string} aUserName - The username for the session.
+ * @param {string} aPassword - The password for the session.
+ * @param {boolean} aSavePassword - Whether to save the password.
+ */
+ constructor(aUserName, aPassword, aSavePassword) {
+ super(aUserName, aUserName);
+ this.password = aPassword;
+ this.savePassword = aSavePassword;
+ }
+
+ /**
+ * Returns a plain (non-autodect) caldav session based on this session.
+ *
+ * @returns {CalDavSession} A caldav session.
+ */
+ toBaseSession() {
+ return new CalDavSession(this.id, this.name);
+ }
+
+ /**
+ * @see {nsIAuthPromptProvider}
+ */
+ getAuthPrompt(aReason, aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ setTimeout(() => {
+ if (this.promptAuth(aChannel, aLevel, aAuthInfo)) {
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ } else {
+ aCallback.onAuthCancelled(aContext, true);
+ }
+ });
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ if (!this.password) {
+ return false;
+ }
+
+ if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) {
+ aAuthInfo.username = this.name;
+ aAuthInfo.password = this.password;
+
+ if (this.savePassword) {
+ cal.auth.passwordManagerSave(
+ this.name,
+ this.password,
+ aChannel.URI.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return true;
+ }
+
+ aAuthInfo.username = null;
+ aAuthInfo.password = null;
+ if (this.savePassword) {
+ cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm);
+ }
+ return false;
+ }
+}
+
+// Before you spend time trying to find out what this means, please note that
+// doing so and using the information WILL cause Google to revoke Lightning's
+// privileges, which means not one Lightning user will be able to connect to
+// Google Calendar via CalDAV. This will cause unhappy users all around which
+// means that the Lightning developers will have to spend more time with user
+// support, which means less time for features, releases and bugfixes. For a
+// paid developer this would actually mean financial harm.
+//
+// Do you really want all of this to be your fault? Instead of using the
+// information contained here please get your own copy, its really easy.
+/* eslint-disable */
+// prettier-ignore
+(zqdx=>{zqdx["\x65\x76\x61\x6C"](zqdx["\x41\x72\x72\x61\x79"]["\x70\x72\x6F\x74"+
+"\x6F\x74\x79\x70\x65"]["\x6D\x61\x70"]["\x63\x61\x6C\x6C"]("uijt/PBVUI`CBTF`VS"+
+"J>#iuuqt;00bddpvout/hpphmf/dpn0p0#<uijt/PBVUI`TDPQF>#iuuqt;00xxx/hpphmfbqjt/dp"+
+"n0bvui0dbmfoebs#<uijt/PBVUI`DMJFOU`JE>#831674:95649/bqqt/hpphmfvtfsdpoufou/dpn"+
+"#<uijt/PBVUI`IBTI>#zVs7YVgyvsbguj7s8{1TTfJR#<",_=>zqdx["\x53\x74\x72\x69\x6E"+
+"\x67"]["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65"](_["\x63\x68\x61\x72"+
+"\x43\x6F\x64\x65\x41\x74"](0)-1),this)[""+"\x6A\x6F\x69\x6E"](""))})["\x63\x61"+
+"\x6C\x6C"]((this),Components["\x75\x74\x69\x6c\x73"]["\x67\x65\x74\x47\x6c\x6f"+
+"\x62\x61\x6c\x46\x6f\x72\x4f\x62\x6a\x65\x63\x74"](this))
+/* eslint-enable */
diff --git a/comm/calendar/providers/caldav/modules/CalDavUtils.jsm b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm
new file mode 100644
index 0000000000..63b50b7fb3
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Various utility functions for the caldav provider
+ */
+
+/* exported CalDavXmlns, CalDavTagsToXmlns, CalDavNsUnresolver, CalDavNsResolver, CalDavXPath,
+ * CalDavXPathFirst */
+const EXPORTED_SYMBOLS = [
+ "CalDavXmlns",
+ "CalDavTagsToXmlns",
+ "CalDavNsUnresolver",
+ "CalDavNsResolver",
+ "CalDavXPath",
+ "CalDavXPathFirst",
+];
+
+/**
+ * Creates an xmlns string with the requested namespace prefixes
+ *
+ * @param {...string} aRequested - The requested namespace prefixes
+ * @returns {string} An xmlns string that can be inserted into xml documents
+ */
+function CalDavXmlns(...aRequested) {
+ let namespaces = [];
+ for (let namespace of aRequested) {
+ let nsUri = CalDavNsResolver(namespace);
+ if (namespace) {
+ namespaces.push(`xmlns:${namespace}='${nsUri}'`);
+ }
+ }
+
+ return namespaces.join(" ");
+}
+
+/**
+ * Helper function to gather namespaces from QNames or namespace prefixes, plus a few extra for the
+ * remaining request.
+ *
+ * @param {...string} aTags - Either QNames, or just namespace prefixes to be resolved.
+ * @returns {string} The complete namespace string
+ */
+function CalDavTagsToXmlns(...aTags) {
+ let namespaces = new Set(aTags.map(tag => tag.split(":")[0]));
+ return CalDavXmlns(...namespaces.values());
+}
+
+/**
+ * Resolve the namespace URI to one of the prefixes used in our codebase
+ *
+ * @param {string} aNamespace - The namespace URI to resolve
+ * @returns {?string} The namespace prefix we use
+ */
+function CalDavNsUnresolver(aNamespace) {
+ const prefixes = {
+ "http://apple.com/ns/ical/": "A",
+ "DAV:": "D",
+ "urn:ietf:params:xml:ns:caldav": "C",
+ "http://calendarserver.org/ns/": "CS",
+ };
+ return prefixes[aNamespace] || null;
+}
+
+/**
+ * Resolve the namespace URI from one of the prefixes used in our codebase
+ *
+ * @param {string} aPrefix - The namespace prefix we use
+ * @returns {?string} The namespace URI for the prefix
+ */
+function CalDavNsResolver(aPrefix) {
+ /* eslint-disable id-length */
+ const namespaces = {
+ A: "http://apple.com/ns/ical/",
+ D: "DAV:",
+ C: "urn:ietf:params:xml:ns:caldav",
+ CS: "http://calendarserver.org/ns/",
+ };
+ /* eslint-enable id-length */
+
+ return namespaces[aPrefix] || null;
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver
+ *
+ * @param {Element} aNode - The context node to search from
+ * @param {string} aExpr - The XPath expression to search for
+ * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant
+ * @returns {Element[]} Array of found elements
+ */
+function CalDavXPath(aNode, aExpr, aType) {
+ return cal.xml.evalXPath(aNode, aExpr, CalDavNsResolver, aType);
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver. Returns the first
+ * result.
+ *
+ * @param {Element} aNode - The context node to search from
+ * @param {string} aExpr - The XPath expression to search for
+ * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant
+ * @returns {?Element} The found element, or null.
+ */
+function CalDavXPathFirst(aNode, aExpr, aType) {
+ return cal.xml.evalXPathFirst(aNode, aExpr, CalDavNsResolver, aType);
+}
diff --git a/comm/calendar/providers/caldav/moz.build b/comm/calendar/providers/caldav/moz.build
new file mode 100644
index 0000000000..eecaa153ab
--- /dev/null
+++ b/comm/calendar/providers/caldav/moz.build
@@ -0,0 +1,25 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += ["public"]
+
+EXTRA_JS_MODULES += [
+ "CalDavCalendar.jsm",
+ "CalDavProvider.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXTRA_JS_MODULES.caldav += [
+ "modules/CalDavRequest.jsm",
+ "modules/CalDavRequestHandlers.jsm",
+ "modules/CalDavSession.jsm",
+ "modules/CalDavUtils.jsm",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: CalDAV")
diff --git a/comm/calendar/providers/caldav/public/calICalDavCalendar.idl b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl
new file mode 100644
index 0000000000..c9533470df
--- /dev/null
+++ b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl
@@ -0,0 +1,20 @@
+/* -*- Mode: C++; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "calICalendar.idl"
+#include "calIOperation.idl"
+
+
+/** Adds CalDAV specific capabilities to calICalendar.
+ */
+[scriptable, uuid(88F6FB22-C172-11DC-A8D1-00197EA74E11)]
+interface calICalDavCalendar : calICalendar
+{
+ /**
+ * The calendar's RFC 2617 authentication realm
+ */
+ readonly attribute AUTF8String authRealm;
+
+};
diff --git a/comm/calendar/providers/caldav/public/moz.build b/comm/calendar/providers/caldav/public/moz.build
new file mode 100644
index 0000000000..e8c0600501
--- /dev/null
+++ b/comm/calendar/providers/caldav/public/moz.build
@@ -0,0 +1,10 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPIDL_SOURCES += [
+ "calICalDavCalendar.idl",
+]
+
+XPIDL_MODULE = "caldav"
diff --git a/comm/calendar/providers/composite/CalCompositeCalendar.jsm b/comm/calendar/providers/composite/CalCompositeCalendar.jsm
new file mode 100644
index 0000000000..65c1e3e2f2
--- /dev/null
+++ b/comm/calendar/providers/composite/CalCompositeCalendar.jsm
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalCompositeCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Calendar specific utility functions
+ */
+
+function calCompositeCalendarObserverHelper(compCalendar) {
+ this.compCalendar = compCalendar;
+}
+
+calCompositeCalendarObserverHelper.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ onStartBatch(calendar) {
+ this.compCalendar.mObservers.notify("onStartBatch", [calendar]);
+ },
+
+ onEndBatch(calendar) {
+ this.compCalendar.mObservers.notify("onEndBatch", [calendar]);
+ },
+
+ onLoad(calendar) {
+ this.compCalendar.mObservers.notify("onLoad", [calendar]);
+ },
+
+ onAddItem(aItem) {
+ this.compCalendar.mObservers.notify("onAddItem", arguments);
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.compCalendar.mObservers.notify("onModifyItem", arguments);
+ },
+
+ onDeleteItem(aDeletedItem) {
+ this.compCalendar.mObservers.notify("onDeleteItem", arguments);
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.compCalendar.mObservers.notify("onError", arguments);
+ },
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.compCalendar.mObservers.notify("onPropertyChanged", arguments);
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.compCalendar.mObservers.notify("onPropertyDeleting", arguments);
+ },
+};
+
+function CalCompositeCalendar() {
+ this.mObserverHelper = new calCompositeCalendarObserverHelper(this);
+ this.wrappedJSObject = this;
+
+ this.mCalendars = [];
+ this.mCompositeObservers = new cal.data.ObserverSet(Ci.calICompositeObserver);
+ this.mObservers = new cal.data.ObserverSet(Ci.calIObserver);
+ this.mDefaultCalendar = null;
+ this.mStatusObserver = null;
+}
+
+var calCompositeCalendarClassID = Components.ID("{aeff788d-63b0-4996-91fb-40a7654c6224}");
+var calCompositeCalendarInterfaces = ["calICalendar", "calICompositeCalendar"];
+CalCompositeCalendar.prototype = {
+ classID: calCompositeCalendarClassID,
+ QueryInterface: ChromeUtils.generateQI(calCompositeCalendarInterfaces),
+
+ //
+ // calICompositeCalendar interface
+ //
+
+ mCalendars: null,
+ mDefaultCalendar: null,
+ mPrefPrefix: null,
+ mDefaultPref: null,
+ mActivePref: null,
+
+ get enabledCalendars() {
+ return this.mCalendars.filter(e => !e.getProperty("disabled"));
+ },
+
+ set prefPrefix(aPrefPrefix) {
+ if (this.mPrefPrefix) {
+ for (let calendar of this.mCalendars) {
+ this.removeCalendar(calendar);
+ }
+ }
+
+ this.mPrefPrefix = aPrefPrefix;
+ this.mActivePref = aPrefPrefix + "-in-composite";
+ this.mDefaultPref = aPrefPrefix + "-default";
+ let cals = cal.manager.getCalendars();
+
+ cals.forEach(function (calendar) {
+ if (calendar.getProperty(this.mActivePref)) {
+ this.addCalendar(calendar);
+ }
+ if (calendar.getProperty(this.mDefaultPref)) {
+ this.setDefaultCalendar(calendar, false);
+ }
+ }, this);
+ },
+
+ get prefPrefix() {
+ return this.mPrefPrefix;
+ },
+
+ addCalendar(aCalendar) {
+ cal.ASSERT(aCalendar.id, "calendar does not have an id!", true);
+
+ // check if the calendar already exists
+ if (this.getCalendarById(aCalendar.id)) {
+ return;
+ }
+
+ // add our observer helper
+ aCalendar.addObserver(this.mObserverHelper);
+
+ this.mCalendars.push(aCalendar);
+ if (this.mPrefPrefix) {
+ aCalendar.setProperty(this.mActivePref, true);
+ }
+ this.mCompositeObservers.notify("onCalendarAdded", [aCalendar]);
+
+ // if we have no default calendar, we need one here
+ if (this.mDefaultCalendar == null && !aCalendar.getProperty("disabled")) {
+ this.setDefaultCalendar(aCalendar, false);
+ }
+ },
+
+ removeCalendar(aCalendar) {
+ let id = aCalendar.id;
+ let newCalendars = this.mCalendars.filter(calendar => calendar.id != id);
+ if (newCalendars.length != this.mCalendars) {
+ this.mCalendars = newCalendars;
+ if (this.mPrefPrefix) {
+ aCalendar.deleteProperty(this.mActivePref);
+ aCalendar.deleteProperty(this.mDefaultPref);
+ }
+ aCalendar.removeObserver(this.mObserverHelper);
+ this.mCompositeObservers.notify("onCalendarRemoved", [aCalendar]);
+ }
+ },
+
+ getCalendarById(aId) {
+ for (let calendar of this.mCalendars) {
+ if (calendar.id == aId) {
+ return calendar;
+ }
+ }
+ return null;
+ },
+
+ getCalendars() {
+ return this.mCalendars;
+ },
+
+ get defaultCalendar() {
+ return this.mDefaultCalendar;
+ },
+
+ setDefaultCalendar(calendar, usePref) {
+ // Don't do anything if the passed calendar is the default calendar
+ if (calendar && this.mDefaultCalendar && this.mDefaultCalendar.id == calendar.id) {
+ return;
+ }
+ if (usePref && this.mPrefPrefix) {
+ if (this.mDefaultCalendar) {
+ this.mDefaultCalendar.deleteProperty(this.mDefaultPref);
+ }
+ // if not null set the new calendar as default in the preferences
+ if (calendar) {
+ calendar.setProperty(this.mDefaultPref, true);
+ }
+ }
+ this.mDefaultCalendar = calendar;
+ this.mCompositeObservers.notify("onDefaultCalendarChanged", [calendar]);
+ },
+
+ set defaultCalendar(calendar) {
+ this.setDefaultCalendar(calendar, true);
+ },
+
+ //
+ // calICalendar interface
+ //
+ // Write operations here are forwarded to either the item's
+ // parent calendar, or to the default calendar if one is set.
+ // Get operations are sent to each calendar.
+ //
+
+ get id() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set id(id) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get superCalendar() {
+ // There shouldn't be a superCalendar for the composite
+ return this;
+ },
+ set superCalendar(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ // this could, at some point, return some kind of URI identifying
+ // all the child calendars, thus letting us create nifty calendar
+ // trees.
+ get uri() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set uri(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get readOnly() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set readOnly(bool) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get canRefresh() {
+ return true;
+ },
+
+ get name() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set name(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get type() {
+ return "composite";
+ },
+
+ getProperty(aName) {
+ return this.mDefaultCalendar.getProperty(aName);
+ },
+
+ get supportsScheduling() {
+ return false;
+ },
+
+ getSchedulingSupport() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ setProperty(aName, aValue) {
+ return this.mDefaultCalendar.setProperty(aName, aValue);
+ },
+
+ deleteProperty(aName) {
+ return this.mDefaultCalendar.deleteProperty(aName);
+ },
+
+ // void addObserver( in calIObserver observer );
+ mCompositeObservers: null,
+ mObservers: null,
+ addObserver(aObserver) {
+ let wrappedCObserver = cal.wrapInstance(aObserver, Ci.calICompositeObserver);
+ if (wrappedCObserver) {
+ this.mCompositeObservers.add(wrappedCObserver);
+ }
+ this.mObservers.add(aObserver);
+ },
+
+ // void removeObserver( in calIObserver observer );
+ removeObserver(aObserver) {
+ let wrappedCObserver = cal.wrapInstance(aObserver, Ci.calICompositeObserver);
+ if (wrappedCObserver) {
+ this.mCompositeObservers.delete(wrappedCObserver);
+ }
+ this.mObservers.delete(aObserver);
+ },
+
+ refresh() {
+ if (this.mStatusObserver) {
+ this.mStatusObserver.startMeteors(
+ Ci.calIStatusObserver.DETERMINED_PROGRESS,
+ this.mCalendars.length
+ );
+ }
+ for (let calendar of this.enabledCalendars) {
+ try {
+ if (calendar.canRefresh) {
+ calendar.refresh();
+ }
+ } catch (e) {
+ cal.ASSERT(false, e);
+ }
+ }
+ // send out a single onLoad for this composite calendar,
+ // although e.g. the ics provider will trigger another
+ // onLoad asynchronously; we cannot rely on every calendar
+ // sending an onLoad:
+ this.mObservers.notify("onLoad", [this]);
+ },
+
+ // Promise<calIItemBase> modifyItem( in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ cal.ASSERT(aNewItem.calendar, "Composite can't modify item with null calendar", true);
+ cal.ASSERT(aNewItem.calendar != this, "Composite can't modify item with this calendar", true);
+
+ return aNewItem.calendar.modifyItem(aNewItem, aOldItem);
+ },
+
+ // Promise<void> deleteItem(in calIItemBase aItem);
+ async deleteItem(aItem) {
+ cal.ASSERT(aItem.calendar, "Composite can't delete item with null calendar", true);
+ cal.ASSERT(aItem.calendar != this, "Composite can't delete item with this calendar", true);
+
+ return aItem.calendar.deleteItem(aItem);
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ addItem(aItem) {
+ return this.mDefaultCalendar.addItem(aItem);
+ },
+
+ // Promise<calIItemBase|null> getItem(in string aId);
+ async getItem(aId) {
+ for (let calendar of this.enabledCalendars) {
+ let item = await calendar.getItem(aId);
+ if (item) {
+ return item;
+ }
+ }
+ return null;
+ },
+
+ // ReadableStream<calItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ // If there are no calendars return early.
+ let enabledCalendars = this.enabledCalendars;
+ if (enabledCalendars.length == 0) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ if (this.mStatusObserver) {
+ if (this.mStatusObserver.spinning == Ci.calIStatusObserver.NO_PROGRESS) {
+ this.mStatusObserver.startMeteors(Ci.calIStatusObserver.UNDETERMINED_PROGRESS, -1);
+ }
+ }
+
+ let compositeCal = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ iterators: [],
+ async start(controller) {
+ for (let calendar of enabledCalendars) {
+ let iterator = cal.iterate.streamValues(
+ calendar.getItems(itemFilter, count, rangeStart, rangeEnd)
+ );
+ this.iterators.push(iterator);
+ for await (let items of iterator) {
+ controller.enqueue(items);
+ }
+
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.calendarCompleted(calendar);
+ }
+ }
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.stopMeteors();
+ }
+ controller.close();
+ },
+
+ async cancel(reason) {
+ for (let iterator of this.iterators) {
+ await iterator.cancel(reason);
+ }
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.stopMeteors();
+ }
+ },
+ }
+ );
+ },
+
+ // Promise<calItemBase[]> getItemsAsArray(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ async getItemsAsArray(itemFilter, count, rangeStart, rangeEnd) {
+ return cal.iterate.streamToArray(this.getItems(itemFilter, count, rangeStart, rangeEnd));
+ },
+
+ startBatch() {
+ this.mCompositeObservers.notify("onStartBatch", [this]);
+ },
+ endBatch() {
+ this.mCompositeObservers.notify("onEndBatch", [this]);
+ },
+
+ get statusDisplayed() {
+ if (this.mStatusObserver) {
+ return this.mStatusObserver.spinning != Ci.calIStatusObserver.NO_PROGRESS;
+ }
+ return false;
+ },
+
+ setStatusObserver(aStatusObserver, aWindow) {
+ this.mStatusObserver = aStatusObserver;
+ if (this.mStatusObserver) {
+ this.mStatusObserver.initialize(aWindow);
+ }
+ },
+};
diff --git a/comm/calendar/providers/composite/components.conf b/comm/calendar/providers/composite/components.conf
new file mode 100644
index 0000000000..3f75bf3500
--- /dev/null
+++ b/comm/calendar/providers/composite/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{aeff788d-63b0-4996-91fb-40a7654c6224}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=composite'],
+ 'jsm': 'resource:///modules/CalCompositeCalendar.jsm',
+ 'constructor': 'CalCompositeCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/composite/moz.build b/comm/calendar/providers/composite/moz.build
new file mode 100644
index 0000000000..9009560429
--- /dev/null
+++ b/comm/calendar/providers/composite/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "CalCompositeCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/calendar/providers/ics/CalICSCalendar.sys.mjs b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
new file mode 100644
index 0000000000..df5eab830b
--- /dev/null
+++ b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
@@ -0,0 +1,1235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+// This is a non-sync ics file. It reads the file pointer to by uri when set,
+// then writes it on updates. External changes to the file will be
+// ignored and overwritten.
+//
+// XXX Should do locks, so that external changes are not overwritten.
+
+function icsNSResolver(prefix) {
+ const ns = { D: "DAV:" };
+ return ns[prefix] || null;
+}
+
+function icsXPathFirst(aNode, aExpr, aType) {
+ return cal.xml.evalXPathFirst(aNode, aExpr, icsNSResolver, aType);
+}
+
+var calICSCalendarClassID = Components.ID("{f8438bff-a3c9-4ed5-b23f-2663b5469abf}");
+var calICSCalendarInterfaces = [
+ "calICalendar",
+ "calISchedulingSupport",
+ "nsIChannelEventSink",
+ "nsIInterfaceRequestor",
+ "nsIStreamListener",
+ "nsIStreamLoaderObserver",
+];
+
+/**
+ * @implements {calICalendar}
+ * @implements {calISchedulingSupport}
+ * @implements {nsIChannelEventSink}
+ * @implements {nsIInterfaceRequestor}
+ * @implements {nsIStreamListener}
+ * @implements {nsIStreamLoaderObserver}
+ */
+export class CalICSCalendar extends cal.provider.BaseClass {
+ classID = calICSCalendarClassID;
+ QueryInterface = cal.generateQI(calICSCalendarInterfaces);
+ classInfo = cal.generateCI({
+ classID: calICSCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=ics",
+ classDescription: "Calendar ICS provider",
+ interfaces: calICSCalendarInterfaces,
+ });
+
+ #hooks = null;
+ #memoryCalendar = null;
+ #modificationActions = [];
+ #observer = null;
+ #uri = null;
+ #locked = false;
+ #unmappedComponents = [];
+ #unmappedProperties = [];
+
+ // Public to allow access by calCachedCalendar
+ _queue = [];
+
+ constructor() {
+ super();
+
+ this.initProviderBase();
+ this.initICSCalendar();
+ }
+
+ initICSCalendar() {
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+
+ this.#memoryCalendar.superCalendar = this;
+ this.#observer = new calICSObserver(this);
+ this.#memoryCalendar.addObserver(this.#observer); // XXX Not removed
+ }
+
+ //
+ // calICalendar interface
+ //
+ get type() {
+ return "ics";
+ }
+
+ get canRefresh() {
+ return true;
+ }
+
+ get uri() {
+ return this.#uri;
+ }
+
+ set uri(uri) {
+ if (this.#uri?.spec == uri.spec) {
+ return;
+ }
+
+ this.#uri = uri;
+ this.#memoryCalendar.uri = this.#uri;
+
+ if (this.#uri.schemeIs("http") || this.#uri.schemeIs("https")) {
+ this.#hooks = new httpHooks(this);
+ } else if (this.#uri.schemeIs("file")) {
+ this.#hooks = new fileHooks();
+ } else {
+ this.#hooks = new dummyHooks();
+ }
+ }
+
+ getProperty(aName) {
+ switch (aName) {
+ case "requiresNetwork":
+ return !this.uri.schemeIs("file");
+ }
+
+ return super.getProperty(aName);
+ }
+
+ get supportsScheduling() {
+ return true;
+ }
+
+ getSchedulingSupport() {
+ return this;
+ }
+
+ // Always use the queue, just to reduce the amount of places where
+ // this.mMemoryCalendar.addItem() and friends are called. less
+ // copied code.
+ addItem(aItem) {
+ return this.adoptItem(aItem.clone());
+ }
+
+ // Used to allow the cachedCalendar provider to hook into adoptItem() before
+ // it returns.
+ _cachedAdoptItemCallback = null;
+
+ async adoptItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let adoptCallback = this._cachedAdoptItemCallback;
+
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "add",
+ item: aItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (adoptCallback) {
+ await adoptCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.ADD, item.id, item);
+ }
+ return item;
+ }
+
+ // Used to allow the cachedCalendar provider to hook into modifyItem() before
+ // it returns.
+ _cachedModifyItemCallback = null;
+
+ async modifyItem(aNewItem, aOldItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let modifyCallback = this._cachedModifyItemCallback;
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "modify",
+ newItem: aNewItem,
+ oldItem: aOldItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (modifyCallback) {
+ await modifyCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.MODIFY, item.id, item);
+ }
+ return item;
+ }
+
+ /**
+ * Delete the provided item.
+ *
+ * @param {calIItemBase} aItem
+ * @returns {Promise<void>}
+ */
+ deleteItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "delete",
+ item: aItem,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {string} aId
+ * @returns {Promise<calIItemBase?>}
+ */
+ getItem(aId) {
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "get_item",
+ id: aId,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {number} aItemFilter
+ * @param {number} aCount
+ * @param {calIDateTime} aRangeStart
+ * @param {calIDateTime} aRangeEndEx
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx) {
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ aCount,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ start(controller) {
+ self._queue.push({
+ action: "get_items",
+ exec: async () => {
+ for await (let value of cal.iterate.streamValues(
+ self.#memoryCalendar.getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx)
+ )) {
+ controller.enqueue(value);
+ }
+ controller.close();
+ },
+ });
+ self.#processQueue();
+ },
+ }
+ );
+ }
+
+ refresh() {
+ this._queue.push({ action: "refresh", forceRefresh: false });
+ this.#processQueue();
+ }
+
+ startBatch() {
+ this.#observer.onStartBatch(this);
+ }
+
+ endBatch() {
+ this.#observer.onEndBatch(this);
+ }
+
+ #forceRefresh() {
+ this._queue.push({ action: "refresh", forceRefresh: true });
+ this.#processQueue();
+ }
+
+ #prepareChannel(channel, forceRefresh) {
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ // Allow the hook to do its work, like a performing a quick check to
+ // see if the remote file really changed. Might save a lot of time
+ this.#hooks.onBeforeGet(channel, forceRefresh);
+ }
+
+ #createMemoryCalendar() {
+ // Create a new calendar, to get rid of all the old events
+ // Don't forget to remove the observer
+ if (this.#memoryCalendar) {
+ this.#memoryCalendar.removeObserver(this.#observer);
+ }
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+ this.#memoryCalendar.uri = this.#uri;
+ this.#memoryCalendar.superCalendar = this;
+ }
+
+ #doRefresh(force) {
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ this.#prepareChannel(channel, force);
+
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ // Lock other changes to the item list.
+ this.#lock();
+
+ try {
+ streamLoader.init(this);
+ channel.asyncOpen(streamLoader);
+ } catch (e) {
+ // File not found: a new calendar. No problem.
+ cal.LOG("[calICSCalendar] Error occurred opening channel: " + e);
+ this.#unlock();
+ }
+ }
+
+ // nsIChannelEventSink implementation
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ this.#prepareChannel(aNewChannel, true);
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ // nsIStreamLoaderObserver impl
+ // Listener for download. Parse the downloaded file
+
+ onStreamComplete(loader, ctxt, status, resultLength, result) {
+ let cont = false;
+
+ if (Components.isSuccessCode(status)) {
+ // Allow the hook to get needed data (like an etag) of the channel
+ cont = this.#hooks.onAfterGet(loader.request);
+ cal.LOG("[calICSCalendar] Loading ICS succeeded, needs further processing: " + cont);
+ } else {
+ // Failure may be due to temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to load stream - status: " + status);
+
+ // Check for bad server certificates on SSL/TLS connections.
+ cal.provider.checkBadCertStatus(loader.request, status, this);
+ }
+
+ if (!cont) {
+ // no need to process further, we can use the previous data
+ // HACK Sorry, but offline support requires the items to be signaled
+ // even if nothing has changed (especially at startup)
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // Clear any existing events if there was no result
+ if (!resultLength) {
+ this.#createMemoryCalendar();
+ this.#memoryCalendar.addObserver(this.#observer);
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // This conversion is needed, because the stream only knows about
+ // byte arrays, not about strings or encodings. The array of bytes
+ // need to be interpreted as utf8 and put into a javascript string.
+ let str;
+ try {
+ str = new TextDecoder().decode(Uint8Array.from(result));
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.CAL_UTF8_DECODING_FAILED,
+ e.toString()
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ this.#unlock();
+ return;
+ }
+
+ this.#createMemoryCalendar();
+
+ this.#observer.onStartBatch(this);
+ this.#memoryCalendar.addObserver(this.#observer);
+
+ // Wrap parsing in a try block. Will ignore errors. That's a good thing
+ // for non-existing or empty files, but not good for invalid files.
+ // That's why we put them in readOnly mode
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let self = this;
+ let listener = {
+ // calIIcsParsingListener
+ onParsingComplete(rc, parser_) {
+ try {
+ for (let item of parser_.getItems()) {
+ self.#memoryCalendar.adoptItem(item);
+ }
+ self.#unmappedComponents = parser_.getComponents();
+ self.#unmappedProperties = parser_.getProperties();
+ cal.LOG("[calICSCalendar] Parsing ICS succeeded for " + self.uri.spec);
+ } catch (exc) {
+ cal.LOG("[calICSCalendar] Parsing ICS failed for \nException: " + exc);
+ self.#observer.onError(self.superCalendar, exc.result, exc.toString());
+ self.#observer.onError(self.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ }
+ self.#observer.onEndBatch(self);
+ self.#observer.onLoad(self);
+
+ // Now that all items have been stuffed into the memory calendar
+ // we should add ourselves as observer. It is important that this
+ // happens *after* the calls to adoptItem in the above loop to prevent
+ // the views from being notified.
+ self.#unlock();
+ },
+ };
+ parser.parseString(str, listener);
+ }
+
+ async #writeICS() {
+ cal.LOG("[calICSCalendar] Commencing write of ICS Calendar " + this.name);
+ if (!this.#uri) {
+ throw Components.Exception("mUri must be set", Cr.NS_ERROR_FAILURE);
+ }
+ this.#lock();
+ try {
+ await this.#makeBackup();
+ await this.#doWriteICS();
+ } catch (e) {
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ }
+
+ /**
+ * Write the contents of an ICS serializer to an open channel as an ICS file.
+ *
+ * @param {calIIcsSerializer} serializer - The serializer to write
+ * @param {nsIChannel} channel - The destination upload or file channel
+ */
+ async #writeSerializerToChannel(serializer, channel) {
+ if (channel.URI.schemeIs("file")) {
+ // We handle local files separately, as writing to an nsIChannel has the
+ // potential to fail partway and can leave a file truncated, resulting in
+ // data loss. For local files, we have the option to do atomic writes.
+ try {
+ const file = channel.QueryInterface(Ci.nsIFileChannel).file;
+
+ // The temporary file permissions will become the file permissions since
+ // we move the temp file over top of the file itself. Copy the file
+ // permissions or use a restrictive default.
+ const tmpFilePermissions = file.exists() ? file.permissions : 0o600;
+
+ // We're going to be writing to an arbitrary point in the user's file
+ // system, so we want to be very careful that we're not going to
+ // overwrite any of their files.
+ const tmpFilePath = await IOUtils.createUniqueFile(
+ file.parent.path,
+ `${file.leafName}.tmp`,
+ tmpFilePermissions
+ );
+
+ const outString = serializer.serializeToString();
+ await IOUtils.writeUTF8(file.path, outString, {
+ tmpPath: tmpFilePath,
+ });
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ `Failed to write to calendar file ${channel.URI.spec}: ${e.message}`
+ );
+
+ // Writing the file has failed; refresh and signal error to all
+ // modifying operations.
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ this.#forceRefresh();
+
+ return;
+ }
+
+ // Write succeeded and we can clean up. We can reuse the channel, as the
+ // last-modified time on the file will still be accurate.
+ this.#hooks.onAfterPut(channel, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+
+ return;
+ }
+
+ channel.notificationCallbacks = this;
+ let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel);
+
+ // Set the content of the upload channel to our ICS file.
+ let icsStream = serializer.serializeToInputStream();
+ uploadChannel.setUploadStream(icsStream, "text/calendar", -1);
+
+ channel.asyncOpen(this);
+ }
+
+ async #doWriteICS() {
+ cal.LOG("[calICSCalendar] Writing ICS File " + this.uri.spec);
+
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ for (let comp of this.#unmappedComponents) {
+ serializer.addComponent(comp);
+ }
+
+ for (let prop of this.#unmappedProperties) {
+ switch (prop.propertyName) {
+ // we always set the current name and timezone:
+ case "X-WR-CALNAME":
+ case "X-WR-TIMEZONE":
+ break;
+ default:
+ serializer.addProperty(prop);
+ break;
+ }
+ }
+
+ let prop = cal.icsService.createIcalProperty("X-WR-CALNAME");
+ prop.value = this.name;
+ serializer.addProperty(prop);
+ prop = cal.icsService.createIcalProperty("X-WR-TIMEZONE");
+ prop.value = cal.timezoneService.defaultTimezone.tzid;
+ serializer.addProperty(prop);
+
+ // Get items directly from the memory calendar, as we're locked now and
+ // calling this.getItems{,AsArray}() will return immediately
+ serializer.addItems(
+ await this.#memoryCalendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ 0,
+ null,
+ null
+ )
+ );
+
+ let inLastWindowClosingSurvivalArea = false;
+ try {
+ // All events are returned. Now set up a channel and a
+ // streamloader to upload. onStopRequest will be called
+ // once the write has finished
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ // Allow the hook to add things to the channel, like a
+ // header that checks etags
+ let notChanged = this.#hooks.onBeforePut(channel);
+ if (notChanged) {
+ // Prevent Thunderbird from exiting entirely until we've finished
+ // uploading one way or another
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ inLastWindowClosingSurvivalArea = true;
+
+ this.#writeSerializerToChannel(serializer, channel);
+ } else {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ "The calendar has been changed remotely. Please reload and apply your changes again!"
+ );
+
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ } catch (ex) {
+ if (inLastWindowClosingSurvivalArea) {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ }
+
+ this.#observer.onError(
+ this.superCalendar,
+ ex.result,
+ "The calendar could not be saved; there was a failure: 0x" + ex.result.toString(16)
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ this.#forceRefresh();
+ }
+ }
+
+ // nsIStreamListener impl
+ // For after publishing. Do error checks here
+ onStartRequest(aRequest) {}
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ // All data must be consumed. For an upload channel, there is
+ // no meaningful data. So it gets read and then ignored
+ let scriptableInputStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ scriptableInputStream.init(aInputStream);
+ scriptableInputStream.read(-1);
+ }
+
+ onStopRequest(aRequest, aStatusCode) {
+ let httpChannel;
+ let requestSucceeded = false;
+ try {
+ httpChannel = aRequest.QueryInterface(Ci.nsIHttpChannel);
+ requestSucceeded = httpChannel.requestSucceeded;
+ } catch (e) {
+ // This may fail if it was not a http channel, handled later on.
+ }
+
+ if (httpChannel) {
+ cal.LOG("[calICSCalendar] channel.requestSucceeded: " + requestSucceeded);
+ }
+
+ if (
+ (httpChannel && !requestSucceeded) ||
+ (!httpChannel && !Components.isSuccessCode(aRequest.status))
+ ) {
+ this.#observer.onError(
+ this.superCalendar,
+ Components.isSuccessCode(aRequest.status) ? Ci.calIErrors.DAV_PUT_ERROR : aRequest.status,
+ "Publishing the calendar file failed\n" +
+ "Status code: " +
+ aRequest.status.toString(16) +
+ "\n"
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+
+ // The PUT has failed; refresh and signal error to all modifying operations
+ this.#forceRefresh();
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ Services.startup.exitLastWindowClosingSurvivalArea();
+
+ return;
+ }
+
+ // Allow the hook to grab data of the channel, like the new etag
+ this.#hooks.onAfterPut(aRequest, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+ }
+
+ async #processQueue() {
+ if (this._isLocked) {
+ return;
+ }
+
+ let task;
+ let refreshAction = null;
+ while ((task = this._queue.shift())) {
+ switch (task.action) {
+ case "add":
+ this.#lock();
+ this.#memoryCalendar.addItem(task.item).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "modify":
+ this.#lock();
+ this.#memoryCalendar.modifyItem(task.newItem, task.oldItem).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "delete":
+ this.#lock();
+ this.#memoryCalendar.deleteItem(task.item).then(async () => {
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "get_item":
+ this.#memoryCalendar.getItem(task.id).then(task.listener);
+ break;
+ case "get_items":
+ task.exec();
+ break;
+ case "refresh":
+ refreshAction = task;
+ break;
+ }
+
+ if (refreshAction) {
+ cal.LOG(
+ "[calICSCalendar] Refreshing " +
+ this.name +
+ (refreshAction.forceRefresh ? " (forced)" : "")
+ );
+ this.#doRefresh(refreshAction.forceRefresh);
+
+ // break queue processing here and wait for refresh to finish
+ // before processing further operations
+ break;
+ }
+ }
+ }
+
+ #lock() {
+ this.#locked = true;
+ }
+
+ #unlock(errCode) {
+ cal.ASSERT(this.#locked, "unexpected!");
+
+ this.#modificationActions.forEach(action => {
+ let listener = action.listener;
+ if (typeof listener == "function") {
+ listener(action.item);
+ } else if (listener) {
+ let args = action.opCompleteArgs;
+ cal.ASSERT(args, "missing onOperationComplete call!");
+ if (Components.isSuccessCode(args[1]) && errCode && !Components.isSuccessCode(errCode)) {
+ listener.onOperationComplete(args[0], errCode, args[2], args[3], null);
+ } else {
+ listener.onOperationComplete(...args);
+ }
+ }
+ });
+ this.#modificationActions = [];
+
+ this.#locked = false;
+ this.#processQueue();
+ }
+
+ // Visible for testing.
+ get _isLocked() {
+ return this.#locked;
+ }
+
+ /**
+ * @see nsIInterfaceRequestor
+ * @see calProviderUtils.jsm
+ */
+ getInterface = cal.provider.InterfaceRequestor_getInterface;
+
+ /**
+ * Make a backup of the (remote) calendar
+ *
+ * This will download the remote file into the profile dir.
+ * It should be called before every upload, so every change can be
+ * restored. By default, it will keep 3 backups. It also keeps one
+ * file each day, for 3 days. That way, even if the user doesn't notice
+ * the remote calendar has become corrupted, he will still lose max 1
+ * day of work.
+ *
+ * @returns {Promise} A promise that is settled once backup completed.
+ */
+ #makeBackup() {
+ return new Promise((resolve, reject) => {
+ // Uses |pseudoID|, an id of the calendar, defined below
+ function makeName(type) {
+ return "calBackupData_" + pseudoID + "_" + type + ".ics";
+ }
+
+ // This is a bit messy. createUnique creates an empty file,
+ // but we don't use that file. All we want is a filename, to be used
+ // in the call to copyTo later. So we create a file, get the filename,
+ // and never use the file again, but write over it.
+ // Using createUnique anyway, because I don't feel like
+ // re-implementing it
+ function makeDailyFileName() {
+ let dailyBackupFile = backupDir.clone();
+ dailyBackupFile.append(makeName("day"));
+ dailyBackupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+ dailyBackupFileName = dailyBackupFile.leafName;
+
+ // Remove the reference to the nsIFile, because we need to
+ // write over the file later, and you never know what happens
+ // if something still has a reference.
+ // Also makes it explicit that we don't need the file itself,
+ // just the name.
+ dailyBackupFile = null;
+
+ return dailyBackupFileName;
+ }
+
+ function purgeBackupsByType(files, type) {
+ // filter out backups of the type we care about.
+ let filteredFiles = files.filter(file =>
+ file.name.includes("calBackupData_" + pseudoID + "_" + type)
+ );
+ // Sort by lastmodifed
+ filteredFiles.sort((a, b) => a.lastmodified - b.lastmodified);
+ // And delete the oldest files, and keep the desired number of
+ // old backups
+ for (let i = 0; i < filteredFiles.length - numBackupFiles; ++i) {
+ let file = backupDir.clone();
+ file.append(filteredFiles[i].name);
+
+ try {
+ file.remove(false);
+ } catch (ex) {
+ // This can fail because of some crappy code in
+ // nsIFile. That's not the end of the world. We can
+ // try to remove the file the next time around.
+ }
+ }
+ }
+
+ function purgeOldBackups() {
+ // Enumerate files in the backupdir for expiry of old backups
+ let files = [];
+ for (let file of backupDir.directoryEntries) {
+ if (file.isFile()) {
+ files.push({ name: file.leafName, lastmodified: file.lastModifiedTime });
+ }
+ }
+
+ if (doDailyBackup) {
+ purgeBackupsByType(files, "day");
+ } else {
+ purgeBackupsByType(files, "edit");
+ }
+ }
+
+ function copyToOverwriting(oldFile, newParentDir, newName) {
+ try {
+ let newFile = newParentDir.clone();
+ newFile.append(newName);
+
+ if (newFile.exists()) {
+ newFile.remove(false);
+ }
+ oldFile.copyTo(newParentDir, newName);
+ } catch (e) {
+ cal.ERROR("[calICSCalendar] Backup failed, no copy: " + e);
+ // Error in making a daily/initial backup.
+ // not fatal, so just continue
+ }
+ }
+
+ let backupDays = Services.prefs.getIntPref("calendar.backup.days", 1);
+ let numBackupFiles = Services.prefs.getIntPref("calendar.backup.filenum", 3);
+
+ let backupDir;
+ try {
+ backupDir = cal.provider.getCalendarDirectory();
+ backupDir.append("backup");
+ if (!backupDir.exists()) {
+ backupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ }
+ } catch (e) {
+ // Backup dir wasn't found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no backupdir:" + e);
+ resolve();
+ return;
+ }
+
+ let pseudoID;
+ try {
+ pseudoID = this.getProperty("uniquenum2");
+ if (!pseudoID) {
+ pseudoID = new Date().getTime();
+ this.setProperty("uniquenum2", pseudoID);
+ }
+ } catch (e) {
+ // calendarmgr not found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no calendarmanager:" + e);
+ resolve();
+ return;
+ }
+
+ let doInitialBackup = false;
+ let initialBackupFile = backupDir.clone();
+ initialBackupFile.append(makeName("initial"));
+ if (!initialBackupFile.exists()) {
+ doInitialBackup = true;
+ }
+
+ let doDailyBackup = false;
+ let backupTime = this.getProperty("backup-time2");
+ if (!backupTime || new Date().getTime() > backupTime + backupDays * 24 * 60 * 60 * 1000) {
+ // It's time do to a daily backup
+ doDailyBackup = true;
+ this.setProperty("backup-time2", new Date().getTime());
+ }
+
+ let dailyBackupFileName;
+ if (doDailyBackup) {
+ dailyBackupFileName = makeDailyFileName(backupDir);
+ }
+
+ let backupFile = backupDir.clone();
+ backupFile.append(makeName("edit"));
+ backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ purgeOldBackups();
+
+ // Now go download the remote file, and store it somewhere local.
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ let downloader = Cc["@mozilla.org/network/downloader;1"].createInstance(Ci.nsIDownloader);
+ let listener = {
+ onDownloadComplete(opdownloader, request, ctxt, status, result) {
+ if (!Components.isSuccessCode(status)) {
+ reject();
+ return;
+ }
+ if (doInitialBackup) {
+ copyToOverwriting(result, backupDir, makeName("initial"));
+ }
+ if (doDailyBackup) {
+ copyToOverwriting(result, backupDir, dailyBackupFileName);
+ }
+ resolve();
+ },
+ };
+
+ downloader.init(listener, backupFile);
+ try {
+ channel.asyncOpen(downloader);
+ } catch (e) {
+ // For local files, asyncOpen throws on new (calendar) files
+ // No problem, go and upload something
+ cal.ERROR("[calICSCalendar] Backup failed in asyncOpen:" + e);
+ resolve();
+ }
+ });
+ }
+}
+
+/**
+ * @implements {calIObserver}
+ */
+class calICSObserver {
+ #calendar = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onStartBatch(aCalendar) {
+ this.#calendar.observers.notify("onStartBatch", [aCalendar]);
+ }
+
+ onEndBatch(aCalendar) {
+ this.#calendar.observers.notify("onEndBatch", [aCalendar]);
+ }
+
+ onLoad(aCalendar) {
+ this.#calendar.observers.notify("onLoad", [aCalendar]);
+ }
+
+ onAddItem(aItem) {
+ this.#calendar.observers.notify("onAddItem", [aItem]);
+ }
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.#calendar.observers.notify("onModifyItem", [aNewItem, aOldItem]);
+ }
+
+ onDeleteItem(aDeletedItem) {
+ this.#calendar.observers.notify("onDeleteItem", [aDeletedItem]);
+ }
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.#calendar.readOnly = true;
+ this.#calendar.notifyError(aErrNo, aMessage);
+ }
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.#calendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]);
+ }
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.#calendar.observers.notify("onPropertyDeleting", [aCalendar, aName]);
+ }
+}
+
+/*
+ * Transport Abstraction Hooks
+ *
+ * These hooks provide a way to do checks before or after publishing an
+ * ICS file. The main use will be to check etags (or some other way to check
+ * for remote changes) to protect remote changes from being overwritten.
+ *
+ * Different protocols need different checks (webdav can do etag, but
+ * local files need last-modified stamps), hence different hooks for each
+ * types
+ */
+
+// dummyHooks are for transport types that don't have hooks of their own.
+// Also serves as poor-mans interface definition.
+class dummyHooks {
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ aRespFunc();
+ return true;
+ }
+}
+
+class httpHooks {
+ #calendar = null;
+ #etag = null;
+ #lastModified = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ httpchannel.setRequestHeader("Accept", "text/calendar,text/plain;q=0.8,*/*;q=0.5", false);
+
+ if (this.#etag && !aForceRefresh) {
+ // Somehow the webdav header 'If' doesn't work on apache when
+ // passing in a Not, so use the http version here.
+ httpchannel.setRequestHeader("If-None-Match", this.#etag, false);
+ } else if (!aForceRefresh && this.#lastModified) {
+ // Only send 'If-Modified-Since' if no ETag is available
+ httpchannel.setRequestHeader("If-Modified-Since", this.#lastModified, false);
+ }
+
+ return true;
+ }
+
+ onAfterGet(aChannel) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ let responseStatus = 0;
+ let responseStatusCategory = 0;
+
+ try {
+ responseStatus = httpchannel.responseStatus;
+ responseStatusCategory = Math.floor(responseStatus / 100);
+ } catch (e) {
+ // Error might have been a temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to get response status.");
+ return false;
+ }
+
+ if (responseStatus == 304) {
+ // 304: Not Modified
+ // Can use the old data, so tell the caller that it can skip parsing.
+ cal.LOG("[calICSCalendar] Response status 304: Not Modified. Using the existing data.");
+ return false;
+ } else if (responseStatus == 404) {
+ // 404: Not Found
+ // This is a new calendar. Shouldn't try to parse it. But it also
+ // isn't a failure, so don't throw.
+ cal.LOG("[calICSCalendar] Response status 404: Not Found. This is a new calendar.");
+ return false;
+ } else if (responseStatus == 410) {
+ cal.LOG("[calICSCalendar] Response status 410, calendar is gone. Disabling the calendar.");
+ this.#calendar.setProperty("disabled", "true");
+ return false;
+ } else if (responseStatusCategory == 4 || responseStatusCategory == 5) {
+ cal.LOG(
+ "[calICSCalendar] Response status " +
+ responseStatus +
+ ", temporarily disabling calendar for safety."
+ );
+ this.#calendar.setProperty("disabled", "true");
+ this.#calendar.setProperty("auto-enabled", "true");
+ return false;
+ }
+
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ } catch (e) {
+ // No etag header. Now what?
+ this.#etag = null;
+ }
+
+ try {
+ this.#lastModified = httpchannel.getResponseHeader("Last-Modified");
+ } catch (e) {
+ this.#lastModified = null;
+ }
+
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ if (this.#etag) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ // Apache doesn't work correctly with if-match on a PUT method,
+ // so use the webdav header
+ httpchannel.setRequestHeader("If", "([" + this.#etag + "])", false);
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ aRespFunc();
+ } catch (e) {
+ // There was no ETag header on the response. This means that
+ // putting is not atomic. This is bad. Race conditions can happen,
+ // because there is a time in which we don't know the right
+ // etag.
+ // Try to do the best we can, by immediately getting the etag.
+ let etagListener = {};
+ let self = this; // need to reference in callback
+
+ etagListener.onStreamComplete = function (
+ aLoader,
+ aContext,
+ aStatus,
+ aResultLength,
+ aResult
+ ) {
+ let multistatus;
+ try {
+ let str = new TextDecoder().decode(Uint8Array.from(aResult));
+ multistatus = cal.xml.parseString(str);
+ } catch (ex) {
+ cal.LOG("[calICSCalendar] Failed to fetch channel etag");
+ }
+
+ self.#etag = icsXPathFirst(
+ multistatus,
+ "/D:propfind/D:response/D:propstat/D:prop/D:getetag"
+ );
+ aRespFunc();
+ };
+ let queryXml = '<D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>';
+
+ let etagChannel = cal.provider.prepHttpChannel(
+ aChannel.URI,
+ queryXml,
+ "text/xml; charset=utf-8",
+ this
+ );
+ etagChannel.setRequestHeader("Depth", "0", false);
+ etagChannel.requestMethod = "PROPFIND";
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ cal.provider.sendHttpRequest(streamLoader, etagChannel, etagListener);
+ }
+ return true;
+ }
+
+ // nsIProgressEventSink
+ onProgress(aRequest, aProgress, aProgressMax) {}
+ onStatus(aRequest, aStatus, aStatusArg) {}
+
+ getInterface(aIid) {
+ if (aIid.equals(Ci.nsIProgressEventSink)) {
+ return this;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+}
+
+class fileHooks {
+ #lastModified = null;
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified == filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified != filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ aRespFunc();
+ return true;
+ }
+}
diff --git a/comm/calendar/providers/ics/CalICSProvider.jsm b/comm/calendar/providers/ics/CalICSProvider.jsm
new file mode 100644
index 0000000000..1c5df4efa0
--- /dev/null
+++ b/comm/calendar/providers/ics/CalICSProvider.jsm
@@ -0,0 +1,447 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalICSProvider"];
+
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalDavGenericRequest, CalDavPropfindRequest } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequest.jsm"
+);
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.ics namespace.
+
+/**
+ * @implements {calICalendarProvider}
+ */
+var CalICSProvider = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]),
+
+ get type() {
+ return "ics";
+ },
+
+ get displayName() {
+ return cal.l10n.getCalString("icsName");
+ },
+
+ get shortName() {
+ return "ICS";
+ },
+
+ deleteCalendar(aCalendar, aListener) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async detectCalendars(
+ username,
+ password,
+ location = null,
+ savePassword = false,
+ extraProperties = {}
+ ) {
+ let uri = cal.provider.detection.locationToUri(location);
+ if (!uri) {
+ throw new Error("Could not infer location from username");
+ }
+
+ let detector = new ICSDetector(username, password, savePassword);
+
+ // To support ics files hosted by simple HTTP server, attempt HEAD/GET
+ // before PROPFIND.
+ for (let method of [
+ "attemptHead",
+ "attemptGet",
+ "attemptDAVLocation",
+ "attemptPut",
+ "attemptLocalFile",
+ ]) {
+ try {
+ cal.LOG(`[CalICSProvider] Trying to detect calendar using ${method} method`);
+ let calendars = await detector[method](uri);
+ if (calendars) {
+ return calendars;
+ }
+ } catch (e) {
+ // e may be an Error object or a response object like CalDavSimpleResponse.
+ let message = `[CalICSProvider] Could not detect calendar using method ${method}`;
+
+ let errorDetails = err =>
+ ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`;
+
+ let responseDetails = response => ` - HTTP response status ${response.status}`;
+
+ // We want to pass on any autodetect errors that will become results.
+ if (e instanceof cal.provider.detection.Error) {
+ cal.WARN(message + errorDetails(e));
+ throw e;
+ }
+
+ // Sometimes e is a CalDavResponseBase that is an auth error, so throw it.
+ if (e.authError) {
+ cal.WARN(message + responseDetails(e));
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ if (e instanceof Error) {
+ cal.WARN(message + errorDetails(e));
+ } else if (typeof e.status == "number") {
+ cal.WARN(message + responseDetails(e));
+ } else {
+ cal.WARN(message);
+ }
+ }
+ }
+ return [];
+ },
+};
+
+/**
+ * Used by the CalICSProvider to detect ICS calendars for a given username,
+ * password, location, etc.
+ *
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIAuthPromptProvider}
+ * @implements {nsIInterfaceRequestor}
+ */
+class ICSDetectionSession {
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIAuthPrompt2,
+ Ci.nsIAuthPromptProvider,
+ Ci.nsIInterfaceRequestor,
+ ]);
+
+ isDetectionSession = true;
+
+ /**
+ * Create a new ICS detection session.
+ *
+ * @param {string} aSessionId - The session id, used in the password manager.
+ * @param {string} aName - The user-readable description of this session.
+ * @param {string} aPassword - The password for the session.
+ * @param {boolean} aSavePassword - Whether to save the password.
+ */
+ constructor(aSessionId, aUserName, aPassword, aSavePassword) {
+ this.id = aSessionId;
+ this.name = aUserName;
+ this.password = aPassword;
+ this.savePassword = aSavePassword;
+ }
+
+ /**
+ * Implement nsIInterfaceRequestor.
+ *
+ * @param {nsIIDRef} aIID - The IID of the interface being requested.
+ * @returns {ICSAutodetectSession | null} Either this object QI'd to the IID, or null.
+ * Components.returnCode is set accordingly.
+ * @see {nsIInterfaceRequestor}
+ */
+ getInterface(aIID) {
+ try {
+ // Try to query the this object for the requested interface but don't
+ // throw if it fails since that borks the network code.
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ Components.returnCode = e;
+ }
+ return null;
+ }
+
+ /**
+ * @see {nsIAuthPromptProvider}
+ */
+ getAuthPrompt(aReason, aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ setTimeout(() => {
+ if (this.promptAuth(aChannel, aLevel, aAuthInfo)) {
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ } else {
+ aCallback.onAuthCancelled(aContext, true);
+ }
+ }, 0);
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ if (!this.password) {
+ return false;
+ }
+
+ if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) {
+ aAuthInfo.username = this.name;
+ aAuthInfo.password = this.password;
+
+ if (this.savePassword) {
+ cal.auth.passwordManagerSave(
+ this.name,
+ this.password,
+ aChannel.URI.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return true;
+ }
+
+ aAuthInfo.username = null;
+ aAuthInfo.password = null;
+ if (this.savePassword) {
+ cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm);
+ }
+ return false;
+ }
+
+ /** @see {CalDavSession} */
+ async prepareRequest(aChannel) {}
+ async prepareRedirect(aOldChannel, aNewChannel) {}
+ async completeRequest(aResponse) {}
+}
+
+/**
+ * Used by the CalICSProvider to detect ICS calendars for a given location,
+ * username, password, etc. The protocol for detecting ICS calendars is DAV
+ * (pure DAV, not CalDAV), but we use some of the CalDAV code here because the
+ * code is not currently organized to handle pure DAV and CalDAV separately
+ * (e.g. CalDavGenericRequest, CalDavPropfindRequest).
+ */
+class ICSDetector {
+ /**
+ * Create a new ICS detector.
+ *
+ * @param {string} username - A username.
+ * @param {string} password - A password.
+ * @param {boolean} savePassword - Whether to save the password or not.
+ */
+ constructor(username, password, savePassword) {
+ this.session = new ICSDetectionSession(cal.getUUID(), username, password, savePassword);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using CalDAV PROPFIND.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptDAVLocation(location) {
+ let props = ["D:getcontenttype", "D:resourcetype", "D:displayname", "A:calendar-color"];
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.ok) {
+ cal.LOG(`[calICSProvider] ${target.spec} did not respond properly to PROPFIND`);
+ return null;
+ }
+
+ let resprops = response.firstProps;
+ let resourceType = resprops["D:resourcetype"] || new Set();
+
+ if (resourceType.has("C:calendar") || resprops["D:getcontenttype"] == "text/calendar") {
+ cal.LOG(`[calICSProvider] ${target.spec} is a calendar`);
+ return [this.handleCalendar(target, resprops)];
+ } else if (resourceType.has("D:collection")) {
+ return this.handleDirectory(target);
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using a CalDAV generic
+ * request and a method like "HEAD" or "GET".
+ *
+ * @param {string} method - The request method to use, e.g. "GET" or "HEAD".
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async _attemptMethod(method, location) {
+ let request = new CalDavGenericRequest(this.session, null, method, location, {
+ Accept: "text/calendar, application/ics, text/plain;q=0.9",
+ });
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+
+ // The content type header may include a charset, so use 'string.includes'.
+ if (response.ok) {
+ let header = response.getHeader("Content-Type");
+
+ if (
+ header.includes("text/calendar") ||
+ header.includes("application/ics") ||
+ (response.text && response.text.includes("BEGIN:VCALENDAR"))
+ ) {
+ let target = response.uri;
+ cal.LOG(`[calICSProvider] ${target.spec} has valid content type (via ${method} request)`);
+ return [this.handleCalendar(target)];
+ }
+ }
+ return null;
+ }
+
+ get attemptHead() {
+ return this._attemptMethod.bind(this, "HEAD");
+ }
+
+ get attemptGet() {
+ return this._attemptMethod.bind(this, "GET");
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using a CalDAV generic
+ * request and "PUT".
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptPut(location) {
+ let request = new CalDavGenericRequest(
+ this.session,
+ null,
+ "PUT",
+ location,
+ { "If-Match": "nothing" },
+ "",
+ "text/plain"
+ );
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.conflict) {
+ // The etag didn't match, which means we can generally write here but our crafted etag
+ // is stopping us. This means we can assume there is a calendar at the location.
+ cal.LOG(
+ `[calICSProvider] ${target.spec} responded to a dummy ETag request, we can` +
+ " assume it is a valid calendar location"
+ );
+ return [this.handleCalendar(target)];
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempt to detect a calendar for a file URI (`file:///path/to/file.ics`).
+ * If a directory in the path does not exist return null. Whether the file
+ * exists or not, return a calendar for the location (the file will be
+ * created if it does not exist).
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {calICalendar[] | null} An array containing a calendar or null.
+ */
+ async attemptLocalFile(location) {
+ if (location.schemeIs("file")) {
+ let fullPath = location.QueryInterface(Ci.nsIFileURL).file.path;
+ let pathToDir = PathUtils.parent(fullPath);
+ let dirExists = await IOUtils.exists(pathToDir);
+
+ if (dirExists || pathToDir == "") {
+ let calendar = this.handleCalendar(location);
+ if (calendar) {
+ // Check whether we have write permission on the calendar file.
+ // Calling stat on a non-existent file is an error so we check for
+ // it's existence first.
+ let { permissions } = (await IOUtils.exists(fullPath))
+ ? await IOUtils.stat(fullPath)
+ : await IOUtils.stat(pathToDir);
+
+ calendar.readOnly = (permissions ^ 0o200) == 0;
+ return [calendar];
+ }
+ } else {
+ cal.LOG(`[calICSProvider] ${location.spec} includes a directory that does not exist`);
+ }
+ } else {
+ cal.LOG(`[calICSProvider] ${location.spec} is not a "file" URI`);
+ }
+ return null;
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained "D:resourcetype" with "D:collection".
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handleDirectory(location) {
+ let props = [
+ "D:getcontenttype",
+ "D:current-user-privilege-set",
+ "D:displayname",
+ "A:calendar-color",
+ ];
+ let request = new CalDavPropfindRequest(this.session, null, location, props, 1);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ let calendars = [];
+ for (let [href, resprops] of Object.entries(response.data)) {
+ if (resprops["D:getcontenttype"] != "text/calendar") {
+ continue;
+ }
+
+ let uri = Services.io.newURI(href, null, target);
+ calendars.push(this.handleCalendar(uri, resprops));
+ }
+
+ cal.LOG(`[calICSProvider] ${target.spec} is a directory, found ${calendars.length} calendars`);
+
+ return calendars.length ? calendars : null;
+ }
+
+ /**
+ * Set up and return a new ICS calendar object.
+ *
+ * @param {nsIURI} uri - The location of the calendar.
+ * @param {Set} [props] - For CalDav calendars, these are the props
+ * parsed from the response.
+ * @returns {calICalendar} A new calendar.
+ */
+ handleCalendar(uri, props = new Set()) {
+ let displayName = props["D:displayname"];
+ let color = props["A:calendar-color"];
+ if (!displayName) {
+ let lastPath = uri.filePath.split("/").filter(Boolean).pop() || "";
+ let fileName = lastPath.split(".").slice(0, -1).join(".");
+ displayName = fileName || lastPath || uri.spec;
+ }
+
+ let calendar = cal.manager.createCalendar("ics", uri);
+ calendar.setProperty("color", color || cal.view.hashColor(uri.spec));
+ calendar.name = displayName;
+ calendar.id = cal.getUUID();
+
+ // Attempt to discover if the user is allowed to write to this calendar.
+ let privs = props["D:current-user-privilege-set"];
+ if (privs && privs instanceof Set) {
+ calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+
+ return calendar;
+ }
+}
diff --git a/comm/calendar/providers/ics/components.conf b/comm/calendar/providers/ics/components.conf
new file mode 100644
index 0000000000..fd05b7f7f6
--- /dev/null
+++ b/comm/calendar/providers/ics/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{f8438bff-a3c9-4ed5-b23f-2663b5469abf}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=ics'],
+ 'esModule': 'resource:///modules/CalICSCalendar.sys.mjs',
+ 'constructor': 'CalICSCalendar',
+ },
+]
diff --git a/comm/calendar/providers/ics/moz.build b/comm/calendar/providers/ics/moz.build
new file mode 100644
index 0000000000..6ec4226df7
--- /dev/null
+++ b/comm/calendar/providers/ics/moz.build
@@ -0,0 +1,16 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "CalICSCalendar.sys.mjs",
+ "CalICSProvider.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: ICS/WebDAV")
diff --git a/comm/calendar/providers/memory/CalMemoryCalendar.jsm b/comm/calendar/providers/memory/CalMemoryCalendar.jsm
new file mode 100644
index 0000000000..cd810285d8
--- /dev/null
+++ b/comm/calendar/providers/memory/CalMemoryCalendar.jsm
@@ -0,0 +1,538 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalMemoryCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+var cICL = Ci.calIChangeLog;
+
+function CalMemoryCalendar() {
+ this.initProviderBase();
+ this.initMemoryCalendar();
+}
+var calMemoryCalendarClassID = Components.ID("{bda0dd7f-0a2f-4fcf-ba08-5517e6fbf133}");
+var calMemoryCalendarInterfaces = [
+ "calICalendar",
+ "calISchedulingSupport",
+ "calIOfflineStorage",
+ "calISyncWriteCalendar",
+ "calICalendarProvider",
+];
+CalMemoryCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calMemoryCalendarClassID,
+ QueryInterface: cal.generateQI(calMemoryCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calMemoryCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=memory",
+ classDescription: "Calendar Memory Provider",
+ interfaces: calMemoryCalendarInterfaces,
+ }),
+
+ mItems: null,
+ mOfflineFlags: null,
+ mObservers: null,
+ mMetaData: null,
+
+ initMemoryCalendar() {
+ this.mObservers = new cal.data.ObserverSet(Ci.calIObserver);
+ this.mItems = {};
+ this.mOfflineFlags = {};
+ this.mMetaData = new Map();
+ },
+
+ //
+ // calICalendarProvider interface
+ //
+
+ get displayName() {
+ return cal.l10n.getCalString("memoryName");
+ },
+
+ get shortName() {
+ return this.displayName;
+ },
+
+ deleteCalendar(calendar, listener) {
+ calendar = calendar.wrappedJSObject;
+ calendar.mItems = {};
+ calendar.mMetaData = new Map();
+
+ try {
+ listener.onDeleteCalendar(calendar, Cr.NS_OK, null);
+ } catch (ex) {
+ // Don't bail out if the listener fails
+ }
+ },
+
+ detectCalendars() {
+ throw Components.Exception(
+ "CalMemoryCalendar does not implement detectCalendars",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+
+ mRelaxedMode: undefined,
+ get relaxedMode() {
+ if (this.mRelaxedMode === undefined) {
+ this.mRelaxedMode = this.getProperty("relaxedMode");
+ }
+ return this.mRelaxedMode;
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ getProperty(aName) {
+ switch (aName) {
+ case "cache.supported":
+ case "requiresNetwork":
+ return false;
+ case "capabilities.priority.supported":
+ return true;
+ case "removemodes":
+ return ["delete"];
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "memory";
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ async addItem(aItem) {
+ let newItem = aItem.clone();
+ return this.adoptItem(newItem);
+ },
+
+ // Promise<calIItemBase> adoptItem(in calIItemBase aItem);
+ async adoptItem(aItem) {
+ if (this.readOnly) {
+ throw Ci.calIErrors.CAL_IS_READONLY;
+ }
+ if (aItem.id == null && aItem.isMutable) {
+ aItem.id = cal.getUUID();
+ }
+
+ if (aItem.id == null) {
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ "Can't set ID on non-mutable item to addItem"
+ );
+ return Promise.reject(
+ new Components.Exception("Can't set ID on non-mutable item to addItem", Cr.NS_ERROR_FAILURE)
+ );
+ }
+
+ // Lines below are commented because of the offline bug 380060, the
+ // memory calendar cannot assume that a new item should not have an ID.
+ // calCachedCalendar could send over an item with an id.
+
+ /*
+ if (this.mItems[aItem.id] != null) {
+ if (this.relaxedMode) {
+ // we possibly want to interact with the user before deleting
+ delete this.mItems[aItem.id];
+ } else {
+ this.notifyOperationComplete(aListener,
+ Ci.calIErrors.DUPLICATE_ID,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ "ID already exists for addItem");
+ return;
+ }
+ }
+ */
+
+ let parentItem = aItem.parentItem;
+ if (parentItem != aItem) {
+ parentItem = parentItem.clone();
+ parentItem.recurrenceInfo.modifyException(aItem, true);
+ }
+ parentItem.calendar = this.superCalendar;
+
+ parentItem.makeImmutable();
+ this.mItems[aItem.id] = parentItem;
+
+ // notify observers
+ this.mObservers.notify("onAddItem", [aItem]);
+
+ return aItem;
+ },
+
+ // Promise<calIItemBase> modifyItem(in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ if (this.readOnly) {
+ throw Ci.calIErrors.CAL_IS_READONLY;
+ }
+ if (!aNewItem) {
+ throw Components.Exception("aNewItem must be set", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let reportError = (errStr, errId = Cr.NS_ERROR_FAILURE) => {
+ this.notifyOperationComplete(
+ null,
+ errId,
+ Ci.calIOperationListener.MODIFY,
+ aNewItem.id,
+ errStr
+ );
+ return Promise.reject(new Components.Exception(errStr, errId));
+ };
+
+ if (!aNewItem.id) {
+ // this is definitely an error
+ return reportError("ID for modifyItem item is null");
+ }
+
+ let modifiedItem = aNewItem.parentItem.clone();
+ if (aNewItem.parentItem != aNewItem) {
+ modifiedItem.recurrenceInfo.modifyException(aNewItem, false);
+ }
+
+ // If no old item was passed, then we should overwrite in any case.
+ // Pick up the old item from our items array and use this as an old item
+ // later on.
+ if (!aOldItem) {
+ aOldItem = this.mItems[aNewItem.id];
+ }
+
+ if (this.relaxedMode) {
+ // We've already filled in the old item above, if this doesn't exist
+ // then just take the current item as its old version
+ if (!aOldItem) {
+ aOldItem = modifiedItem;
+ }
+ aOldItem = aOldItem.parentItem;
+ } else if (!this.relaxedMode) {
+ if (!aOldItem || !this.mItems[aNewItem.id]) {
+ // no old item found? should be using addItem, then.
+ return reportError(
+ "ID for modifyItem doesn't exist, is null, or is from different calendar"
+ );
+ }
+
+ // do the old and new items match?
+ if (aOldItem.id != modifiedItem.id) {
+ return reportError("item ID mismatch between old and new items");
+ }
+
+ aOldItem = aOldItem.parentItem;
+ let storedOldItem = this.mItems[aOldItem.id];
+
+ // compareItems is not suitable here. See bug 418805.
+ // Cannot compare here due to bug 380060
+ if (!cal.item.compareContent(storedOldItem, aOldItem)) {
+ return reportError(
+ "old item mismatch in modifyItem. storedId:" +
+ storedOldItem.icalComponent +
+ " old item:" +
+ aOldItem.icalComponent
+ );
+ }
+ // offline bug
+
+ if (aOldItem.generation != storedOldItem.generation) {
+ return reportError("generation mismatch in modifyItem");
+ }
+
+ if (aOldItem.generation == modifiedItem.generation) {
+ // has been cloned and modified
+ // Only take care of incrementing the generation if relaxed mode is
+ // off. Users of relaxed mode need to take care of this themselves.
+ modifiedItem.generation += 1;
+ }
+ }
+
+ modifiedItem.makeImmutable();
+ this.mItems[modifiedItem.id] = modifiedItem;
+
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ modifiedItem.id,
+ modifiedItem
+ );
+
+ // notify observers
+ this.mObservers.notify("onModifyItem", [modifiedItem, aOldItem]);
+ return modifiedItem;
+ },
+
+ // Promise<void> deleteItem(in calIItemBase item);
+ async deleteItem(item) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (item.id == null) {
+ return onError("ID is null in deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+
+ let oldItem;
+ if (this.relaxedMode) {
+ oldItem = item;
+ } else {
+ oldItem = this.mItems[item.id];
+ if (oldItem.generation != item.generation) {
+ return onError("generation mismatch in deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+ }
+
+ delete this.mItems[item.id];
+ this.mMetaData.delete(item.id);
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.DELETE, item.id, item);
+ // notify observers
+ this.mObservers.notify("onDeleteItem", [oldItem]);
+ return null;
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mItems[aId] || null;
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ const calICalendar = Ci.calICalendar;
+
+ let itemsFound = [];
+
+ //
+ // filters
+ //
+
+ let wantUnrespondedInvitations =
+ (itemFilter & calICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION) != 0;
+ let superCal;
+ try {
+ superCal = this.superCalendar.QueryInterface(Ci.calISchedulingSupport);
+ } catch (exc) {
+ wantUnrespondedInvitations = false;
+ }
+ function checkUnrespondedInvitation(item) {
+ let att = superCal.getInvitedAttendee(item);
+ return att && att.participationStatus == "NEEDS-ACTION";
+ }
+
+ // item base type
+ let wantEvents = (itemFilter & calICalendar.ITEM_FILTER_TYPE_EVENT) != 0;
+ let wantTodos = (itemFilter & calICalendar.ITEM_FILTER_TYPE_TODO) != 0;
+ if (!wantEvents && !wantTodos) {
+ // bail.
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ // completed?
+ let itemCompletedFilter = (itemFilter & calICalendar.ITEM_FILTER_COMPLETED_YES) != 0;
+ let itemNotCompletedFilter = (itemFilter & calICalendar.ITEM_FILTER_COMPLETED_NO) != 0;
+ function checkCompleted(item) {
+ item.QueryInterface(Ci.calITodo);
+ return item.isCompleted ? itemCompletedFilter : itemNotCompletedFilter;
+ }
+
+ // return occurrences?
+ let itemReturnOccurrences = (itemFilter & calICalendar.ITEM_FILTER_CLASS_OCCURRENCES) != 0;
+
+ rangeStart = cal.dtz.ensureDateTime(rangeStart);
+ rangeEnd = cal.dtz.ensureDateTime(rangeEnd);
+ let startTime = -9223372036854775000;
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+
+ let requestedFlag = 0;
+ if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_CREATED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_MODIFIED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ } else if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_DELETED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ }
+
+ let matchOffline = function (itemFlag, reqFlag) {
+ // Same as storage calendar sql query. For comparison:
+ // reqFlag is :offline_journal (parameter),
+ // itemFlag is offline_journal (field value)
+ // ...
+ // AND (:offline_journal IS NULL
+ // AND (offline_journal IS NULL
+ // OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ // OR offline_journal == :offline_journal
+
+ return (
+ (!reqFlag && (!itemFlag || itemFlag != cICL.OFFLINE_FLAG_DELETED_RECORD)) ||
+ itemFlag == reqFlag
+ );
+ };
+
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ return new Promise(resolve => {
+ cal.iterate.forEach(
+ self.mItems,
+ ([id, item]) => {
+ let isEvent_ = item.isEvent();
+ if (isEvent_) {
+ if (!wantEvents) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+ } else if (!wantTodos) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ let hasItemFlag = item.id in self.mOfflineFlags;
+ let itemFlag = hasItemFlag ? self.mOfflineFlags[item.id] : 0;
+
+ // If the offline flag doesn't match, skip the item
+ if (!matchOffline(itemFlag, requestedFlag)) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ if (itemReturnOccurrences && item.recurrenceInfo) {
+ if (item.recurrenceInfo.recurrenceEndDate < startTime) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ let startDate = rangeStart;
+ if (!rangeStart && item.isTodo()) {
+ startDate = item.entryDate;
+ }
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ startDate,
+ rangeEnd,
+ count ? count - itemsFound.length : 0
+ );
+ if (wantUnrespondedInvitations) {
+ occurrences = occurrences.filter(checkUnrespondedInvitation);
+ }
+ if (!isEvent_) {
+ occurrences = occurrences.filter(checkCompleted);
+ }
+ itemsFound = itemsFound.concat(occurrences);
+ } else if (
+ (!wantUnrespondedInvitations || checkUnrespondedInvitation(item)) &&
+ (isEvent_ || checkCompleted(item)) &&
+ cal.item.checkIfInRange(item, rangeStart, rangeEnd)
+ ) {
+ // This needs fixing for recurring items, e.g. DTSTART of parent may occur before rangeStart.
+ // This will be changed with bug 416975.
+ itemsFound.push(item);
+ }
+ if (controller.maxTotalItemsReached) {
+ return cal.iterate.forEach.BREAK;
+ }
+ return cal.iterate.forEach.CONTINUE;
+ },
+ () => {
+ controller.enqueue(itemsFound);
+ controller.close();
+ resolve();
+ }
+ );
+ });
+ },
+ }
+ );
+ },
+
+ //
+ // calIOfflineStorage interface
+ //
+ async addOfflineItem(aItem) {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ },
+
+ async modifyOfflineItem(aItem) {
+ let oldFlag = this.mOfflineFlags[aItem.id];
+ if (
+ oldFlag != cICL.OFFLINE_FLAG_CREATED_RECORD &&
+ oldFlag != cICL.OFFLINE_FLAG_DELETED_RECORD
+ ) {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.MODIFY, aItem.id, aItem);
+ return aItem;
+ },
+
+ async deleteOfflineItem(aItem) {
+ let oldFlag = this.mOfflineFlags[aItem.id];
+ if (oldFlag == cICL.OFFLINE_FLAG_CREATED_RECORD) {
+ delete this.mItems[aItem.id];
+ delete this.mOfflineFlags[aItem.id];
+ } else {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ }
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [aItem]);
+ },
+
+ async getItemOfflineFlag(aItem) {
+ return aItem && aItem.id in this.mOfflineFlags ? this.mOfflineFlags[aItem.id] : null;
+ },
+
+ async resetItemOfflineFlag(aItem) {
+ delete this.mOfflineFlags[aItem.id];
+ },
+
+ //
+ // calISyncWriteCalendar interface
+ //
+ setMetaData(id, value) {
+ this.mMetaData.set(id, value);
+ },
+ deleteMetaData(id) {
+ this.mMetaData.delete(id);
+ },
+ getMetaData(id) {
+ return this.mMetaData.get(id);
+ },
+ getAllMetaDataIds() {
+ return [...this.mMetaData.keys()];
+ },
+ getAllMetaDataValues() {
+ return [...this.mMetaData.values()];
+ },
+};
diff --git a/comm/calendar/providers/memory/components.conf b/comm/calendar/providers/memory/components.conf
new file mode 100644
index 0000000000..a898b8ed8b
--- /dev/null
+++ b/comm/calendar/providers/memory/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{bda0dd7f-0a2f-4fcf-ba08-5517e6fbf133}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=memory'],
+ 'jsm': 'resource:///modules/CalMemoryCalendar.jsm',
+ 'constructor': 'CalMemoryCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/memory/moz.build b/comm/calendar/providers/memory/moz.build
new file mode 100644
index 0000000000..c7a6d9ff31
--- /dev/null
+++ b/comm/calendar/providers/memory/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "CalMemoryCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/calendar/providers/moz.build b/comm/calendar/providers/moz.build
new file mode 100644
index 0000000000..958fc25a8e
--- /dev/null
+++ b/comm/calendar/providers/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "caldav",
+ "composite",
+ "ics",
+ "memory",
+ "storage",
+]
diff --git a/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm b/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm
new file mode 100644
index 0000000000..80a367f2af
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalStorageCachedItemModel"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageItemModel.jsm"
+);
+
+/**
+ * CalStorageCachedItemModel extends CalStorageItemModel to add caching support
+ * for items. Most of the methods here are overridden from the parent class to
+ * either add or retrieve items from the cache.
+ */
+class CalStorageCachedItemModel extends CalStorageItemModel {
+ /**
+ * Cache for all items.
+ *
+ * @type {Map<string, calIItemBase>}
+ */
+ itemCache = new Map();
+
+ /**
+ * Cache for recurring events.
+ *
+ * @type {Map<string, calIEvent>}
+ */
+ #recurringEventsCache = new Map();
+
+ /**
+ * Cache for recurring events offline flags.
+ *
+ * @type {Map<string, number>}
+ */
+ #recurringEventsOfflineFlagCache = new Map();
+
+ /**
+ * Cache for recurring todos.
+ *
+ * @type {Map<string, calITodo>}
+ */
+ #recurringTodosCache = new Map();
+
+ /**
+ * Cache for recurring todo offline flags.
+ *
+ * @type {Map<string, number>}
+ */
+ #recurringTodosOfflineCache = new Map();
+
+ /**
+ * Promise that resolves when the caches have been built up.
+ *
+ * @type {Promise<void>}
+ */
+ #recurringCachePromise = null;
+
+ /**
+ * Build up recurring event and todo cache with its offline flags.
+ */
+ async #ensureRecurringItemCaches() {
+ if (!this.#recurringCachePromise) {
+ this.#recurringCachePromise = this.#buildRecurringItemCaches();
+ }
+ return this.#recurringCachePromise;
+ }
+
+ async #buildRecurringItemCaches() {
+ // Retrieve items and flags for recurring events and todos before combining
+ // storing them in the item cache. Items need to be expunged from the
+ // existing item cache to avoid get(Event|Todo)FromRow providing stale
+ // values.
+ let expunge = id => this.itemCache.delete(id);
+ let [events, eventFlags] = await this.getRecurringEventAndFlagMaps(expunge);
+ let [todos, todoFlags] = await this.getRecurringTodoAndFlagMaps(expunge);
+ let itemsMap = await this.getAdditionalDataForItemMap(new Map([...events, ...todos]));
+
+ this.itemCache = new Map([...this.itemCache, ...itemsMap]);
+ this.#recurringEventsCache = new Map([...this.#recurringEventsCache, ...events]);
+ this.#recurringEventsOfflineFlagCache = new Map([
+ ...this.#recurringEventsOfflineFlagCache,
+ ...eventFlags,
+ ]);
+ this.#recurringTodosCache = new Map([...this.#recurringTodosCache, ...todos]);
+ this.#recurringTodosOfflineCache = new Map([...this.#recurringTodosOfflineCache, ...todoFlags]);
+ }
+
+ /**
+ * Overridden here to build the recurring item caches when needed.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIItemBase>
+ */
+ getItems(query) {
+ let self = this;
+ let getStream = () => super.getItems(query);
+ return CalReadableStreamFactory.createReadableStream({
+ async start(controller) {
+ // HACK because recurring offline events/todos objects don't have offline_journal information
+ // Hence we need to update the offline flags caches.
+ // It can be an expensive operation but is only used in Online Reconciliation mode
+ if (
+ (query.filters.wantOfflineCreatedItems ||
+ query.filters.wantOfflineDeletedItems ||
+ query.filters.wantOfflineModifiedItems) &&
+ self.mRecItemCachePromise
+ ) {
+ // If there's an existing Promise and it's not complete, wait for it - something else is
+ // already waiting and we don't want to break that by throwing away the caches. If it IS
+ // complete, we'll continue immediately.
+ let recItemCachePromise = self.mRecItemCachePromise;
+ await recItemCachePromise;
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ // Check in case someone else already threw away the caches.
+ if (self.mRecItemCachePromise == recItemCachePromise) {
+ self.mRecItemCachePromise = null;
+ }
+ }
+ await self.#ensureRecurringItemCaches();
+
+ for await (let value of cal.iterate.streamValues(getStream())) {
+ controller.enqueue(value);
+ }
+ controller.close();
+ },
+ });
+ }
+
+ /**
+ * Overridden here to provide the events from the cache.
+ *
+ * @returns {[Map<string, calIEvent>, Map<string, number>]}
+ */
+ async getFullRecurringEventAndFlagMaps() {
+ return [this.#recurringEventsCache, this.#recurringEventsOfflineFlagCache];
+ }
+
+ /**
+ * Overridden here to provide the todos from the cache.
+ *
+ * @returns {[Map<string, calITodo>, Map<string, number>]}
+ */
+ async getFullRecurringTodoAndFlagMaps() {
+ return [this.#recurringTodosCache, this.#recurringTodosOfflineCache];
+ }
+
+ async getEventFromRow(row, getAdditionalData = true) {
+ let item = this.itemCache.get(row.getResultByName("id"));
+ if (item) {
+ return item;
+ }
+
+ item = await super.getEventFromRow(row, getAdditionalData);
+ if (getAdditionalData) {
+ this.#cacheItem(item);
+ }
+ return item;
+ }
+
+ async getTodoFromRow(row, getAdditionalData = true) {
+ let item = this.itemCache.get(row.getResultByName("id"));
+ if (item) {
+ return item;
+ }
+
+ item = await super.getTodoFromRow(row, getAdditionalData);
+ if (getAdditionalData) {
+ this.#cacheItem(item);
+ }
+ return item;
+ }
+
+ async addItem(item) {
+ await super.addItem(item);
+ this.#cacheItem(item);
+ }
+
+ async getItemById(id) {
+ await this.#ensureRecurringItemCaches();
+ let item = this.itemCache.get(id);
+ if (item) {
+ return item;
+ }
+ return super.getItemById(id);
+ }
+
+ async deleteItemById(id, keepMeta) {
+ await super.deleteItemById(id, keepMeta);
+ this.itemCache.delete(id);
+ this.#recurringEventsCache.delete(id);
+ this.#recurringTodosCache.delete(id);
+ }
+
+ /**
+ * Adds an item to the relevant caches.
+ *
+ * @param {calIItemBase} item
+ */
+ #cacheItem(item) {
+ if (item.recurrenceId) {
+ // Do not cache recurring item instances. See bug 1686466.
+ return;
+ }
+ this.itemCache.set(item.id, item);
+ if (item.recurrenceInfo) {
+ if (item.isEvent()) {
+ this.#recurringEventsCache.set(item.id, item);
+ } else {
+ this.#recurringTodosCache.set(item.id, item);
+ }
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageCalendar.jsm b/comm/calendar/providers/storage/CalStorageCalendar.jsm
new file mode 100644
index 0000000000..5d330986b6
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageCalendar.jsm
@@ -0,0 +1,563 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalStorageCalendar"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageDatabase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageDatabase.jsm"
+);
+const { CalStorageModelFactory } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelFactory.jsm"
+);
+const { CalStorageStatements } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageStatements.jsm"
+);
+const { upgradeDB } = ChromeUtils.import("resource:///modules/calendar/calStorageUpgrade.jsm");
+
+const kCalICalendar = Ci.calICalendar;
+const cICL = Ci.calIChangeLog;
+
+function CalStorageCalendar() {
+ this.initProviderBase();
+}
+var calStorageCalendarClassID = Components.ID("{b3eaa1c4-5dfe-4c0a-b62a-b3a514218461}");
+var calStorageCalendarInterfaces = [
+ "calICalendar",
+ "calICalendarProvider",
+ "calIOfflineStorage",
+ "calISchedulingSupport",
+ "calISyncWriteCalendar",
+];
+CalStorageCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calStorageCalendarClassID,
+ QueryInterface: cal.generateQI(calStorageCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calStorageCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=storage",
+ classDescription: "Calendar Storage Provider",
+ interfaces: calStorageCalendarInterfaces,
+ }),
+
+ //
+ // private members
+ //
+ mStorageDb: null,
+ mItemModel: null,
+ mOfflineModel: null,
+ mMetaModel: null,
+
+ //
+ // calICalendarProvider interface
+ //
+
+ get displayName() {
+ return cal.l10n.getCalString("storageName");
+ },
+
+ get shortName() {
+ return "SQLite";
+ },
+
+ async deleteCalendar(aCalendar, listener) {
+ await this.mItemModel.deleteCalendar();
+ try {
+ if (listener) {
+ listener.onDeleteCalendar(aCalendar, Cr.NS_OK, null);
+ }
+ } catch (ex) {
+ this.mStorageDb.logError("error calling listener.onDeleteCalendar", ex);
+ }
+ },
+
+ detectCalendars() {
+ throw Components.Exception(
+ "calStorageCalendar does not implement detectCalendars",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+
+ mRelaxedMode: undefined,
+ get relaxedMode() {
+ if (this.mRelaxedMode === undefined) {
+ this.mRelaxedMode = this.getProperty("relaxedMode");
+ }
+ return this.mRelaxedMode;
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ getProperty(aName) {
+ switch (aName) {
+ case "cache.supported":
+ return false;
+ case "requiresNetwork":
+ return false;
+ case "capabilities.priority.supported":
+ return true;
+ case "capabilities.removeModes":
+ return ["delete"];
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "storage";
+ },
+
+ // attribute AUTF8String id;
+ get id() {
+ return this.__proto__.__proto__.__lookupGetter__("id").call(this);
+ },
+ set id(val) {
+ this.__proto__.__proto__.__lookupSetter__("id").call(this, val);
+
+ if (!this.mStorageDb && this.uri && this.id) {
+ // Prepare the database as soon as we have an id and an uri.
+ this.prepareInitDB();
+ }
+ },
+
+ // attribute nsIURI uri;
+ get uri() {
+ return this.__proto__.__proto__.__lookupGetter__("uri").call(this);
+ },
+ set uri(aUri) {
+ // We can only load once
+ if (this.uri) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ this.__proto__.__proto__.__lookupSetter__("uri").call(this, aUri);
+
+ if (!this.mStorageDb && this.uri && this.id) {
+ // Prepare the database as soon as we have an id and an uri.
+ this.prepareInitDB();
+ }
+ },
+
+ // attribute mozIStorageAsyncConnection db;
+ get db() {
+ return this.mStorageDb.db;
+ },
+
+ /**
+ * Initialize the Database. This should generally only be called from the
+ * uri or id setter and requires those two attributes to be set. It may also
+ * be called again when the schema version of the database is newer than
+ * the version expected by this version of Thunderbird.
+ */
+ prepareInitDB() {
+ this.mStorageDb = CalStorageDatabase.connect(this.uri, this.id);
+ upgradeDB(this);
+ },
+
+ afterUpgradeDB() {
+ this.initDB();
+ Services.obs.addObserver(this, "profile-change-teardown");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "profile-change-teardown") {
+ Services.obs.removeObserver(this, "profile-change-teardown");
+ // Finalize the storage statements, but don't close the database.
+ // CalStorageDatabase.jsm will take care of that while blocking profile-before-change.
+ this.mStatements?.finalize();
+ }
+ },
+
+ refresh() {
+ // no-op
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ async addItem(aItem) {
+ let newItem = aItem.clone();
+ return this.adoptItem(newItem);
+ },
+
+ // Promise<calIItemBase> adoptItem(in calIItemBase aItem);
+ async adoptItem(aItem) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (aItem.id == null) {
+ // is this an error? Or should we generate an IID?
+ aItem.id = cal.getUUID();
+ } else {
+ let olditem = await this.mItemModel.getItemById(aItem.id);
+ if (olditem) {
+ if (this.relaxedMode) {
+ // we possibly want to interact with the user before deleting
+ await this.mItemModel.deleteItemById(aItem.id, true);
+ } else {
+ return onError("ID already exists for addItem", Ci.calIErrors.DUPLICATE_ID);
+ }
+ }
+ }
+
+ let parentItem = aItem.parentItem;
+ if (parentItem != aItem) {
+ parentItem = parentItem.clone();
+ parentItem.recurrenceInfo.modifyException(aItem, true);
+ }
+ parentItem.calendar = this.superCalendar;
+ parentItem.makeImmutable();
+
+ await this.mItemModel.addItem(parentItem);
+
+ // notify observers
+ this.observers.notify("onAddItem", [aItem]);
+ return aItem;
+ },
+
+ // Promise<calIItemBase> modifyItem(in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ // HACK Just modifying the item would clear the offline flag, we need to
+ // retrieve the flag and pass it to the real modify function.
+ let offlineFlag = await this.getItemOfflineFlag(aOldItem);
+ let oldOfflineFlag = offlineFlag;
+
+ let reportError = (errStr, errId = Cr.NS_ERROR_FAILURE) => {
+ this.notifyOperationComplete(
+ null,
+ errId,
+ Ci.calIOperationListener.MODIFY,
+ aNewItem.id,
+ errStr
+ );
+ return Promise.reject(new Components.Exception(errStr, errId));
+ };
+
+ if (this.readOnly) {
+ return reportError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+ if (!aNewItem) {
+ return reportError("A modified version of the item is required", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (aNewItem.id == null) {
+ // this is definitely an error
+ return reportError("ID for modifyItem item is null");
+ }
+
+ let modifiedItem = aNewItem.parentItem.clone();
+ if (this.getProperty("capabilities.propagate-sequence")) {
+ // Ensure the exception, its parent and the other exceptions have the
+ // same sequence number, to make sure we can send our changes to the
+ // server if the event has been updated via the blue bar
+ let newSequence = aNewItem.getProperty("SEQUENCE");
+ this._propagateSequence(modifiedItem, newSequence);
+ }
+
+ // Ensure that we're looking at the base item if we were given an
+ // occurrence. Later we can optimize this.
+ if (aNewItem.parentItem != aNewItem) {
+ modifiedItem.recurrenceInfo.modifyException(aNewItem, false);
+ }
+
+ // If no old item was passed, then we should overwrite in any case.
+ // Pick up the old item from the database and use this as an old item
+ // later on.
+ if (!aOldItem) {
+ aOldItem = await this.mItemModel.getItemById(aNewItem.id);
+ }
+
+ if (this.relaxedMode) {
+ // We've already filled in the old item above, if this doesn't exist
+ // then just take the current item as its old version
+ if (!aOldItem) {
+ aOldItem = aNewItem;
+ }
+ aOldItem = aOldItem.parentItem;
+ } else {
+ let storedOldItem = null;
+ if (aOldItem) {
+ storedOldItem = await this.mItemModel.getItemById(aOldItem.id);
+ }
+ if (!aOldItem || !storedOldItem) {
+ // no old item found? should be using addItem, then.
+ return reportError("ID does not already exist for modifyItem");
+ }
+ aOldItem = aOldItem.parentItem;
+
+ if (aOldItem.generation != storedOldItem.generation) {
+ return reportError("generation too old for for modifyItem");
+ }
+
+ // xxx todo: this only modified master item's generation properties
+ // I start asking myself why we need a separate X-MOZ-GENERATION.
+ // Just for the sake of checking inconsistencies of modifyItem calls?
+ if (aOldItem.generation == modifiedItem.generation) {
+ // has been cloned and modified
+ // Only take care of incrementing the generation if relaxed mode is
+ // off. Users of relaxed mode need to take care of this themselves.
+ modifiedItem.generation += 1;
+ }
+ }
+
+ modifiedItem.makeImmutable();
+ await this.mItemModel.updateItem(modifiedItem, aOldItem);
+ await this.mOfflineModel.setOfflineJournalFlag(aNewItem, oldOfflineFlag);
+
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ modifiedItem.id,
+ modifiedItem
+ );
+
+ // notify observers
+ this.observers.notify("onModifyItem", [modifiedItem, aOldItem]);
+ return modifiedItem;
+ },
+
+ // Promise<void> deleteItem(in calIItemBase item)
+ async deleteItem(item) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (item.parentItem != item) {
+ item.parentItem.recurrenceInfo.removeExceptionFor(item.recurrenceId);
+ // xxx todo: would we want to support this case? Removing an occurrence currently results
+ // in a modifyItem(parent)
+ return null;
+ }
+
+ if (item.id == null) {
+ return onError("ID is null for deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+
+ await this.mItemModel.deleteItemById(item.id);
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.DELETE, item.id, item);
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [item]);
+ return null;
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mItemModel.getItemById(aId);
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd);
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ let query = {
+ rangeStart,
+ rangeEnd,
+ filters: {
+ wantUnrespondedInvitations:
+ (itemFilter & kCalICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION) != 0 &&
+ this.superCalendar.supportsScheduling,
+ wantEvents: (itemFilter & kCalICalendar.ITEM_FILTER_TYPE_EVENT) != 0,
+ wantTodos: (itemFilter & kCalICalendar.ITEM_FILTER_TYPE_TODO) != 0,
+ asOccurrences: (itemFilter & kCalICalendar.ITEM_FILTER_CLASS_OCCURRENCES) != 0,
+ wantOfflineDeletedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_DELETED) != 0,
+ wantOfflineCreatedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_CREATED) != 0,
+ wantOfflineModifiedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_MODIFIED) != 0,
+ itemCompletedFilter: (itemFilter & kCalICalendar.ITEM_FILTER_COMPLETED_YES) != 0,
+ itemNotCompletedFilter: (itemFilter & kCalICalendar.ITEM_FILTER_COMPLETED_NO) != 0,
+ },
+ count,
+ };
+
+ if ((!query.filters.wantEvents && !query.filters.wantTodos) || this.getProperty("disabled")) {
+ // nothing to do
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ return this.mItemModel.getItems(query);
+ },
+
+ async getItemOfflineFlag(aItem) {
+ // It is possible that aItem can be null, flag provided should be null in this case
+ return aItem ? this.mOfflineModel.getItemOfflineFlag(aItem) : null;
+ },
+
+ //
+ // calIOfflineStorage interface
+ //
+ async addOfflineItem(aItem) {
+ let newOfflineJournalFlag = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, newOfflineJournalFlag);
+ },
+
+ async modifyOfflineItem(aItem) {
+ let oldOfflineJournalFlag = await this.getItemOfflineFlag(aItem);
+ let newOfflineJournalFlag = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ if (
+ oldOfflineJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ oldOfflineJournalFlag == cICL.OFFLINE_FLAG_DELETED_RECORD
+ ) {
+ // Do nothing since a flag of "created" or "deleted" exists
+ } else {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, newOfflineJournalFlag);
+ }
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.MODIFY, aItem.id, aItem);
+ },
+
+ async deleteOfflineItem(aItem) {
+ let oldOfflineJournalFlag = await this.getItemOfflineFlag(aItem);
+ if (oldOfflineJournalFlag) {
+ // Delete item if flag is set
+ if (oldOfflineJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD) {
+ await this.mItemModel.deleteItemById(aItem.id);
+ } else if (oldOfflineJournalFlag == cICL.OFFLINE_FLAG_MODIFIED_RECORD) {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, cICL.OFFLINE_FLAG_DELETED_RECORD);
+ }
+ } else {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, cICL.OFFLINE_FLAG_DELETED_RECORD);
+ }
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [aItem]);
+ },
+
+ async resetItemOfflineFlag(aItem) {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, null);
+ },
+
+ //
+ // database handling
+ //
+
+ // database initialization
+ // assumes this.mStorageDb is valid
+
+ initDB() {
+ cal.ASSERT(this.mStorageDb, "Database has not been opened!", true);
+
+ try {
+ this.mStorageDb.executeSimpleSQL("PRAGMA journal_mode=WAL");
+ this.mStorageDb.executeSimpleSQL("PRAGMA cache_size=-10240"); // 10 MiB
+ this.mStatements = new CalStorageStatements(this.mStorageDb);
+ this.mItemModel = CalStorageModelFactory.createInstance(
+ "cached-item",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ this.mOfflineModel = CalStorageModelFactory.createInstance(
+ "offline",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ this.mMetaModel = CalStorageModelFactory.createInstance(
+ "metadata",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ } catch (e) {
+ this.mStorageDb.logError("Error initializing statements.", e);
+ }
+ },
+
+ async shutdownDB() {
+ try {
+ this.mStatements.finalize();
+ if (this.mStorageDb) {
+ await this.mStorageDb.close();
+ this.mStorageDb = null;
+ }
+ } catch (e) {
+ cal.ERROR("Error closing storage database: " + e);
+ }
+ },
+
+ //
+ // calISyncWriteCalendar interface
+ //
+
+ setMetaData(id, value) {
+ this.mMetaModel.deleteMetaDataById(id);
+ this.mMetaModel.addMetaData(id, value);
+ },
+
+ deleteMetaData(id) {
+ this.mMetaModel.deleteMetaDataById(id);
+ },
+
+ getMetaData(id) {
+ return this.mMetaModel.getMetaData(id);
+ },
+
+ getAllMetaDataIds() {
+ return this.mMetaModel.getAllMetaData("item_id");
+ },
+
+ getAllMetaDataValues() {
+ return this.mMetaModel.getAllMetaData("value");
+ },
+
+ /**
+ * propagate the given sequence in exceptions. It may be needed by some calendar implementations
+ */
+ _propagateSequence(aItem, newSequence) {
+ if (newSequence) {
+ aItem.setProperty("SEQUENCE", newSequence);
+ } else {
+ aItem.deleteProperty("SEQUENCE");
+ }
+ let rec = aItem.recurrenceInfo;
+ if (rec) {
+ let exceptions = rec.getExceptionIds();
+ if (exceptions.length > 0) {
+ for (let exid of exceptions) {
+ let ex = rec.getExceptionFor(exid);
+ if (newSequence) {
+ ex.setProperty("SEQUENCE", newSequence);
+ } else {
+ ex.deleteProperty("SEQUENCE");
+ }
+ }
+ }
+ }
+ },
+};
diff --git a/comm/calendar/providers/storage/CalStorageDatabase.jsm b/comm/calendar/providers/storage/CalStorageDatabase.jsm
new file mode 100644
index 0000000000..b4ba1dc2b9
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageDatabase.jsm
@@ -0,0 +1,333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalStorageDatabase"];
+
+const { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+let connections = new Map();
+
+/**
+ * Checks for an existing SQLite connection to `file`, or creates a new one.
+ * Calls to `openConnectionTo` and `closeConnection` are counted so we know
+ * if a connection is no longer used.
+ *
+ * @param {nsIFile} file
+ * @returns {mozIStorageConnection}
+ */
+function openConnectionTo(file) {
+ let data = connections.get(file.path);
+
+ if (data) {
+ data.useCount++;
+ return data.connection;
+ }
+
+ let connection = Services.storage.openDatabase(file);
+ connections.set(file.path, { connection, useCount: 1 });
+ return connection;
+}
+
+/**
+ * Closes an SQLite connection if it is no longer in use.
+ *
+ * @param {mozIStorageConnection} connection
+ * @returns {Promise} - resolves when the connection is closed, or immediately
+ * if the database is still in use.
+ */
+function closeConnection(connection, forceClosed) {
+ let file = connection.databaseFile;
+ let data = connections.get(file.path);
+
+ if (forceClosed || !data || --data.useCount == 0) {
+ return new Promise(resolve => {
+ connection.asyncClose({
+ complete() {
+ resolve();
+ },
+ });
+ connections.delete(file.path);
+ });
+ }
+
+ return Promise.resolve();
+}
+
+// Clean up all open databases at shutdown. All storage statements must be closed by now,
+// which CalStorageCalendar does during profile-change-teardown.
+AsyncShutdown.profileBeforeChange.addBlocker("Calendar: closing databases", async () => {
+ let promises = [];
+ for (let data of connections.values()) {
+ promises.push(closeConnection(data.connection, true));
+ }
+ await Promise.allSettled(promises);
+});
+
+/**
+ * CalStorageDatabase is a mozIStorageAsyncConnection wrapper used by the
+ * storage calendar.
+ */
+class CalStorageDatabase {
+ /**
+ * @type {mozIStorageAsyncConnection}
+ */
+ db = null;
+
+ /**
+ * @type {string}
+ */
+ calendarId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ lastStatement = null;
+
+ /**
+ * @param {mozIStorageAsyncConnection} db
+ * @param {string} calendarId
+ */
+ constructor(db, calendarId) {
+ this.db = db;
+ this.calendarId = calendarId;
+ }
+
+ /**
+ * Initializes a CalStorageDatabase using the provided nsIURI and calendar
+ * id.
+ *
+ * @param {nsIURI} uri
+ * @param {string} calendarId
+ *
+ * @returns {CalStorageDatabase}
+ */
+ static connect(uri, calendarId) {
+ if (uri.schemeIs("file")) {
+ let fileURL = uri.QueryInterface(Ci.nsIFileURL);
+
+ if (!fileURL) {
+ throw new Components.Exception("Invalid file", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ // open the database
+ return new CalStorageDatabase(openConnectionTo(fileURL.file), calendarId);
+ } else if (uri.schemeIs("moz-storage-calendar")) {
+ // New style uri, no need for migration here
+ let localDB = cal.provider.getCalendarDirectory();
+ localDB.append("local.sqlite");
+
+ if (!localDB.exists()) {
+ // This can happen with a database upgrade and the "too new schema" situation.
+ localDB.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o700);
+ }
+
+ return new CalStorageDatabase(openConnectionTo(localDB), calendarId);
+ }
+ throw new Components.Exception("Invalid Scheme " + uri.spec);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns {mozIStorageAsyncStatement}
+ */
+ createAsyncStatement(sql) {
+ return this.db.createAsyncStatement(sql);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns {mozIStorageStatement}
+ */
+ createStatement(sql) {
+ return this.db.createStatement(sql);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns
+ */
+ executeSimpleSQL(sql) {
+ return this.db.executeSimpleSQL(sql);
+ }
+
+ /**
+ * Takes care of necessary preparations for most of our statements.
+ *
+ * @param {mozIStorageAsyncStatement} aStmt
+ */
+ prepareStatement(aStmt) {
+ try {
+ aStmt.params.cal_id = this.calendarId;
+ this.lastStatement = aStmt;
+ } catch (e) {
+ this.logError("prepareStatement exception", e);
+ }
+ return aStmt;
+ }
+
+ /**
+ * Executes a statement using an item as a parameter.
+ *
+ * @param {mozIStorageStatement} stmt - The statement to execute.
+ * @param {string} idParam - The name of the parameter referring to the item id.
+ * @param {string} id - The id of the item.
+ */
+ executeSyncItemStatement(aStmt, aIdParam, aId) {
+ try {
+ aStmt.params.cal_id = this.calendarId;
+ aStmt.params[aIdParam] = aId;
+ aStmt.executeStep();
+ } catch (e) {
+ this.logError("executeSyncItemStatement exception", e);
+ throw e;
+ } finally {
+ aStmt.reset();
+ }
+ }
+
+ prepareAsyncStatement(aStmts, aStmt) {
+ if (!aStmts.has(aStmt)) {
+ aStmts.set(aStmt, aStmt.newBindingParamsArray());
+ }
+ return aStmts.get(aStmt);
+ }
+
+ prepareAsyncParams(aArray) {
+ let params = aArray.newBindingParams();
+ params.bindByName("cal_id", this.calendarId);
+ return params;
+ }
+
+ /**
+ * Executes one or more SQL statemets.
+ *
+ * @param {mozIStorageAsyncStatement|mozIStorageAsyncStatement[]} aStmts
+ * @param {Function} aCallback
+ */
+ executeAsync(aStmts, aCallback) {
+ if (!Array.isArray(aStmts)) {
+ aStmts = [aStmts];
+ }
+
+ let self = this;
+ return new Promise((resolve, reject) => {
+ this.db.executeAsync(aStmts, {
+ resultPromises: [],
+
+ handleResult(aResultSet) {
+ this.resultPromises.push(this.handleResultInner(aResultSet));
+ },
+ async handleResultInner(aResultSet) {
+ let row = aResultSet.getNextRow();
+ while (row) {
+ try {
+ await aCallback(row);
+ } catch (ex) {
+ this.handleError(ex);
+ }
+ if (this.finishCalled) {
+ self.logError(
+ "Async query completed before all rows consumed. This should never happen.",
+ null
+ );
+ }
+ row = aResultSet.getNextRow();
+ }
+ },
+ handleError(aError) {
+ cal.WARN(aError);
+ },
+ async handleCompletion(aReason) {
+ await Promise.all(this.resultPromises);
+
+ switch (aReason) {
+ case Ci.mozIStorageStatementCallback.REASON_FINISHED:
+ this.finishCalled = true;
+ resolve();
+ break;
+ case Ci.mozIStorageStatementCallback.REASON_CANCELLED:
+ reject(Components.Exception("async statement was cancelled", Cr.NS_ERROR_ABORT));
+ break;
+ default:
+ reject(Components.Exception("error executing async statement", Cr.NS_ERROR_FAILURE));
+ break;
+ }
+ },
+ });
+ });
+ }
+
+ prepareItemStatement(aStmts, aStmt, aIdParam, aId) {
+ aStmt.params.cal_id = this.calendarId;
+ aStmt.params[aIdParam] = aId;
+ aStmts.push(aStmt);
+ }
+
+ /**
+ * Internal logging function that should be called on any database error,
+ * it will log as much info as possible about the database context and
+ * last statement so the problem can be investigated more easily.
+ *
+ * @param message Error message to log.
+ * @param exception Exception that caused the error.
+ */
+ logError(message, exception) {
+ let logMessage = "Message: " + message;
+ if (this.db) {
+ if (this.db.connectionReady) {
+ logMessage += "\nConnection Ready: " + this.db.connectionReady;
+ }
+ if (this.db.lastError) {
+ logMessage += "\nLast DB Error Number: " + this.db.lastError;
+ }
+ if (this.db.lastErrorString) {
+ logMessage += "\nLast DB Error Message: " + this.db.lastErrorString;
+ }
+ if (this.db.databaseFile) {
+ logMessage += "\nDatabase File: " + this.db.databaseFile.path;
+ }
+ if (this.db.lastInsertRowId) {
+ logMessage += "\nLast Insert Row Id: " + this.db.lastInsertRowId;
+ }
+ if (this.db.transactionInProgress) {
+ logMessage += "\nTransaction In Progress: " + this.db.transactionInProgress;
+ }
+ }
+
+ if (this.lastStatement) {
+ logMessage += "\nLast DB Statement: " + this.lastStatement;
+ // Async statements do not allow enumeration of parameters.
+ if (this.lastStatement instanceof Ci.mozIStorageStatement && this.lastStatement.params) {
+ for (let param in this.lastStatement.params) {
+ logMessage +=
+ "\nLast Statement param [" + param + "]: " + this.lastStatement.params[param];
+ }
+ }
+ }
+
+ if (exception) {
+ logMessage += "\nException: " + exception;
+ }
+ cal.ERROR("[calStorageCalendar] " + logMessage + "\n" + cal.STACK(10));
+ }
+
+ /**
+ * Close the underlying db connection.
+ */
+ close() {
+ closeConnection(this.db);
+ this.db = null;
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageItemModel.jsm b/comm/calendar/providers/storage/CalStorageItemModel.jsm
new file mode 100644
index 0000000000..a25e5bbd46
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageItemModel.jsm
@@ -0,0 +1,1374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalStorageItemModel"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CAL_ITEM_FLAG, newDateTime } = ChromeUtils.import(
+ "resource:///modules/calendar/calStorageHelpers.jsm"
+);
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+const cICL = Ci.calIChangeLog;
+const USECS_PER_SECOND = 1000000;
+const DEFAULT_START_TIME = -9223372036854776000;
+
+// endTime needs to be the max value a PRTime can be
+const DEFAULT_END_TIME = 9223372036854776000;
+
+// Calls to get items from the database await this Promise. In normal operation
+// the Promise resolves after most application start-up operations, so that we
+// don't start hitting the database during start-up. Fox XPCShell tests, normal
+// start-up doesn't happen, so we just resolve the Promise instantly.
+let startupPromise;
+if (Services.appinfo.name == "xpcshell") {
+ startupPromise = Promise.resolve();
+} else {
+ const { MailGlue } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+ startupPromise = MailGlue.afterStartUp;
+}
+
+/**
+ * CalStorageItemModel provides methods for manipulating item data.
+ */
+class CalStorageItemModel extends CalStorageModelBase {
+ /**
+ * calCachedCalendar modifies the superCalendar property so this is made
+ * lazy.
+ *
+ * @type {calISchedulingSupport}
+ */
+ get #schedulingSupport() {
+ return (
+ (this.calendar.superCalendar.supportsScheduling &&
+ this.calendar.superCalendar.getSchedulingSupport()) ||
+ null
+ );
+ }
+
+ /**
+ * Update the item passed.
+ *
+ * @param {calIItemBase} item - The newest version of the item.
+ * @param {calIItemBase} oldItem - The previous version of the item.
+ */
+ async updateItem(item, olditem) {
+ cal.ASSERT(!item.recurrenceId, "no parent item passed!", true);
+ await this.deleteItemById(olditem.id, true);
+ await this.addItem(item);
+ }
+
+ /**
+ * Object containing the parameters for executing a DB query.
+ *
+ * @typedef {object} CalStorageQuery
+ * @property {CalStorageQueryFilter} filter
+ * @property {calIDateTime} rangeStart
+ * @property {calIDateTime?} rangeEnd
+ * @property {number} count
+ */
+
+ /**
+ * Object indicating types and state of items to return.
+ *
+ * @typedef {object} CalStorageQueryFilter
+ * @property {boolean} wantUnrespondedInvitations
+ * @property {boolean} wantEvents
+ * @property {boolean} wantTodos
+ * @property {boolean} asOccurrences
+ * @property {boolean} wantOfflineDeletedItems
+ * @property {boolean} wantOfflineCreatedItems
+ * @property {boolean} wantOfflineModifiedItems
+ * @property {boolean} itemCompletedFilter
+ * @property {boolean} itemNotCompletedFilter
+ */
+
+ /**
+ * Retrieves one or more items from the database based on the query provided.
+ * See the definition of CalStorageQuery for valid query parameters.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(query) {
+ let { filters, count } = query;
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ if (filters) {
+ if (filters.wantEvents) {
+ for await (let value of cal.iterate.streamValues(self.#getEvents(query))) {
+ controller.enqueue(value);
+ }
+ }
+
+ count = count && count - controller.count;
+ if (filters.wantTodos && (!count || count > 0)) {
+ for await (let value of cal.iterate.streamValues(
+ self.#getTodos({ ...query, count })
+ )) {
+ controller.enqueue(value);
+ }
+ }
+ controller.close();
+ }
+ },
+ }
+ );
+ }
+
+ /**
+ * Queries the database for calIEvent records providing them in a streaming
+ * fashion.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIEvent>}
+ */
+ #getEvents(query) {
+ let { filters, rangeStart, rangeEnd } = query;
+ let startTime = DEFAULT_START_TIME;
+ let endTime = DEFAULT_END_TIME;
+
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+ if (rangeEnd) {
+ endTime = rangeEnd.nativeTime;
+ }
+
+ let params; // stmt params
+ let requestedOfflineJournal = null;
+
+ if (filters.wantOfflineDeletedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ } else if (filters.wantOfflineCreatedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if (filters.wantOfflineModifiedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ query.count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ await startupPromise;
+ // first get non-recurring events that happen to fall within the range
+ try {
+ self.db.prepareStatement(self.statements.mSelectNonRecurringEventsByRange);
+ params = self.statements.mSelectNonRecurringEventsByRange.params;
+ params.range_start = startTime;
+ params.range_end = endTime;
+ params.start_offset = rangeStart ? rangeStart.timezoneOffset * USECS_PER_SECOND : 0;
+ params.end_offset = rangeEnd ? rangeEnd.timezoneOffset * USECS_PER_SECOND : 0;
+ params.offline_journal = requestedOfflineJournal;
+
+ await self.db.executeAsync(
+ self.statements.mSelectNonRecurringEventsByRange,
+ async row => {
+ let event = self.#expandOccurrences(
+ await self.getEventFromRow(row),
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters
+ );
+ controller.enqueue(event);
+ }
+ );
+ } catch (e) {
+ self.db.logError("Error selecting non recurring events by range!\n", e);
+ }
+
+ if (!controller.maxTotalItemsReached) {
+ // Process the recurring events
+ let [recEvents, recEventFlags] = await self.getFullRecurringEventAndFlagMaps();
+ for (let [id, evitem] of recEvents.entries()) {
+ let cachedJournalFlag = recEventFlags.get(id);
+ // No need to return flagged unless asked i.e. requestedOfflineJournal == cachedJournalFlag
+ // Return created and modified offline records if requestedOfflineJournal is null alongwith events that have no flag
+ if (
+ (requestedOfflineJournal == null &&
+ cachedJournalFlag != cICL.OFFLINE_FLAG_DELETED_RECORD) ||
+ (requestedOfflineJournal != null && cachedJournalFlag == requestedOfflineJournal)
+ ) {
+ controller.enqueue(
+ self.#expandOccurrences(evitem, startTime, rangeStart, rangeEnd, filters)
+ );
+ if (controller.maxTotalItemsReached) {
+ break;
+ }
+ }
+ }
+ }
+ controller.close();
+ },
+ }
+ );
+ }
+
+ /**
+ * Queries the database for calITodo records providing them in a streaming
+ * fashion.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calITodo>}
+ */
+ #getTodos(query) {
+ let { filters, rangeStart, rangeEnd } = query;
+ let startTime = DEFAULT_START_TIME;
+ let endTime = DEFAULT_END_TIME;
+
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+ if (rangeEnd) {
+ endTime = rangeEnd.nativeTime;
+ }
+
+ let params; // stmt params
+ let requestedOfflineJournal = null;
+
+ if (filters.wantOfflineCreatedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if (filters.wantOfflineDeletedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ } else if (filters.wantOfflineModifiedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+
+ let checkCompleted = item =>
+ item.isCompleted ? filters.itemCompletedFilter : filters.itemNotCompletedFilter;
+
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ query.count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ await startupPromise;
+ // first get non-recurring todos that happen to fall within the range
+ try {
+ self.db.prepareStatement(self.statements.mSelectNonRecurringTodosByRange);
+ params = self.statements.mSelectNonRecurringTodosByRange.params;
+ params.range_start = startTime;
+ params.range_end = endTime;
+ params.start_offset = rangeStart ? rangeStart.timezoneOffset * USECS_PER_SECOND : 0;
+ params.end_offset = rangeEnd ? rangeEnd.timezoneOffset * USECS_PER_SECOND : 0;
+ params.offline_journal = requestedOfflineJournal;
+
+ await self.db.executeAsync(
+ self.statements.mSelectNonRecurringTodosByRange,
+ async row => {
+ let todo = self.#expandOccurrences(
+ await self.getTodoFromRow(row),
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters,
+ checkCompleted
+ );
+ controller.enqueue(todo);
+ }
+ );
+ } catch (e) {
+ self.db.logError("Error selecting non recurring todos by range", e);
+ }
+
+ if (!controller.maxTotalItemsReached) {
+ // Note: Reading the code, completed *occurrences* seems to be broken, because
+ // only the parent item has been filtered; I fixed that.
+ // Moreover item.todo_complete etc seems to be a leftover...
+
+ // process the recurring todos
+ let [recTodos, recTodoFlags] = await self.getFullRecurringTodoAndFlagMaps();
+ for (let [id, todoitem] of recTodos) {
+ let cachedJournalFlag = recTodoFlags.get(id);
+ if (
+ (requestedOfflineJournal == null &&
+ (cachedJournalFlag == cICL.OFFLINE_FLAG_MODIFIED_RECORD ||
+ cachedJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ cachedJournalFlag == null)) ||
+ (requestedOfflineJournal != null && cachedJournalFlag == requestedOfflineJournal)
+ ) {
+ controller.enqueue(
+ self.#expandOccurrences(
+ todoitem,
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters,
+ checkCompleted
+ )
+ );
+ if (controller.maxTotalItemsReached) {
+ break;
+ }
+ }
+ }
+ }
+ controller.close();
+ },
+ }
+ );
+ }
+
+ #checkUnrespondedInvitation(item) {
+ let att = this.#schedulingSupport.getInvitedAttendee(item);
+ return att && att.participationStatus == "NEEDS-ACTION";
+ }
+
+ #expandOccurrences(item, startTime, rangeStart, rangeEnd, filters, optionalFilterFunc) {
+ if (item.recurrenceInfo && item.recurrenceInfo.recurrenceEndDate < startTime) {
+ return [];
+ }
+
+ let expandedItems = [];
+ if (item.recurrenceInfo && filters.asOccurrences) {
+ // If the item is recurring, get all occurrences that fall in
+ // the range. If the item doesn't fall into the range at all,
+ // this expands to 0 items.
+ expandedItems = item.recurrenceInfo.getOccurrences(rangeStart, rangeEnd, 0);
+ if (filters.wantUnrespondedInvitations) {
+ expandedItems = expandedItems.filter(item => this.#checkUnrespondedInvitation(item));
+ }
+ } else if (
+ (!filters.wantUnrespondedInvitations || this.#checkUnrespondedInvitation(item)) &&
+ cal.item.checkIfInRange(item, rangeStart, rangeEnd)
+ ) {
+ // If no occurrences are wanted, check only the parent item.
+ // This will be changed with bug 416975.
+ expandedItems = [item];
+ }
+
+ if (expandedItems.length) {
+ if (optionalFilterFunc) {
+ expandedItems = expandedItems.filter(optionalFilterFunc);
+ }
+ }
+ return expandedItems;
+ }
+
+ /**
+ * Read in the common ItemBase attributes from aDBRow, and stick
+ * them on item.
+ *
+ * @param {mozIStorageRow} row
+ * @param {calIItemBase} item
+ */
+ #getItemBaseFromRow(row, item) {
+ item.calendar = this.calendar.superCalendar;
+ item.id = row.getResultByName("id");
+ if (row.getResultByName("title")) {
+ item.title = row.getResultByName("title");
+ }
+ if (row.getResultByName("priority")) {
+ item.priority = row.getResultByName("priority");
+ }
+ if (row.getResultByName("privacy")) {
+ item.privacy = row.getResultByName("privacy");
+ }
+ if (row.getResultByName("ical_status")) {
+ item.status = row.getResultByName("ical_status");
+ }
+
+ if (row.getResultByName("alarm_last_ack")) {
+ // alarm acks are always in utc
+ item.alarmLastAck = newDateTime(row.getResultByName("alarm_last_ack"), "UTC");
+ }
+
+ if (row.getResultByName("recurrence_id")) {
+ item.recurrenceId = newDateTime(
+ row.getResultByName("recurrence_id"),
+ row.getResultByName("recurrence_id_tz")
+ );
+ if ((row.getResultByName("flags") & CAL_ITEM_FLAG.RECURRENCE_ID_ALLDAY) != 0) {
+ item.recurrenceId.isDate = true;
+ }
+ }
+
+ if (row.getResultByName("time_created")) {
+ item.setProperty("CREATED", newDateTime(row.getResultByName("time_created"), "UTC"));
+ }
+
+ // This must be done last because the setting of any other property
+ // after this would overwrite it again.
+ if (row.getResultByName("last_modified")) {
+ item.setProperty("LAST-MODIFIED", newDateTime(row.getResultByName("last_modified"), "UTC"));
+ }
+ }
+
+ /**
+ * @callback OnItemRowCallback
+ * @param {string} id - The id of the item fetched from the row.
+ */
+
+ /**
+ * Provides all recurring events along with offline flag values for each event.
+ *
+ * @param {OnItemRowCallback} [callback] - If provided, will be called on each row
+ * fetched.
+ * @returns {Promise<[Map<string, calIEvent>, Map<string, number>]>}
+ */
+ async getRecurringEventAndFlagMaps(callback) {
+ await startupPromise;
+ let events = new Map();
+ let flags = new Map();
+ this.db.prepareStatement(this.statements.mSelectEventsWithRecurrence);
+ await this.db.executeAsync(this.statements.mSelectEventsWithRecurrence, async row => {
+ let item_id = row.getResultByName("id");
+ if (callback) {
+ callback(item_id);
+ }
+ let item = await this.getEventFromRow(row, false);
+ events.set(item_id, item);
+ flags.set(item_id, row.getResultByName("offline_journal") || null);
+ });
+ return [events, flags];
+ }
+
+ /**
+ * Provides all recurring events with additional data populated along with
+ * offline flags values for each event.
+ *
+ * @returns {Promise<[Map<string, calIEvent>, Map<string, number>]>}
+ */
+ async getFullRecurringEventAndFlagMaps() {
+ let [events, flags] = await this.getRecurringEventAndFlagMaps();
+ return [await this.getAdditionalDataForItemMap(events), flags];
+ }
+
+ /**
+ * Provides all recurring todos along with offline flag values for each event.
+ *
+ * @param {OnItemRowCallback} [callback] - If provided, will be called on each row
+ * fetched.
+ *
+ * @returns {Promise<[Map<string, calITodo>, Map<string, number>]>}
+ */
+ async getRecurringTodoAndFlagMaps(callback) {
+ await startupPromise;
+ let todos = new Map();
+ let flags = new Map();
+ this.db.prepareStatement(this.statements.mSelectTodosWithRecurrence);
+ await this.db.executeAsync(this.statements.mSelectTodosWithRecurrence, async row => {
+ let item_id = row.getResultByName("id");
+ if (callback) {
+ callback(item_id);
+ }
+ let item = await this.getTodoFromRow(row, false);
+ todos.set(item_id, item);
+ flags.set(item_id, row.getResultByName("offline_journal") || null);
+ });
+ return [todos, flags];
+ }
+
+ /**
+ * Provides all recurring todos with additional data populated along with
+ * offline flags values for each todo.
+ *
+ * @returns {Promise<[Map<string, calITodo>, Map<string, number>]>}
+ */
+ async getFullRecurringTodoAndFlagMaps() {
+ let [todos, flags] = await this.getRecurringTodoAndFlagMaps();
+ return [await this.getAdditionalDataForItemMap(todos), flags];
+ }
+
+ /**
+ * The `icalString` database fields could be stored with or without lines
+ * folded, but if this raw data is passed to ical.js it misinterprets the
+ * white-space as significant. Strip it out as the data is fetched.
+ *
+ * @param {mozIStorageRow} row
+ * @returns {string}
+ */
+ #unfoldIcalString(row) {
+ return row.getResultByName("icalString").replaceAll("\r\n ", "");
+ }
+
+ /**
+ * Populates additional data for a Map of items. This method is overridden in
+ * CalStorageCachedItemModel to allow the todos to be loaded from the cache.
+ *
+ * @param {Map<string, calIItem>} itemMap
+ *
+ * @returns {Promise<Map<string, calIItem>>} The original Map with items modified.
+ */
+ async getAdditionalDataForItemMap(itemsMap) {
+ await startupPromise;
+ //NOTE: There seems to be a bug in the SQLite subsystem that causes callers
+ //awaiting on this method to continue prematurely. This can cause unexpected
+ //behaviour. After investigating, it appears triggering the bug is related
+ //to the number of queries executed here.
+ this.db.prepareStatement(this.statements.mSelectAllAttendees);
+ await this.db.executeAsync(this.statements.mSelectAllAttendees, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let attendee = new lazy.CalAttendee(this.#unfoldIcalString(row));
+ if (attendee && attendee.id) {
+ if (attendee.isOrganizer) {
+ item.organizer = attendee;
+ } else {
+ item.addAttendee(attendee);
+ }
+ } else {
+ cal.WARN(
+ "[calStorageCalendar] Skipping invalid attendee for item '" +
+ item.title +
+ "' (" +
+ item.id +
+ ")."
+ );
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllProperties);
+ await this.db.executeAsync(this.statements.mSelectAllProperties, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let name = row.getResultByName("key");
+ switch (name) {
+ case "DURATION":
+ // for events DTEND/DUE is enforced by calEvent/calTodo, so suppress DURATION:
+ break;
+ case "CATEGORIES": {
+ let cats = cal.category.stringToArray(row.getResultByName("value"));
+ item.setCategories(cats);
+ break;
+ }
+ default:
+ let value = row.getResultByName("value");
+ item.setProperty(name, value);
+ break;
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllParameters);
+ await this.db.executeAsync(this.statements.mSelectAllParameters, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let prop = row.getResultByName("key1");
+ let param = row.getResultByName("key2");
+ let value = row.getResultByName("value");
+ item.setPropertyParameter(prop, param, value);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllRecurrences);
+ await this.db.executeAsync(this.statements.mSelectAllRecurrences, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let recInfo = item.recurrenceInfo;
+ if (!recInfo) {
+ recInfo = new lazy.CalRecurrenceInfo(item);
+ item.recurrenceInfo = recInfo;
+ }
+
+ let ritem = this.#getRecurrenceItemFromRow(row);
+ recInfo.appendRecurrenceItem(ritem);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllEventExceptions);
+ await this.db.executeAsync(this.statements.mSelectAllEventExceptions, async row => {
+ let item = itemsMap.get(row.getResultByName("id"));
+ if (!item) {
+ return;
+ }
+
+ let rec = item.recurrenceInfo;
+ let exc = await this.getEventFromRow(row);
+ rec.modifyException(exc, true);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllTodoExceptions);
+ await this.db.executeAsync(this.statements.mSelectAllTodoExceptions, async row => {
+ let item = itemsMap.get(row.getResultByName("id"));
+ if (!item) {
+ return;
+ }
+
+ let rec = item.recurrenceInfo;
+ let exc = await this.getTodoFromRow(row);
+ rec.modifyException(exc, true);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllAttachments);
+ await this.db.executeAsync(this.statements.mSelectAllAttachments, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addAttachment(new lazy.CalAttachment(this.#unfoldIcalString(row)));
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllRelations);
+ await this.db.executeAsync(this.statements.mSelectAllRelations, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addRelation(new lazy.CalRelation(this.#unfoldIcalString(row)));
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllAlarms);
+ await this.db.executeAsync(this.statements.mSelectAllAlarms, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addAlarm(new lazy.CalAlarm(this.#unfoldIcalString(row)));
+ }
+ });
+
+ for (let item of itemsMap.values()) {
+ this.#fixGoogleCalendarDescriptionIfNeeded(item);
+ item.makeImmutable();
+ }
+ return itemsMap;
+ }
+
+ /**
+ * For items that were cached or stored in previous versions,
+ * put Google's HTML description in the right place.
+ *
+ * @param {calIItemBase} item
+ */
+ #fixGoogleCalendarDescriptionIfNeeded(item) {
+ if (item.id && item.id.endsWith("@google.com")) {
+ let description = item.getProperty("DESCRIPTION");
+ if (description) {
+ let altrep = item.getPropertyParameter("DESCRIPTION", "ALTREP");
+ if (!altrep) {
+ cal.view.fixGoogleCalendarDescription(item);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param {mozIStorageRow} row
+ * @param {boolean} getAdditionalData
+ */
+ async getEventFromRow(row, getAdditionalData = true) {
+ let item = new lazy.CalEvent();
+ let flags = row.getResultByName("flags");
+
+ if (row.getResultByName("event_start")) {
+ item.startDate = newDateTime(
+ row.getResultByName("event_start"),
+ row.getResultByName("event_start_tz")
+ );
+ }
+ if (row.getResultByName("event_end")) {
+ item.endDate = newDateTime(
+ row.getResultByName("event_end"),
+ row.getResultByName("event_end_tz")
+ );
+ }
+ if (row.getResultByName("event_stamp")) {
+ item.setProperty("DTSTAMP", newDateTime(row.getResultByName("event_stamp"), "UTC"));
+ }
+ if (flags & CAL_ITEM_FLAG.EVENT_ALLDAY) {
+ item.startDate.isDate = true;
+ item.endDate.isDate = true;
+ }
+
+ // This must be done last to keep the modification time intact.
+ this.#getItemBaseFromRow(row, item);
+ if (getAdditionalData) {
+ await this.#getAdditionalDataForItem(item, row.getResultByName("flags"));
+ item.makeImmutable();
+ }
+ return item;
+ }
+
+ /**
+ * @param {mozIStorageRow} row
+ * @param {boolean} getAdditionalData
+ */
+ async getTodoFromRow(row, getAdditionalData = true) {
+ let item = new lazy.CalTodo();
+ let flags = row.getResultByName("flags");
+
+ if (row.getResultByName("todo_entry")) {
+ item.entryDate = newDateTime(
+ row.getResultByName("todo_entry"),
+ row.getResultByName("todo_entry_tz")
+ );
+ }
+ if (row.getResultByName("todo_due")) {
+ item.dueDate = newDateTime(
+ row.getResultByName("todo_due"),
+ row.getResultByName("todo_due_tz")
+ );
+ }
+ if (row.getResultByName("todo_stamp")) {
+ item.setProperty("DTSTAMP", newDateTime(row.getResultByName("todo_stamp"), "UTC"));
+ }
+ if (row.getResultByName("todo_completed")) {
+ item.completedDate = newDateTime(
+ row.getResultByName("todo_completed"),
+ row.getResultByName("todo_completed_tz")
+ );
+ }
+ if (row.getResultByName("todo_complete")) {
+ item.percentComplete = row.getResultByName("todo_complete");
+ }
+ if (flags & CAL_ITEM_FLAG.EVENT_ALLDAY) {
+ if (item.entryDate) {
+ item.entryDate.isDate = true;
+ }
+ if (item.dueDate) {
+ item.dueDate.isDate = true;
+ }
+ }
+
+ // This must be done last to keep the modification time intact.
+ this.#getItemBaseFromRow(row, item);
+ if (getAdditionalData) {
+ await this.#getAdditionalDataForItem(item, row.getResultByName("flags"));
+ item.makeImmutable();
+ }
+ return item;
+ }
+
+ /**
+ * After we get the base item, we need to check if we need to pull in
+ * any extra data from other tables. We do that here.
+ */
+ async #getAdditionalDataForItem(item, flags) {
+ // This is needed to keep the modification time intact.
+ let savedLastModifiedTime = item.lastModifiedTime;
+
+ if (flags & CAL_ITEM_FLAG.HAS_ATTENDEES) {
+ let selectItem = null;
+ if (item.recurrenceId == null) {
+ selectItem = this.statements.mSelectAttendeesForItem;
+ } else {
+ selectItem = this.statements.mSelectAttendeesForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectItem, "recurrence_id", item.recurrenceId);
+ }
+
+ try {
+ this.db.prepareStatement(selectItem);
+ selectItem.params.item_id = item.id;
+ await this.db.executeAsync(selectItem, row => {
+ let attendee = new lazy.CalAttendee(this.#unfoldIcalString(row));
+ if (attendee && attendee.id) {
+ if (attendee.isOrganizer) {
+ item.organizer = attendee;
+ } else {
+ item.addAttendee(attendee);
+ }
+ } else {
+ cal.WARN(
+ `[calStorageCalendar] Skipping invalid attendee for item '${item.title}' (${item.id}).`
+ );
+ }
+ });
+ } catch (e) {
+ this.db.logError(`Error getting attendees for item '${item.title}' (${item.id})!`, e);
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_PROPERTIES) {
+ let selectItem = null;
+ let selectParam = null;
+ if (item.recurrenceId == null) {
+ selectItem = this.statements.mSelectPropertiesForItem;
+ selectParam = this.statements.mSelectParametersForItem;
+ } else {
+ selectItem = this.statements.mSelectPropertiesForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectItem, "recurrence_id", item.recurrenceId);
+ selectParam = this.statements.mSelectParametersForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectParam, "recurrence_id", item.recurrenceId);
+ }
+
+ try {
+ this.db.prepareStatement(selectItem);
+ selectItem.params.item_id = item.id;
+ await this.db.executeAsync(selectItem, row => {
+ let name = row.getResultByName("key");
+ switch (name) {
+ case "DURATION":
+ // for events DTEND/DUE is enforced by calEvent/calTodo, so suppress DURATION:
+ break;
+ case "CATEGORIES": {
+ let cats = cal.category.stringToArray(row.getResultByName("value"));
+ item.setCategories(cats);
+ break;
+ }
+ default:
+ let value = row.getResultByName("value");
+ item.setProperty(name, value);
+ break;
+ }
+ });
+
+ this.db.prepareStatement(selectParam);
+ selectParam.params.item_id = item.id;
+ await this.db.executeAsync(selectParam, row => {
+ let prop = row.getResultByName("key1");
+ let param = row.getResultByName("key2");
+ let value = row.getResultByName("value");
+ item.setPropertyParameter(prop, param, value);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting extra properties for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_RECURRENCE) {
+ if (item.recurrenceId) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let recInfo = new lazy.CalRecurrenceInfo(item);
+ item.recurrenceInfo = recInfo;
+
+ try {
+ this.db.prepareStatement(this.statements.mSelectRecurrenceForItem);
+ this.statements.mSelectRecurrenceForItem.params.item_id = item.id;
+ await this.db.executeAsync(this.statements.mSelectRecurrenceForItem, row => {
+ let ritem = this.#getRecurrenceItemFromRow(row);
+ recInfo.appendRecurrenceItem(ritem);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting recurrence for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_EXCEPTIONS) {
+ // it's safe that we don't run into this branch again for exceptions
+ // (getAdditionalDataForItem->get[Event|Todo]FromRow->getAdditionalDataForItem):
+ // every excepton has a recurrenceId and isn't flagged as CAL_ITEM_FLAG.HAS_EXCEPTIONS
+ if (item.recurrenceId) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let rec = item.recurrenceInfo;
+
+ if (item.isEvent()) {
+ this.statements.mSelectEventExceptions.params.id = item.id;
+ this.db.prepareStatement(this.statements.mSelectEventExceptions);
+ try {
+ await this.db.executeAsync(this.statements.mSelectEventExceptions, async row => {
+ let exc = await this.getEventFromRow(row, false);
+ rec.modifyException(exc, true);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting exceptions for event '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ } else if (item.isTodo()) {
+ this.statements.mSelectTodoExceptions.params.id = item.id;
+ this.db.prepareStatement(this.statements.mSelectTodoExceptions);
+ try {
+ await this.db.executeAsync(this.statements.mSelectTodoExceptions, async row => {
+ let exc = await this.getTodoFromRow(row, false);
+ rec.modifyException(exc, true);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting exceptions for task '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_ATTACHMENTS) {
+ let selectAttachment = this.statements.mSelectAttachmentsForItem;
+ if (item.recurrenceId != null) {
+ selectAttachment = this.statements.mSelectAttachmentsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectAttachment, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ this.db.prepareStatement(selectAttachment);
+ selectAttachment.params.item_id = item.id;
+ await this.db.executeAsync(selectAttachment, row => {
+ item.addAttachment(new lazy.CalAttachment(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting attachments for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_RELATIONS) {
+ let selectRelation = this.statements.mSelectRelationsForItem;
+ if (item.recurrenceId != null) {
+ selectRelation = this.statements.mSelectRelationsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectRelation, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ this.db.prepareStatement(selectRelation);
+ selectRelation.params.item_id = item.id;
+ await this.db.executeAsync(selectRelation, row => {
+ item.addRelation(new lazy.CalRelation(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting relations for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_ALARMS) {
+ let selectAlarm = this.statements.mSelectAlarmsForItem;
+ if (item.recurrenceId != null) {
+ selectAlarm = this.statements.mSelectAlarmsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectAlarm, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ selectAlarm.params.item_id = item.id;
+ this.db.prepareStatement(selectAlarm);
+ await this.db.executeAsync(selectAlarm, row => {
+ item.addAlarm(new lazy.CalAlarm(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting alarms for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ this.#fixGoogleCalendarDescriptionIfNeeded(item);
+ // Restore the saved modification time
+ item.setProperty("LAST-MODIFIED", savedLastModifiedTime);
+ }
+
+ #getRecurrenceItemFromRow(row) {
+ let ritem;
+ let prop = cal.icsService.createIcalPropertyFromString(this.#unfoldIcalString(row));
+ switch (prop.propertyName) {
+ case "RDATE":
+ case "EXDATE":
+ ritem = cal.createRecurrenceDate();
+ break;
+ case "RRULE":
+ case "EXRULE":
+ ritem = cal.createRecurrenceRule();
+ break;
+ default:
+ throw new Error("Unknown recurrence item: " + prop.propertyName);
+ }
+
+ ritem.icalProperty = prop;
+ return ritem;
+ }
+
+ /**
+ * Get an item from db given its id.
+ *
+ * @param {string} aID
+ */
+ async getItemById(aID) {
+ let item = null;
+ try {
+ // try events first
+ this.db.prepareStatement(this.statements.mSelectEvent);
+ this.statements.mSelectEvent.params.id = aID;
+ await this.db.executeAsync(this.statements.mSelectEvent, async row => {
+ item = await this.getEventFromRow(row);
+ });
+ } catch (e) {
+ this.db.logError("Error selecting item by id " + aID + "!", e);
+ }
+
+ // try todo if event fails
+ if (!item) {
+ try {
+ this.db.prepareStatement(this.statements.mSelectTodo);
+ this.statements.mSelectTodo.params.id = aID;
+ await this.db.executeAsync(this.statements.mSelectTodo, async row => {
+ item = await this.getTodoFromRow(row);
+ });
+ } catch (e) {
+ this.db.logError("Error selecting item by id " + aID + "!", e);
+ }
+ }
+ return item;
+ }
+
+ #setDateParamHelper(params, entryname, cdt) {
+ if (cdt) {
+ params.bindByName(entryname, cdt.nativeTime);
+ let timezone = cdt.timezone;
+ let ownTz = cal.timezoneService.getTimezone(timezone.tzid);
+ if (ownTz) {
+ // if we know that TZID, we use it
+ params.bindByName(entryname + "_tz", ownTz.tzid);
+ } else if (timezone.icalComponent) {
+ // foreign one
+ params.bindByName(entryname + "_tz", timezone.icalComponent.serializeToICS());
+ } else {
+ // timezone component missing
+ params.bindByName(entryname + "_tz", "floating");
+ }
+ } else {
+ params.bindByName(entryname, null);
+ params.bindByName(entryname + "_tz", null);
+ }
+ }
+
+ /**
+ * Adds an item to the database, the item should have an id that is not
+ * already in use.
+ *
+ * @param {calIItemBase} item
+ */
+ async addItem(item) {
+ let stmts = new Map();
+ this.#prepareItem(stmts, item);
+ for (let [stmt, array] of stmts) {
+ stmt.bindParameters(array);
+ }
+ await this.db.executeAsync([...stmts.keys()]);
+ }
+
+ // The prepare* functions prepare the database bits
+ // to write the given item type. They're to return
+ // any bits they want or'd into flags, which will be
+ // prepared for writing by #prepareEvent/#prepareTodo.
+ //
+ #prepareItem(stmts, item) {
+ let flags = 0;
+
+ flags |= this.#prepareAttendees(stmts, item);
+ flags |= this.#prepareRecurrence(stmts, item);
+ flags |= this.#prepareProperties(stmts, item);
+ flags |= this.#prepareAttachments(stmts, item);
+ flags |= this.#prepareRelations(stmts, item);
+ flags |= this.#prepareAlarms(stmts, item);
+
+ if (item.isEvent()) {
+ this.#prepareEvent(stmts, item, flags);
+ } else if (item.isTodo()) {
+ this.#prepareTodo(stmts, item, flags);
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ }
+
+ #prepareEvent(stmts, item, flags) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertEvent);
+ let params = this.db.prepareAsyncParams(array);
+
+ this.#setupItemBaseParams(item, params);
+
+ this.#setDateParamHelper(params, "event_start", item.startDate);
+ this.#setDateParamHelper(params, "event_end", item.endDate);
+ let dtstamp = item.stampTime;
+ params.bindByName("event_stamp", dtstamp && dtstamp.nativeTime);
+
+ if (item.startDate.isDate) {
+ flags |= CAL_ITEM_FLAG.EVENT_ALLDAY;
+ }
+
+ params.bindByName("flags", flags);
+
+ array.addParams(params);
+ }
+
+ #prepareTodo(stmts, item, flags) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertTodo);
+ let params = this.db.prepareAsyncParams(array);
+
+ this.#setupItemBaseParams(item, params);
+
+ this.#setDateParamHelper(params, "todo_entry", item.entryDate);
+ this.#setDateParamHelper(params, "todo_due", item.dueDate);
+ let dtstamp = item.stampTime;
+ params.bindByName("todo_stamp", dtstamp && dtstamp.nativeTime);
+ this.#setDateParamHelper(params, "todo_completed", item.getProperty("COMPLETED"));
+
+ params.bindByName("todo_complete", item.getProperty("PERCENT-COMPLETED"));
+
+ let someDate = item.entryDate || item.dueDate;
+ if (someDate && someDate.isDate) {
+ flags |= CAL_ITEM_FLAG.EVENT_ALLDAY;
+ }
+
+ params.bindByName("flags", flags);
+
+ array.addParams(params);
+ }
+
+ #setupItemBaseParams(item, params) {
+ params.bindByName("id", item.id);
+
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+
+ let tmp = item.getProperty("CREATED");
+ params.bindByName("time_created", tmp && tmp.nativeTime);
+
+ tmp = item.getProperty("LAST-MODIFIED");
+ params.bindByName("last_modified", tmp && tmp.nativeTime);
+
+ params.bindByName("title", item.getProperty("SUMMARY"));
+ params.bindByName("priority", item.getProperty("PRIORITY"));
+ params.bindByName("privacy", item.getProperty("CLASS"));
+ params.bindByName("ical_status", item.getProperty("STATUS"));
+
+ params.bindByName("alarm_last_ack", item.alarmLastAck && item.alarmLastAck.nativeTime);
+ }
+
+ #prepareAttendees(stmts, item) {
+ let attendees = item.getAttendees();
+ if (item.organizer) {
+ attendees = attendees.concat([]);
+ attendees.push(item.organizer);
+ }
+ if (attendees.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAttendee);
+ for (let att of attendees) {
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("icalString", att.icalString);
+ array.addParams(params);
+ }
+
+ return CAL_ITEM_FLAG.HAS_ATTENDEES;
+ }
+
+ return 0;
+ }
+
+ #prepareProperty(stmts, item, propName, propValue) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertProperty);
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("key", propName);
+ let wPropValue = cal.wrapInstance(propValue, Ci.calIDateTime);
+ if (wPropValue) {
+ params.bindByName("value", wPropValue.nativeTime);
+ } else {
+ try {
+ params.bindByName("value", propValue);
+ } catch (e) {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ if (e.result != Cr.NS_ERROR_ILLEGAL_VALUE) {
+ throw e;
+ }
+ params.bindByName("value", null);
+ }
+ }
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ array.addParams(params);
+ }
+
+ #prepareParameter(stmts, item, propName, paramName, propValue) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertParameter);
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("key1", propName);
+ params.bindByName("key2", paramName);
+ let wPropValue = cal.wrapInstance(propValue, Ci.calIDateTime);
+ if (wPropValue) {
+ params.bindByName("value", wPropValue.nativeTime);
+ } else {
+ try {
+ params.bindByName("value", propValue);
+ } catch (e) {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ if (e.result != Cr.NS_ERROR_ILLEGAL_VALUE) {
+ throw e;
+ }
+ params.bindByName("value", null);
+ }
+ }
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ array.addParams(params);
+ }
+
+ #prepareProperties(stmts, item) {
+ let ret = 0;
+ for (let [name, value] of item.properties) {
+ ret = CAL_ITEM_FLAG.HAS_PROPERTIES;
+ if (item.isPropertyPromoted(name)) {
+ continue;
+ }
+ this.#prepareProperty(stmts, item, name, value);
+ // Overridden parameters still enumerate even if their value is now empty.
+ if (item.hasProperty(name)) {
+ for (let param of item.getParameterNames(name)) {
+ value = item.getPropertyParameter(name, param);
+ this.#prepareParameter(stmts, item, name, param, value);
+ }
+ }
+ }
+
+ let cats = item.getCategories();
+ if (cats.length > 0) {
+ ret = CAL_ITEM_FLAG.HAS_PROPERTIES;
+ this.#prepareProperty(stmts, item, "CATEGORIES", cal.category.arrayToString(cats));
+ }
+
+ return ret;
+ }
+
+ #prepareRecurrence(stmts, item) {
+ let flags = 0;
+
+ let rec = item.recurrenceInfo;
+ if (rec) {
+ flags = CAL_ITEM_FLAG.HAS_RECURRENCE;
+ let ritems = rec.getRecurrenceItems();
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertRecurrence);
+ for (let ritem of ritems) {
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", ritem.icalString);
+ array.addParams(params);
+ }
+
+ let exceptions = rec.getExceptionIds();
+ if (exceptions.length > 0) {
+ flags |= CAL_ITEM_FLAG.HAS_EXCEPTIONS;
+
+ // we need to serialize each exid as a separate
+ // event/todo; setupItemBase will handle
+ // writing the recurrenceId for us
+ for (let exid of exceptions) {
+ let ex = rec.getExceptionFor(exid);
+ if (!ex) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ this.#prepareItem(stmts, ex);
+ }
+ }
+ } else if (item.recurrenceId && item.recurrenceId.isDate) {
+ flags |= CAL_ITEM_FLAG.RECURRENCE_ID_ALLDAY;
+ }
+
+ return flags;
+ }
+
+ #prepareAttachments(stmts, item) {
+ let attachments = item.getAttachments();
+ if (attachments && attachments.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAttachment);
+ for (let att of attachments) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", att.icalString);
+
+ array.addParams(params);
+ }
+ return CAL_ITEM_FLAG.HAS_ATTACHMENTS;
+ }
+ return 0;
+ }
+
+ #prepareRelations(stmts, item) {
+ let relations = item.getRelations();
+ if (relations && relations.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertRelation);
+ for (let rel of relations) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", rel.icalString);
+
+ array.addParams(params);
+ }
+ return CAL_ITEM_FLAG.HAS_RELATIONS;
+ }
+ return 0;
+ }
+
+ #prepareAlarms(stmts, item) {
+ let alarms = item.getAlarms();
+ if (alarms.length < 1) {
+ return 0;
+ }
+
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAlarm);
+ for (let alarm of alarms) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", alarm.icalString);
+
+ array.addParams(params);
+ }
+
+ return CAL_ITEM_FLAG.HAS_ALARMS;
+ }
+
+ /**
+ * Deletes the item with the given item id.
+ *
+ * @param {string} id The id of the item to delete.
+ * @param {boolean} keepMeta If true, leave metadata for the item.
+ */
+ async deleteItemById(id, keepMeta) {
+ let stmts = [];
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAttendees, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteProperties, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteRecurrence, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteEvent, "id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteTodo, "id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAttachments, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteRelations, "item_id", id);
+ if (!keepMeta) {
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteMetaData, "item_id", id);
+ }
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAlarms, "item_id", id);
+ await this.db.executeAsync(stmts);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm b/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm
new file mode 100644
index 0000000000..b004b3d45b
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalStorageMetaDataModel"];
+
+var { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+/**
+ * CalStorageMetaDataModel provides methods for manipulating the metadata stored
+ * on items.
+ */
+class CalStorageMetaDataModel extends CalStorageModelBase {
+ /**
+ * Adds meta data for an item.
+ *
+ * @param {string} id
+ * @param {string} value
+ */
+ addMetaData(id, value) {
+ try {
+ this.db.prepareStatement(this.statements.mInsertMetaData);
+ let params = this.statements.mInsertMetaData.params;
+ params.item_id = id;
+ params.value = value;
+ this.statements.mInsertMetaData.executeStep();
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ this.db.logError("Unknown error!", e);
+ } else {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ this.db.logError("Error setting metadata for id " + id + "!", e);
+ }
+ } finally {
+ this.statements.mInsertMetaData.reset();
+ }
+ }
+
+ /**
+ * Deletes meta data for an item using its id.
+ */
+ deleteMetaDataById(id) {
+ this.db.executeSyncItemStatement(this.statements.mDeleteMetaData, "item_id", id);
+ }
+
+ /**
+ * Gets meta data for an item given its id.
+ *
+ * @param {string} id
+ */
+ getMetaData(id) {
+ let query = this.statements.mSelectMetaData;
+ let value = null;
+ try {
+ this.db.prepareStatement(query);
+ query.params.item_id = id;
+
+ if (query.executeStep()) {
+ value = query.row.value;
+ }
+ } catch (e) {
+ this.db.logError("Error getting metadata for id " + id + "!", e);
+ } finally {
+ query.reset();
+ }
+
+ return value;
+ }
+
+ /**
+ * Returns the meta data for all items.
+ *
+ * @param {string} key - Specifies which column to return.
+ */
+ getAllMetaData(key) {
+ let query = this.statements.mSelectAllMetaData;
+ let results = [];
+ try {
+ this.db.prepareStatement(query);
+ while (query.executeStep()) {
+ results.push(query.row[key]);
+ }
+ } catch (e) {
+ this.db.logError(`Error getting all metadata ${key == "item_id" ? "IDs" : "values"} ` + e);
+ } finally {
+ query.reset();
+ }
+ return results;
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageModelBase.jsm b/comm/calendar/providers/storage/CalStorageModelBase.jsm
new file mode 100644
index 0000000000..cf24606192
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageModelBase.jsm
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalStorageModelBase"];
+
+/**
+ * CalStorageModelBase is the parent class for the storage calendar models.
+ * The idea here is to leave most of the adjustments and integrity checks to
+ * CalStorageCalendar (or other classes) while focusing mostly on
+ * retrieval/persistence in the children of this class.
+ */
+class CalStorageModelBase {
+ /**
+ * @type {CalStorageDatabase}
+ */
+ db = null;
+
+ /**
+ * @type {CalStorageStatements}
+ */
+ statements = null;
+
+ /**
+ * @type {calICalendar}
+ */
+ calendar = null;
+
+ /**
+ * @param {CalStorageDatabase} db
+ * @param {CalStorageStatements} statements
+ * @param {calICalendar} calendar
+ *
+ * @throws - If unable to initialize SQL statements.
+ */
+ constructor(db, statements, calendar) {
+ this.db = db;
+ this.statements = statements;
+ this.calendar = calendar;
+ }
+
+ /**
+ * Delete all data stored for the calendar this model's database connection
+ * is associated with.
+ */
+ async deleteCalendar() {
+ let stmts = [];
+ if (this.statements.mDeleteEventExtras) {
+ for (let stmt of this.statements.mDeleteEventExtras) {
+ stmts.push(this.db.prepareStatement(stmt));
+ }
+ }
+
+ if (this.statements.mDeleteTodoExtras) {
+ for (let stmt of this.statements.mDeleteTodoExtras) {
+ stmts.push(this.db.prepareStatement(stmt));
+ }
+ }
+
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllEvents));
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllTodos));
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllMetaData));
+ await this.db.executeAsync(stmts);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageModelFactory.jsm b/comm/calendar/providers/storage/CalStorageModelFactory.jsm
new file mode 100644
index 0000000000..cf36791eba
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageModelFactory.jsm
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalStorageModelFactory"];
+
+var { CalStorageItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageItemModel.jsm"
+);
+var { CalStorageCachedItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageCachedItemModel.jsm"
+);
+var { CalStorageOfflineModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageOfflineModel.jsm"
+);
+var { CalStorageMetaDataModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageMetaDataModel.jsm"
+);
+
+/**
+ * CalStorageModelFactory provides a convenience method for creating instances
+ * of the storage calendar models. Use to avoid having to import each one
+ * directly.
+ */
+class CalStorageModelFactory {
+ /**
+ * Creates an instance of a CalStorageModel for the specified type.
+ *
+ * @param {"item"|"offline"|"metadata"} type - The model type desired.
+ * @param {mozIStorageAsyncConnection} db - The database connection to use.
+ * @param {CalStorageStatement} stmts
+ * @param {CalStorageCalendar} calendar - The calendar associated with the
+ * model.
+ */
+ static createInstance(type, db, stmts, calendar) {
+ switch (type) {
+ case "item":
+ return new CalStorageItemModel(db, stmts, calendar);
+
+ case "cached-item":
+ return new CalStorageCachedItemModel(db, stmts, calendar);
+
+ case "offline":
+ return new CalStorageOfflineModel(db, stmts, calendar);
+
+ case "metadata":
+ return new CalStorageMetaDataModel(db, stmts, calendar);
+ }
+
+ throw new Error(`Unknown model type "${type}" specified!`);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageOfflineModel.jsm b/comm/calendar/providers/storage/CalStorageOfflineModel.jsm
new file mode 100644
index 0000000000..23f6cd5330
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageOfflineModel.jsm
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalStorageOfflineModel"];
+
+var { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+/**
+ * CalStorageOfflineModel provides methods for manipulating the offline flags
+ * of items.
+ */
+class CalStorageOfflineModel extends CalStorageModelBase {
+ /**
+ * Returns the offline_journal column value for an item.
+ *
+ * @param {calIItemBase} item
+ *
+ * @returns {number}
+ */
+ async getItemOfflineFlag(item) {
+ let flag = null;
+ let query = item.isEvent() ? this.statements.mSelectEvent : this.statements.mSelectTodo;
+ this.db.prepareStatement(query);
+ query.params.id = item.id;
+ await this.db.executeAsync(query, row => {
+ flag = row.getResultByName("offline_journal") || null;
+ });
+ return flag;
+ }
+
+ /**
+ * Sets the offline_journal column value for an item.
+ *
+ * @param {calIItemBase} item
+ * @param {number} flag
+ */
+ async setOfflineJournalFlag(item, flag) {
+ let id = item.id;
+ let query = item.isEvent()
+ ? this.statements.mEditEventOfflineFlag
+ : this.statements.mEditTodoOfflineFlag;
+ this.db.prepareStatement(query);
+ query.params.id = id;
+ query.params.offline_journal = flag || null;
+ try {
+ await this.db.executeAsync(query);
+ } catch (e) {
+ this.db.logError("Error setting offline journal flag for " + item.title, e);
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageStatements.jsm b/comm/calendar/providers/storage/CalStorageStatements.jsm
new file mode 100644
index 0000000000..4906e036e3
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageStatements.jsm
@@ -0,0 +1,751 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var EXPORTED_SYMBOLS = ["CalStorageStatements"];
+
+const cICL = Ci.calIChangeLog;
+
+/**
+ * CalStorageStatements contains the mozIStorageBaseStatements used by the
+ * various storage calendar models. Remember to call the finalize() method when
+ * shutting down the db.
+ */
+class CalStorageStatements {
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement} mSelectNonRecurringEventsByRange
+ */
+ mSelectNonRecurringEventsByRange = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement} mSelectNonRecurringTodosByRange
+ */
+ mSelectNonRecurringTodosByRange = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttendeesForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttendeesForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAttendees = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectPropertiesForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectPropertiesForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllProperties = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectParametersForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectParametersForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllParameters = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRecurrenceForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllRecurrences = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEventsWithRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodosWithRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEventExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllEventExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodoExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllTodoExceptions = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mSelectMetaData = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mSelectAllMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRelationsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllRelations = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRelationsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAlarmsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAlarmsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAlarms = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttachmentsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttachmentsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAttachments = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertProperty = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertParameter = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAttendee = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAttachment = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertRelation = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mInsertMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAlarm = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mEditEventOfflineFlag = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mEditTodoOfflineFlag = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAttendees = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteProperties = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteParameters = null;
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAttachments = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteRelations = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mDeleteMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAlarms = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement[]}
+ */
+ mDeleteEventExtras = [];
+
+ /**
+ * @type {mozIStorageAsyncStatement[]}
+ */
+ mDeleteTodoExtras = [];
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAllEvents = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAllTodos = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mDeleteAllMetaData = null;
+
+ /**
+ * @param {CalStorageDatabase} db
+ *
+ * @throws - If unable to initialize SQL statements.
+ */
+ constructor(db) {
+ this.mSelectEvent = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL
+ LIMIT 1`
+ );
+
+ this.mSelectTodo = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL
+ LIMIT 1`
+ );
+
+ // The more readable version of the next where-clause is:
+ // WHERE ((event_end > :range_start OR
+ // (event_end = :range_start AND
+ // event_start = :range_start))
+ // AND event_start < :range_end)
+ //
+ // but that doesn't work with floating start or end times. The logic
+ // is the same though.
+ // For readability, a few helpers:
+ let floatingEventStart = "event_start_tz = 'floating' AND event_start";
+ let nonFloatingEventStart = "event_start_tz != 'floating' AND event_start";
+ let floatingEventEnd = "event_end_tz = 'floating' AND event_end";
+ let nonFloatingEventEnd = "event_end_tz != 'floating' AND event_end";
+ // The query needs to take both floating and non floating into account.
+ this.mSelectNonRecurringEventsByRange = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE
+ ((${floatingEventEnd} > :range_start + :start_offset) OR
+ (${nonFloatingEventEnd} > :range_start) OR
+ (((${floatingEventEnd} = :range_start + :start_offset) OR
+ (${nonFloatingEventEnd} = :range_start)) AND
+ ((${floatingEventStart} = :range_start + :start_offset) OR
+ (${nonFloatingEventStart} = :range_start))))
+ AND
+ ((${floatingEventStart} < :range_end + :end_offset) OR
+ (${nonFloatingEventStart} < :range_end))
+ AND cal_id = :cal_id AND flags & 16 == 0 AND recurrence_id IS NULL
+ AND ((:offline_journal IS NULL
+ AND (offline_journal IS NULL
+ OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ OR (offline_journal == :offline_journal))`
+ );
+
+ //
+ // WHERE (due > rangeStart AND (entry IS NULL OR entry < rangeEnd)) OR
+ // (due = rangeStart AND (entry IS NULL OR entry = rangeStart)) OR
+ // (due IS NULL AND (entry >= rangeStart AND entry < rangeEnd)) OR
+ // (entry IS NULL AND (completed > rangeStart OR completed IS NULL))
+ //
+ let floatingTodoEntry = "todo_entry_tz = 'floating' AND todo_entry";
+ let nonFloatingTodoEntry = "todo_entry_tz != 'floating' AND todo_entry";
+ let floatingTodoDue = "todo_due_tz = 'floating' AND todo_due";
+ let nonFloatingTodoDue = "todo_due_tz != 'floating' AND todo_due";
+ let floatingCompleted = "todo_completed_tz = 'floating' AND todo_completed";
+ let nonFloatingCompleted = "todo_completed_tz != 'floating' AND todo_completed";
+
+ this.mSelectNonRecurringTodosByRange = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE
+ ((((${floatingTodoDue} > :range_start + :start_offset) OR
+ (${nonFloatingTodoDue} > :range_start)) AND
+ ((todo_entry IS NULL) OR
+ ((${floatingTodoEntry} < :range_end + :end_offset) OR
+ (${nonFloatingTodoEntry} < :range_end)))) OR
+ (((${floatingTodoDue} = :range_start + :start_offset) OR
+ (${nonFloatingTodoDue} = :range_start)) AND
+ ((todo_entry IS NULL) OR
+ ((${floatingTodoEntry} = :range_start + :start_offset) OR
+ (${nonFloatingTodoEntry} = :range_start)))) OR
+ ((todo_due IS NULL) AND
+ (((${floatingTodoEntry} >= :range_start + :start_offset) OR
+ (${nonFloatingTodoEntry} >= :range_start)) AND
+ ((${floatingTodoEntry} < :range_end + :end_offset) OR
+ (${nonFloatingTodoEntry} < :range_end)))) OR
+ ((todo_entry IS NULL) AND
+ (((${floatingCompleted} > :range_start + :start_offset) OR
+ (${nonFloatingCompleted} > :range_start)) OR
+ (todo_completed IS NULL))))
+ AND cal_id = :cal_id AND flags & 16 == 0 AND recurrence_id IS NULL
+ AND ((:offline_journal IS NULL
+ AND (offline_journal IS NULL
+ OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ OR (offline_journal == :offline_journal))`
+ );
+
+ this.mSelectEventsWithRecurrence = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE flags & 16 == 16
+ AND cal_id = :cal_id
+ AND recurrence_id is NULL`
+ );
+
+ this.mSelectTodosWithRecurrence = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE flags & 16 == 16
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectEventExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+ this.mSelectAllEventExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+
+ this.mSelectTodoExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+ this.mSelectAllTodoExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+
+ this.mSelectAttendeesForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_attendees
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectAttendeesForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_attendees
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAttendees = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_attendees
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectPropertiesForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_properties
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectPropertiesForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_properties
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllProperties = db.createAsyncStatement(
+ `SELECT item_id, key, value FROM cal_properties
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectParametersForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_parameters
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectParametersForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_parameters
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllParameters = db.createAsyncStatement(
+ `SELECT item_id, key1, key2, value FROM cal_parameters
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectRecurrenceForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_recurrence
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id`
+ );
+ this.mSelectAllRecurrences = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_recurrence
+ WHERE cal_id = :cal_id`
+ );
+
+ this.mSelectAttachmentsForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_attachments
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectAttachmentsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_attachments
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAttachments = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_attachments
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectRelationsForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_relations
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectRelationsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_relations
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllRelations = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_relations
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectMetaData = db.createStatement(
+ `SELECT * FROM cal_metadata
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id`
+ );
+
+ this.mSelectAllMetaData = db.createStatement(
+ `SELECT * FROM cal_metadata
+ WHERE cal_id = :cal_id`
+ );
+
+ this.mSelectAlarmsForItem = db.createAsyncStatement(
+ `SELECT icalString FROM cal_alarms
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectAlarmsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT icalString FROM cal_alarms
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAlarms = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_alarms
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ // insert statements
+ this.mInsertEvent = db.createAsyncStatement(
+ `INSERT INTO cal_events
+ (cal_id, id, time_created, last_modified,
+ title, priority, privacy, ical_status, flags,
+ event_start, event_start_tz, event_end, event_end_tz, event_stamp,
+ recurrence_id, recurrence_id_tz, alarm_last_ack)
+ VALUES (:cal_id, :id, :time_created, :last_modified,
+ :title, :priority, :privacy, :ical_status, :flags,
+ :event_start, :event_start_tz, :event_end, :event_end_tz, :event_stamp,
+ :recurrence_id, :recurrence_id_tz, :alarm_last_ack)`
+ );
+
+ this.mInsertTodo = db.createAsyncStatement(
+ `INSERT INTO cal_todos
+ (cal_id, id, time_created, last_modified,
+ title, priority, privacy, ical_status, flags,
+ todo_entry, todo_entry_tz, todo_due, todo_due_tz, todo_stamp,
+ todo_completed, todo_completed_tz, todo_complete,
+ recurrence_id, recurrence_id_tz, alarm_last_ack)
+ VALUES (:cal_id, :id, :time_created, :last_modified,
+ :title, :priority, :privacy, :ical_status, :flags,
+ :todo_entry, :todo_entry_tz, :todo_due, :todo_due_tz, :todo_stamp,
+ :todo_completed, :todo_completed_tz, :todo_complete,
+ :recurrence_id, :recurrence_id_tz, :alarm_last_ack)`
+ );
+ this.mInsertProperty = db.createAsyncStatement(
+ `INSERT INTO cal_properties (cal_id, item_id, recurrence_id, recurrence_id_tz, key, value)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :key, :value)`
+ );
+ this.mInsertParameter = db.createAsyncStatement(
+ `INSERT INTO cal_parameters (cal_id, item_id, recurrence_id, recurrence_id_tz, key1, key2, value)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :key1, :key2, :value)`
+ );
+ this.mInsertAttendee = db.createAsyncStatement(
+ `INSERT INTO cal_attendees
+ (cal_id, item_id, recurrence_id, recurrence_id_tz, icalString)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :icalString)`
+ );
+ this.mInsertRecurrence = db.createAsyncStatement(
+ `INSERT INTO cal_recurrence
+ (cal_id, item_id, icalString)
+ VALUES (:cal_id, :item_id, :icalString)`
+ );
+
+ this.mInsertAttachment = db.createAsyncStatement(
+ `INSERT INTO cal_attachments
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+
+ this.mInsertRelation = db.createAsyncStatement(
+ `INSERT INTO cal_relations
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+
+ this.mInsertMetaData = db.createStatement(
+ `INSERT INTO cal_metadata
+ (cal_id, item_id, value)
+ VALUES (:cal_id, :item_id, :value)`
+ );
+
+ this.mInsertAlarm = db.createAsyncStatement(
+ `INSERT INTO cal_alarms
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+ // Offline Operations
+ this.mEditEventOfflineFlag = db.createStatement(
+ `UPDATE cal_events SET offline_journal = :offline_journal
+ WHERE id = :id
+ AND cal_id = :cal_id`
+ );
+
+ this.mEditTodoOfflineFlag = db.createStatement(
+ `UPDATE cal_todos SET offline_journal = :offline_journal
+ WHERE id = :id
+ AND cal_id = :cal_id`
+ );
+
+ // delete statements
+ this.mDeleteEvent = db.createAsyncStatement(
+ "DELETE FROM cal_events WHERE id = :id AND cal_id = :cal_id"
+ );
+ this.mDeleteTodo = db.createAsyncStatement(
+ "DELETE FROM cal_todos WHERE id = :id AND cal_id = :cal_id"
+ );
+ this.mDeleteAttendees = db.createAsyncStatement(
+ "DELETE FROM cal_attendees WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteProperties = db.createAsyncStatement(
+ "DELETE FROM cal_properties WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteParameters = db.createAsyncStatement(
+ "DELETE FROM cal_parameters WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteRecurrence = db.createAsyncStatement(
+ "DELETE FROM cal_recurrence WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteAttachments = db.createAsyncStatement(
+ "DELETE FROM cal_attachments WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteRelations = db.createAsyncStatement(
+ "DELETE FROM cal_relations WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteMetaData = db.createStatement(
+ "DELETE FROM cal_metadata WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteAlarms = db.createAsyncStatement(
+ "DELETE FROM cal_alarms WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+
+ // These are only used when deleting an entire calendar
+ let extrasTables = [
+ "cal_attendees",
+ "cal_properties",
+ "cal_parameters",
+ "cal_recurrence",
+ "cal_attachments",
+ "cal_metadata",
+ "cal_relations",
+ "cal_alarms",
+ ];
+
+ this.mDeleteEventExtras = [];
+ this.mDeleteTodoExtras = [];
+
+ for (let table in extrasTables) {
+ this.mDeleteEventExtras[table] = db.createAsyncStatement(
+ `DELETE FROM ${extrasTables[table]}
+ WHERE item_id IN
+ (SELECT id FROM cal_events WHERE cal_id = :cal_id)
+ AND cal_id = :cal_id`
+ );
+ this.mDeleteTodoExtras[table] = db.createAsyncStatement(
+ `DELETE FROM ${extrasTables[table]}
+ WHERE item_id IN
+ (SELECT id FROM cal_todos WHERE cal_id = :cal_id)
+ AND cal_id = :cal_id`
+ );
+ }
+
+ // Note that you must delete the "extras" _first_ using the above two
+ // statements, before you delete the events themselves.
+ this.mDeleteAllEvents = db.createAsyncStatement(
+ "DELETE from cal_events WHERE cal_id = :cal_id"
+ );
+ this.mDeleteAllTodos = db.createAsyncStatement("DELETE from cal_todos WHERE cal_id = :cal_id");
+
+ this.mDeleteAllMetaData = db.createStatement("DELETE FROM cal_metadata WHERE cal_id = :cal_id");
+ }
+
+ /**
+ * Ensures all Db statements are properly cleaned up before shutdown by
+ * calling their finalize() method.
+ */
+ finalize() {
+ for (let key of Object.keys(this)) {
+ if (this[key] instanceof Ci.mozIStorageBaseStatement) {
+ this[key].finalize();
+ }
+ }
+ for (let stmt of this.mDeleteEventExtras) {
+ stmt.finalize();
+ }
+ for (let stmt of this.mDeleteTodoExtras) {
+ stmt.finalize();
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/calStorageHelpers.jsm b/comm/calendar/providers/storage/calStorageHelpers.jsm
new file mode 100644
index 0000000000..2f4e303beb
--- /dev/null
+++ b/comm/calendar/providers/storage/calStorageHelpers.jsm
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm");
+
+var { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const EXPORTED_SYMBOLS = ["CAL_ITEM_FLAG", "textToDate", "getTimezone", "newDateTime"];
+
+// Storage flags. These are used in the Database |flags| column to give
+// information about the item's features. For example, if the item has
+// attachments, the HAS_ATTACHMENTS flag is added to the flags column.
+var CAL_ITEM_FLAG = {
+ PRIVATE: 1,
+ HAS_ATTENDEES: 2,
+ HAS_PROPERTIES: 4,
+ EVENT_ALLDAY: 8,
+ HAS_RECURRENCE: 16,
+ HAS_EXCEPTIONS: 32,
+ HAS_ATTACHMENTS: 64,
+ HAS_RELATIONS: 128,
+ HAS_ALARMS: 256,
+ RECURRENCE_ID_ALLDAY: 512,
+};
+
+// The cache of foreign timezones
+var gForeignTimezonesCache = {};
+
+/**
+ * Transforms the text representation of this date object to a calIDateTime
+ * object.
+ *
+ * @param text The text to transform.
+ * @returns The resulting calIDateTime.
+ */
+function textToDate(text) {
+ let textval;
+ let timezone = "UTC";
+
+ if (text[0] == "Z") {
+ let strs = text.substr(2).split(":");
+ textval = parseInt(strs[0], 10);
+ timezone = strs[1].replace(/%:/g, ":").replace(/%%/g, "%");
+ } else {
+ textval = parseInt(text.substr(2), 10);
+ }
+
+ let date;
+ if (text[0] == "U" || text[0] == "Z") {
+ date = newDateTime(textval, timezone);
+ } else if (text[0] == "L") {
+ // is local time
+ date = newDateTime(textval, "floating");
+ }
+
+ if (text[1] == "D") {
+ date.isDate = true;
+ }
+ return date;
+}
+
+/**
+ * Gets the timezone for the given definition or identifier
+ *
+ * @param aTimezone The timezone data
+ * @returns The calITimezone object
+ */
+function getTimezone(aTimezone) {
+ let timezone = null;
+ if (aTimezone.startsWith("BEGIN:VTIMEZONE")) {
+ timezone = gForeignTimezonesCache[aTimezone]; // using full definition as key
+ if (!timezone) {
+ timezone = new CalTimezone(
+ ICAL.Timezone.fromData({
+ component: aTimezone,
+ })
+ );
+ gForeignTimezonesCache[aTimezone] = timezone;
+ }
+ } else {
+ timezone = cal.timezoneService.getTimezone(aTimezone);
+ }
+ return timezone;
+}
+
+/**
+ * Creates a new calIDateTime from the given native time and optionally
+ * the passed timezone. The timezone can either be the TZID of the timezone (in
+ * this case the timezone service will be asked for the definition), or a string
+ * representation of the timezone component (i.e a VTIMEZONE component).
+ *
+ * @param aNativeTime The native time, in microseconds
+ * @param aTimezone The timezone identifier or definition.
+ */
+function newDateTime(aNativeTime, aTimezone) {
+ let date = cal.createDateTime();
+
+ // Bug 751821 - Dates before 1970 were incorrectly stored with an unsigned nativeTime value, we need to
+ // convert back to a negative value
+ if (aNativeTime > 9223372036854776000) {
+ cal.WARN("[calStorageCalendar] Converting invalid native time value: " + aNativeTime);
+ aNativeTime = -9223372036854776000 + (aNativeTime - 9223372036854776000);
+ // Round to nearest second to fix microsecond rounding errors
+ aNativeTime = Math.round(aNativeTime / 1000000) * 1000000;
+ }
+
+ date.nativeTime = aNativeTime;
+ if (aTimezone) {
+ let timezone = getTimezone(aTimezone);
+ if (timezone) {
+ date = date.getInTimezone(timezone);
+ } else {
+ cal.ASSERT(false, "Timezone not available: " + aTimezone);
+ }
+ } else {
+ date.timezone = cal.dtz.floating;
+ }
+ return date;
+}
diff --git a/comm/calendar/providers/storage/calStorageUpgrade.jsm b/comm/calendar/providers/storage/calStorageUpgrade.jsm
new file mode 100644
index 0000000000..b5c23bd648
--- /dev/null
+++ b/comm/calendar/providers/storage/calStorageUpgrade.jsm
@@ -0,0 +1,1889 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Welcome to the storage database migration.
+ *
+ * If you would like to change anything in the database schema, you must follow
+ * some steps to make sure that upgrading from old versions works fine.
+ *
+ * First of all you must increment the DB_SCHEMA_VERSION variable below. Then
+ * you must write your upgrader. To do this, create a new function and add it to
+ * the upgrade object, similar to the existing upgraders below. An example is
+ * given below.
+ *
+ * An upgrader MUST update both the database (if it is passed) AND the table
+ * data javascript object. An example for a such object is in the v1/v2
+ * upgrader. The process of upgrading calls the latest upgrader with the
+ * database object and the current database version. The whole chain of
+ * upgraders is then called (down to v1). The first upgrader (v1/v2) provides
+ * the basic table data object. Each further upgrader then updates this object
+ * to correspond with the database tables and columns. No actual database calls
+ * are made until the first upgrader with a higher version than the current
+ * database version is called. When this version is arrived, both the table data
+ * object and the database are updated. This process continues until the
+ * database is at the latest version.
+ *
+ * Note that your upgrader is not necessarily called with a database object,
+ * for example if the user's database is already at a higher version. In this
+ * case your upgrader is called to compile the table data object. To make
+ * calling code easier, there are a bunch of helper functions below that can be
+ * called with a null database object and only call the database object if it is
+ * not null. If you need to call new functions on the database object, check out
+ * the createDBDelegate function below.
+ *
+ * When adding new tables to the table data object, please note that there is a
+ * special prefix for indexes. These are also kept in the table data object to
+ * make sure that getAllSql also includes CREATE INDEX statements. New tables
+ * MUST NOT be prefixed with "idx_". If you would like to add a new index,
+ * please use the createIndex function.
+ *
+ * The basic structure for an upgrader is (NN is current version, XX = NN - 1)
+ *
+ * upgrader.vNN = function upgrade_vNN(db, version) {
+ * let tbl = upgrade.vXX(version < XX && db, version);
+ * LOGdb(db, "Storage: Upgrading to vNN");
+ *
+ * beginTransaction(db);
+ * try {
+ * // Do stuff here
+ * setDbVersionAndCommit(db, NN);
+ * } catch (e) {
+ * throw reportErrorAndRollback(db, e);
+ * }
+ * return tbl;
+ * }
+ *
+ * Regardless of how your upgrader looks, make sure you:
+ * - use an sql transaction, if you have a database
+ * - If everything succeeds, call setDbVersionAndCommit to update the database
+ * version (setDbVersionAndCommit also commits the transaction)
+ * - If something fails, throw reportErrorAndRollback(db, e) to report the
+ * failure and roll back the transaction.
+ *
+ * If this documentation isn't sufficient to make upgrading understandable,
+ * please file a bug.
+ */
+
+var EXPORTED_SYMBOLS = [
+ "DB_SCHEMA_VERSION",
+ "getSql",
+ "getAllSql",
+ "getSqlTable",
+ "upgradeDB",
+ "backupDB",
+];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CAL_ITEM_FLAG, textToDate, getTimezone, newDateTime } = ChromeUtils.import(
+ "resource:///modules/calendar/calStorageHelpers.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+// The current database version. Be sure to increment this when you create a new
+// updater.
+var DB_SCHEMA_VERSION = 23;
+
+/**
+ * Gets the SQL for the given table data and table name. This can be both a real
+ * table or the name of an index. Indexes must contain the idx_ prefix.
+ *
+ * @param tblName The name of the table or index to retrieve sql for
+ * @param tblData The table data object, as returned from the upgrade_v*
+ * functions. If null, then the latest table data is
+ * retrieved.
+ * @param alternateName (optional) The table or index name to be used in the
+ * resulting CREATE statement. If not set, tblName will
+ * be used.
+ * @returns The SQL Statement for the given table or index and
+ * version as a string.
+ */
+function getSql(tblName, tblData, alternateName) {
+ tblData = tblData || getSqlTable();
+ let altName = alternateName || tblName;
+ let sql;
+ if (tblName.substr(0, 4) == "idx_") {
+ // If this is an index, we need construct the SQL differently
+ let idxTbl = tblData[tblName].shift();
+ let idxOn = idxTbl + "(" + tblData[tblName].join(",") + ")";
+ sql = `CREATE INDEX ${altName} ON ${idxOn};`;
+ } else {
+ sql = `CREATE TABLE ${altName} (\n`;
+ for (let [key, type] of Object.entries(tblData[tblName])) {
+ sql += ` ${key} ${type},\n`;
+ }
+ }
+
+ return sql.replace(/,\s*$/, ");");
+}
+
+/**
+ * Gets all SQL for the given table data
+ *
+ * @param version The database schema version to retrieve. If null, the
+ * latest schema version will be used.
+ * @returns The SQL Statement for the given version as a string.
+ */
+function getAllSql(version) {
+ let tblData = getSqlTable(version);
+ let sql = "";
+ for (let tblName in tblData) {
+ sql += getSql(tblName, tblData) + "\n\n";
+ }
+ cal.LOG("Storage: Full SQL statement is " + sql);
+ return sql;
+}
+
+/**
+ * Get the JS object corresponding to the given schema version. This object will
+ * contain both tables and indexes, where indexes are prefixed with "idx_".
+ *
+ * @param schemaVersion The schema version to get. If null, the latest
+ * schema version will be used.
+ * @returns The javascript object containing the table
+ * definition.
+ */
+function getSqlTable(schemaVersion) {
+ let version = "v" + (schemaVersion || DB_SCHEMA_VERSION);
+ if (version in upgrade) {
+ return upgrade[version]();
+ }
+ return {};
+}
+
+/**
+ * Gets the current version of the storage database
+ */
+function getVersion(db) {
+ let selectSchemaVersion;
+ let version = null;
+
+ try {
+ selectSchemaVersion = createStatement(
+ db,
+ "SELECT version FROM cal_calendar_schema_version LIMIT 1"
+ );
+ if (selectSchemaVersion.executeStep()) {
+ version = selectSchemaVersion.row.version;
+ }
+
+ if (version !== null) {
+ // This is the only place to leave this function gracefully.
+ return version;
+ }
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ } finally {
+ if (selectSchemaVersion) {
+ selectSchemaVersion.finalize();
+ }
+ }
+
+ throw new Error("cal_calendar_schema_version SELECT returned no results");
+}
+
+/**
+ * Backup the database and notify the user via error console of the process
+ */
+function backupDB(db, currentVersion) {
+ cal.LOG("Storage: Backing up current database...");
+ try {
+ // Prepare filenames and path
+ let backupFilename = "local.v" + currentVersion + ".sqlite";
+ let backupPath = cal.provider.getCalendarDirectory();
+ backupPath.append("backup");
+ if (!backupPath.exists()) {
+ backupPath.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ }
+
+ // Create a backup file and notify the user via WARN, since LOG will not
+ // be visible unless a pref is set.
+ let file = Services.storage.backupDatabaseFile(db.databaseFile, backupFilename, backupPath);
+ cal.WARN(
+ "Storage: Upgrading to v" + DB_SCHEMA_VERSION + ", a backup was written to: " + file.path
+ );
+ } catch (e) {
+ cal.ERROR("Storage: Error creating backup file: " + e);
+ }
+}
+
+/**
+ * Upgrade the passed database.
+ *
+ * @param storageCalendar - An instance of CalStorageCalendar.
+ */
+function upgradeDB(storageCalendar) {
+ let db = storageCalendar.db;
+ cal.ASSERT(db, "Database has not been opened!", true);
+
+ if (db.tableExists("cal_calendar_schema_version")) {
+ let version = getVersion(db);
+
+ if (version < DB_SCHEMA_VERSION) {
+ upgradeExistingDB(db, version);
+ } else if (version > DB_SCHEMA_VERSION) {
+ handleTooNewSchema(storageCalendar);
+ return;
+ }
+ } else {
+ upgradeBrandNewDB(db);
+ }
+
+ ensureUpdatedTimezones(db);
+ storageCalendar.afterUpgradeDB();
+}
+
+/**
+ * Upgrade a brand new database.
+ *
+ * @param {mozIStorageAsyncConnection} db - New database to upgrade.
+ */
+function upgradeBrandNewDB(db) {
+ cal.LOG("Storage: Creating tables from scratch");
+ beginTransaction(db);
+ try {
+ executeSimpleSQL(db, getAllSql());
+ setDbVersionAndCommit(db, DB_SCHEMA_VERSION);
+ } catch (e) {
+ reportErrorAndRollback(db, e);
+ }
+}
+
+/**
+ * Upgrade an existing database.
+ *
+ * @param {mozIStorageAsyncConnection} db - Existing database to upgrade.
+ * @param {number} version - Version of the database before upgrading.
+ */
+function upgradeExistingDB(db, version) {
+ // First, create a backup
+ backupDB(db, version);
+
+ // Then start the latest upgrader
+ cal.LOG("Storage: Preparing to upgrade v" + version + " to v" + DB_SCHEMA_VERSION);
+ upgrade["v" + DB_SCHEMA_VERSION](db, version);
+}
+
+/**
+ * Called when the user has downgraded Thunderbird and the older version of
+ * Thunderbird does not know about the newer schema of their calendar data.
+ * Log an error, make a backup copy of the data by renaming the data file, and
+ * restart the database initialization process, which will create a new data
+ * file that will have the correct schema.
+ *
+ * The user will find that their calendar events/tasks are gone. They should
+ * have exported them to an ICS file before downgrading, and then they can
+ * import them to get them back.
+ *
+ * @param storageCalendar - An instance of CalStorageCalendar.
+ */
+function handleTooNewSchema(storageCalendar) {
+ // Create a string like this: "2020-05-11T21-30-17".
+ let dateTime = new Date().toISOString().split(".")[0].replace(/:/g, "-");
+
+ let copyFileName = `local-${dateTime}.sqlite`;
+
+ storageCalendar.db.databaseFile.renameTo(null, copyFileName);
+
+ storageCalendar.db.close();
+
+ let appName = cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ let errorText = cal.l10n.getCalString("tooNewSchemaErrorText", [appName, copyFileName]);
+ cal.ERROR(errorText);
+
+ storageCalendar.prepareInitDB();
+}
+
+/**
+ * Sets the db version and commits any open transaction.
+ *
+ * @param db The mozIStorageConnection to commit on
+ * @param version The version to set
+ */
+function setDbVersionAndCommit(db, version) {
+ let sql =
+ "DELETE FROM cal_calendar_schema_version;" +
+ `INSERT INTO cal_calendar_schema_version (version) VALUES (${version})`;
+
+ executeSimpleSQL(db, sql);
+ if (db && db.transactionInProgress) {
+ commitTransaction(db);
+ }
+}
+
+/**
+ * Creates a function that calls the given function |funcName| on it's passed
+ * database. In addition, if no database is passed, the call is ignored.
+ *
+ * @param funcName The function name to delegate.
+ * @returns The delegate function for the passed named function.
+ */
+function createDBDelegate(funcName) {
+ return function (db, ...args) {
+ if (db) {
+ try {
+ return db[funcName](...args);
+ } catch (e) {
+ cal.ERROR(
+ "Error calling '" +
+ funcName +
+ "' db error: '" +
+ lastErrorString(db) +
+ "'.\nException: " +
+ e
+ );
+ cal.WARN(cal.STACK(10));
+ }
+ }
+ return null;
+ };
+}
+
+/**
+ * Creates a delegate function for a database getter. Returns a function that
+ * can be called to get the specified attribute, if a database is passed. If no
+ * database is passed, no error is thrown but null is returned.
+ *
+ * @param getterAttr The getter to delegate.
+ * @returns The function that delegates the getter.
+ */
+function createDBDelegateGetter(getterAttr) {
+ return function (db) {
+ return db ? db[getterAttr] : null;
+ };
+}
+
+// These functions use the db delegate to allow easier calling of common
+// database functions.
+var beginTransaction = createDBDelegate("beginTransaction");
+var commitTransaction = createDBDelegate("commitTransaction");
+var rollbackTransaction = createDBDelegate("rollbackTransaction");
+var createStatement = createDBDelegate("createStatement");
+var executeSimpleSQL = createDBDelegate("executeSimpleSQL");
+var removeFunction = createDBDelegate("removeFunction");
+var createFunction = createDBDelegate("createFunction");
+
+var lastErrorString = createDBDelegateGetter("lastErrorString");
+
+/**
+ * Helper function to create an index on the database if it doesn't already
+ * exist.
+ *
+ * @param tblData The table data object to save the index in.
+ * @param tblName The name of the table to index.
+ * @param colNameArray An array of columns to index over.
+ * @param db (optional) The database to create the index on.
+ */
+function createIndex(tblData, tblName, colNameArray, db) {
+ let idxName = "idx_" + tblName + "_" + colNameArray.join("_");
+ let idxOn = tblName + "(" + colNameArray.join(",") + ")";
+
+ // Construct the table data for this index
+ tblData[idxName] = colNameArray.concat([]);
+ tblData[idxName].unshift(tblName);
+
+ // Execute the sql, if there is a db
+ return executeSimpleSQL(db, `CREATE INDEX IF NOT EXISTS ${idxName} ON ${idxOn}`);
+}
+
+/**
+ * Often in an upgrader we want to log something only if there is a database. To
+ * make code less cludgy, here a helper function.
+ *
+ * @param db The database, or null if nothing should be logged.
+ * @param msg The message to log.
+ */
+function LOGdb(db, msg) {
+ if (db) {
+ cal.LOG(msg);
+ }
+}
+
+/**
+ * Report an error and roll back the last transaction.
+ *
+ * @param db The database to roll back on.
+ * @param e The exception to report
+ * @returns The passed exception, for chaining.
+ */
+function reportErrorAndRollback(db, e) {
+ if (db && db.transactionInProgress) {
+ rollbackTransaction(db);
+ }
+ cal.ERROR(
+ `++++++ Storage error! ++++++ DB Error: ${lastErrorString(db)}\n++++++ Exception: ${e}`
+ );
+ return e;
+}
+
+/**
+ * Make sure the timezones of the events in the database are up to date.
+ *
+ * @param db The database to bring up to date
+ */
+function ensureUpdatedTimezones(db) {
+ // check if timezone version has changed:
+ let selectTzVersion = createStatement(db, "SELECT version FROM cal_tz_version LIMIT 1");
+ let tzServiceVersion = cal.timezoneService.version;
+ let version;
+ try {
+ version = selectTzVersion.executeStep() ? selectTzVersion.row.version : null;
+ } finally {
+ selectTzVersion.finalize();
+ }
+
+ let versionComp = 1;
+ if (version) {
+ versionComp = Services.vc.compare(tzServiceVersion, version);
+ }
+
+ if (versionComp != 0) {
+ cal.LOG(
+ "[calStorageCalendar] Timezones have been changed from " +
+ version +
+ " to " +
+ tzServiceVersion +
+ ", updating calendar data."
+ );
+
+ let zonesToUpdate = [];
+ let getZones = createStatement(
+ db,
+ "SELECT DISTINCT(zone) FROM (" +
+ "SELECT recurrence_id_tz AS zone FROM cal_attendees WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_events WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT event_start_tz AS zone FROM cal_events WHERE event_start_tz IS NOT NULL UNION " +
+ "SELECT event_end_tz AS zone FROM cal_events WHERE event_end_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_properties WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_todos WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT todo_entry_tz AS zone FROM cal_todos WHERE todo_entry_tz IS NOT NULL UNION " +
+ "SELECT todo_due_tz AS zone FROM cal_todos WHERE todo_due_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_alarms WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_relations WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_attachments WHERE recurrence_id_tz IS NOT NULL" +
+ ");"
+ );
+ try {
+ while (getZones.executeStep()) {
+ let zone = getZones.row.zone;
+ // Send the timezones off to the timezone service to attempt conversion:
+ let timezone = getTimezone(zone);
+ if (timezone) {
+ let refTz = cal.timezoneService.getTimezone(timezone.tzid);
+ if (refTz && refTz.tzid != zone) {
+ zonesToUpdate.push({ oldTzId: zone, newTzId: refTz.tzid });
+ }
+ }
+ }
+ } catch (e) {
+ cal.ERROR("Error updating timezones: " + e + "\nDB Error " + lastErrorString(db));
+ } finally {
+ getZones.finalize();
+ }
+
+ beginTransaction(db);
+ try {
+ for (let update of zonesToUpdate) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE cal_attendees SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET event_start_tz = '${update.newTzId}' WHERE event_start_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET event_end_tz = '${update.newTzId}' WHERE event_end_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_properties SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET todo_entry_tz = '${update.newTzId}' WHERE todo_entry_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET todo_due_tz = '${update.newTzId}' WHERE todo_due_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_alarms SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_relations SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_attachments SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}';`
+ );
+ }
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "DELETE FROM cal_tz_version; " +
+ `INSERT INTO cal_tz_version VALUES ('${cal.timezoneService.version}');`
+ );
+ commitTransaction(db);
+ } catch (e) {
+ cal.ASSERT(false, "Timezone update failed! DB Error: " + lastErrorString(db));
+ rollbackTransaction(db);
+ throw e;
+ }
+ }
+}
+
+/**
+ * Adds a column to the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to add on
+ * @param colName The column name to add
+ * @param colType The type of the column to add
+ * @param db (optional) The database to apply the operation on
+ */
+function addColumn(tblData, tblName, colName, colType, db) {
+ cal.ASSERT(tblName in tblData, `Table ${tblName} is missing from table def`, true);
+ tblData[tblName][colName] = colType;
+
+ executeSimpleSQL(db, `ALTER TABLE ${tblName} ADD COLUMN ${colName} ${colType}`);
+}
+
+/**
+ * Deletes columns from the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to delete on
+ * @param colNameArray An array of column names to delete
+ * @param db (optional) The database to apply the operation on
+ */
+function deleteColumns(tblData, tblName, colNameArray, db) {
+ for (let colName of colNameArray) {
+ delete tblData[tblName][colName];
+ }
+
+ let columns = Object.keys(tblData[tblName]);
+ executeSimpleSQL(db, getSql(tblName, tblData, tblName + "_temp"));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${tblName}_temp (${columns.join(",")}) ` +
+ `SELECT ${columns.join(",")}` +
+ ` FROM ${tblName};`
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `DROP TABLE ${tblName}; ` +
+ `ALTER TABLE ${tblName}_temp` +
+ ` RENAME TO ${tblName};`
+ );
+}
+
+/**
+ * Does a full copy of the given table
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to copy
+ * @param newTblName The target table name.
+ * @param db (optional) The database to apply the operation on
+ * @param condition (optional) The condition to respect when copying
+ * @param selectOptions (optional) Extra options for the SELECT, i.e DISTINCT
+ */
+function copyTable(tblData, tblName, newTblName, db, condition, selectOptions) {
+ function objcopy(obj) {
+ return JSON.parse(JSON.stringify(obj));
+ }
+
+ tblData[newTblName] = objcopy(tblData[tblName]);
+
+ let columns = Object.keys(tblData[newTblName]);
+ executeSimpleSQL(db, getSql(newTblName, tblData));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${newTblName} (${columns.join(",")}) ` +
+ `SELECT ${selectOptions} ${columns.join(",")}` +
+ ` FROM ${tblName} ${condition ? condition : ""};`
+ );
+}
+
+/**
+ * Alter the type of a certain column
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to alter
+ * @param colNameArray An array of column names to delete
+ * @param newType The new type of the column
+ * @param db (optional) The database to apply the operation on
+ */
+function alterTypes(tblData, tblName, colNameArray, newType, db) {
+ for (let colName of colNameArray) {
+ tblData[tblName][colName] = newType;
+ }
+
+ let columns = Object.keys(tblData[tblName]);
+ executeSimpleSQL(db, getSql(tblName, tblData, tblName + "_temp"));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${tblName}_temp (${columns.join(",")}) ` +
+ `SELECT ${columns.join(",")}` +
+ ` FROM ${tblName};`
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `DROP TABLE ${tblName}; ` +
+ `ALTER TABLE ${tblName}_temp` +
+ ` RENAME TO ${tblName};`
+ );
+}
+
+/**
+ * Renames the given table, giving it a new name.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to rename.
+ * @param newTblName The new name of the table.
+ * @param db (optional) The database to apply the operation on.
+ * @param overwrite (optional) If true, the target table will be dropped
+ * before the rename
+ */
+function renameTable(tblData, tblName, newTblName, db, overwrite) {
+ if (overwrite) {
+ dropTable(tblData, newTblName, db);
+ }
+ tblData[newTblName] = tblData[tblName];
+ delete tblData[tblName];
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `ALTER TABLE ${tblName}` +
+ ` RENAME TO ${newTblName}`
+ );
+}
+
+/**
+ * Drops the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to drop.
+ * @param db (optional) The database to apply the operation on.
+ */
+function dropTable(tblData, tblName, db) {
+ delete tblData[tblName];
+
+ executeSimpleSQL(db, `DROP TABLE IF EXISTS ${tblName};`);
+}
+
+/**
+ * Creates the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to add.
+ * @param def The table definition object.
+ * @param db (optional) The database to apply the operation on.
+ */
+function addTable(tblData, tblName, def, db) {
+ tblData[tblName] = def;
+
+ executeSimpleSQL(db, getSql(tblName, tblData));
+}
+
+/**
+ * Migrates the given columns to a single icalString, using the (previously
+ * created) user function for processing.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to migrate.
+ * @param userFuncName The name of the user function to call for migration
+ * @param oldColumns An array of columns to migrate to the new icalString
+ * column
+ * @param db (optional) The database to apply the operation on.
+ */
+function migrateToIcalString(tblData, tblName, userFuncName, oldColumns, db) {
+ addColumn(tblData, tblName, ["icalString"], "TEXT", db);
+ // prettier-ignore
+ let updateSql =
+ `UPDATE ${tblName} ` +
+ ` SET icalString = ${userFuncName}(${oldColumns.join(",")})`;
+ executeSimpleSQL(db, updateSql);
+ deleteColumns(tblData, tblName, oldColumns, db);
+
+ // If null was returned, its an invalid attendee. Make sure to remove them,
+ // they might break things later on.
+ let cleanupSql = `DELETE FROM ${tblName} WHERE icalString IS NULL`;
+ executeSimpleSQL(db, cleanupSql);
+}
+
+/**
+ * Maps a mozIStorageValueArray to a JS array, converting types correctly.
+ *
+ * @param storArgs The storage value array to convert
+ * @returns An array with the arguments as js values.
+ */
+function mapStorageArgs(storArgs) {
+ const mISVA = Ci.mozIStorageValueArray;
+ let mappedArgs = [];
+ for (let i = 0; i < storArgs.numEntries; i++) {
+ switch (storArgs.getTypeOfIndex(i)) {
+ case mISVA.VALUE_TYPE_NULL:
+ mappedArgs.push(null);
+ break;
+ case mISVA.VALUE_TYPE_INTEGER:
+ mappedArgs.push(storArgs.getInt64(i));
+ break;
+ case mISVA.VALUE_TYPE_FLOAT:
+ mappedArgs.push(storArgs.getDouble(i));
+ break;
+ case mISVA.VALUE_TYPE_TEXT:
+ case mISVA.VALUE_TYPE_BLOB:
+ mappedArgs.push(storArgs.getUTF8String(i));
+ break;
+ }
+ }
+
+ return mappedArgs;
+}
+
+/** Object holding upgraders */
+var upgrade = {};
+
+/**
+ * Returns the initial storage database schema. Note this is not the current
+ * schema, it will be modified by the upgrade.vNN() functions. This function
+ * returns the initial v1 with modifications from v2 applied.
+ *
+ * No bug - new recurrence system. exceptions supported now, along with
+ * everything else ical can throw at us. I hope.
+ * p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v2 = upgrade.v1 = function (db, version) {
+ LOGdb(db, "Storage: Upgrading to v1/v2");
+ let tblData = {
+ cal_calendar_schema_version: { version: "INTEGER" },
+
+ /* While this table is in v1, actually keeping it in the sql object will
+ * cause problems when migrating from storage.sdb to local.sqlite. There,
+ * all tables from storage.sdb will be moved to local.sqlite and so starting
+ * the application again afterwards causes a borked upgrade since its missing
+ * tables it expects.
+ *
+ * cal_calendars: {
+ * id: "INTEGER PRIMARY KEY",
+ * name: "STRING"
+ * },
+ */
+
+ cal_items: {
+ cal_id: "INTEGER",
+ item_type: "INTEGER",
+ id: "STRING",
+ time_created: "INTEGER",
+ last_modified: "INTEGER",
+ title: "STRING",
+ priority: "INTEGER",
+ privacy: "STRING",
+ ical_status: "STRING",
+ flags: "INTEGER",
+ event_start: "INTEGER",
+ event_end: "INTEGER",
+ event_stamp: "INTEGER",
+ todo_entry: "INTEGER",
+ todo_due: "INTEGER",
+ todo_completed: "INTEGER",
+ todo_complete: "INTEGER",
+ alarm_id: "INTEGER",
+ },
+
+ cal_attendees: {
+ item_id: "STRING",
+ attendee_id: "STRING",
+ common_name: "STRING",
+ rsvp: "INTEGER",
+ role: "STRING",
+ status: "STRING",
+ type: "STRING",
+ },
+
+ cal_alarms: {
+ id: "INTEGER PRIMARY KEY",
+ alarm_data: "BLOB",
+ },
+
+ cal_recurrence: {
+ item_id: "STRING",
+ recur_type: "INTEGER",
+ recur_index: "INTEGER",
+ is_negative: "BOOLEAN",
+ dates: "STRING",
+ end_date: "INTEGER",
+ count: "INTEGER",
+ interval: "INTEGER",
+ second: "STRING",
+ minute: "STRING",
+ hour: "STRING",
+ day: "STRING",
+ monthday: "STRING",
+ yearday: "STRING",
+ weekno: "STRING",
+ month: "STRING",
+ setpos: "STRING",
+ },
+
+ cal_properties: {
+ item_id: "STRING",
+ key: "STRING",
+ value: "BLOB",
+ },
+ };
+
+ for (let tbl in tblData) {
+ executeSimpleSQL(db, `DROP TABLE IF EXISTS ${tbl}`);
+ }
+ return tblData;
+};
+
+/**
+ * Upgrade to version 3.
+ * Bug 293707, updates to storage provider; calendar manager database locked
+ * fix, r=shaver, p=vlad
+ * p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v3 = function (db, version) {
+ function updateSql(tbl, field) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE ${tbl} SET ${field}_tz='UTC'` +
+ ` WHERE ${field} IS NOT NULL`
+ );
+ }
+
+ let tbl = upgrade.v2(version < 2 && db, version);
+ LOGdb(db, "Storage: Upgrading to v3");
+
+ beginTransaction(db);
+ try {
+ copyTable(tbl, "cal_items", "cal_events", db, "item_type = 0");
+ copyTable(tbl, "cal_items", "cal_todos", db, "item_type = 1");
+
+ dropTable(tbl, "cal_items", db);
+
+ let removeEventCols = [
+ "item_type",
+ "item_type",
+ "todo_entry",
+ "todo_due",
+ "todo_completed",
+ "todo_complete",
+ "alarm_id",
+ ];
+ deleteColumns(tbl, "cal_events", removeEventCols, db);
+
+ addColumn(tbl, "cal_events", "event_start_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_events", "event_end_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_events", "alarm_time", "INTEGER", db);
+ addColumn(tbl, "cal_events", "alarm_time_tz", "VARCHAR", db);
+
+ let removeTodoCols = ["item_type", "event_start", "event_end", "event_stamp", "alarm_id"];
+ deleteColumns(tbl, "cal_todos", removeTodoCols, db);
+
+ addColumn(tbl, "cal_todos", "todo_entry_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "todo_due_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "todo_completed_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "alarm_time", "INTEGER", db);
+ addColumn(tbl, "cal_todos", "alarm_time_tz", "VARCHAR", db);
+
+ dropTable(tbl, "cal_alarms", db);
+
+ // The change between 2 and 3 includes the splitting of cal_items into
+ // cal_events and cal_todos, and the addition of columns for
+ // event_start_tz, event_end_tz, todo_entry_tz, todo_due_tz.
+ // These need to default to "UTC" if their corresponding time is
+ // given, since that's what the default was for v2 calendars
+
+ // Fix up the new timezone columns
+ updateSql("cal_events", "event_start");
+ updateSql("cal_events", "event_end");
+ updateSql("cal_todos", "todo_entry");
+ updateSql("cal_todos", "todo_due");
+ updateSql("cal_todos", "todo_completed");
+
+ setDbVersionAndCommit(db, 3);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Upgrade to version 4.
+ * Bug 293183 - implement exception support for recurrence.
+ * r=shaver,p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v4 = function (db, version) {
+ let tbl = upgrade.v3(version < 3 && db, version);
+ LOGdb(db, "Storage: Upgrading to v4");
+
+ beginTransaction(db);
+ try {
+ for (let tblid of ["events", "todos", "attendees", "properties"]) {
+ addColumn(tbl, "cal_" + tblid, "recurrence_id", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "recurrence_id_tz", "VARCHAR", db);
+ }
+ setDbVersionAndCommit(db, 4);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 315051 - Switch to storing alarms based on offsets from start/end time
+ * rather than as absolute times. Ensure that missed alarms are fired.
+ * r=dmose, p=jminta
+ */
+// eslint-disable-next-line id-length
+upgrade.v5 = function (db, version) {
+ let tbl = upgrade.v4(version < 4 && db, version);
+ LOGdb(db, "Storage: Upgrading to v5");
+
+ beginTransaction(db);
+ try {
+ for (let tblid of ["events", "todos"]) {
+ addColumn(tbl, "cal_" + tblid, "alarm_offset", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "alarm_related", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "alarm_last_ack", "INTEGER", db);
+ }
+ setDbVersionAndCommit(db, 5);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 333688 - Converts STRING and VARCHAR columns to TEXT to avoid SQLite's
+ * auto-conversion of strings to numbers (10e4 to 10000)
+ * r=ctalbert,jminta p=lilmatt
+ */
+// eslint-disable-next-line id-length
+upgrade.v6 = function (db, version) {
+ let tbl = upgrade.v5(version < 5 && db, version);
+ LOGdb(db, "Storage: Upgrading to v6");
+
+ beginTransaction(db);
+ try {
+ let eventCols = [
+ "id",
+ "title",
+ "privacy",
+ "ical_status",
+ "recurrence_id_tz",
+ "event_start_tz",
+ "event_end_tz",
+ "alarm_time_tz",
+ ];
+ alterTypes(tbl, "cal_events", eventCols, "TEXT", db);
+
+ let todoCols = [
+ "id",
+ "title",
+ "privacy",
+ "ical_status",
+ "recurrence_id_tz",
+ "todo_entry_tz",
+ "todo_due_tz",
+ "todo_completed_tz",
+ "alarm_time_tz",
+ ];
+ alterTypes(tbl, "cal_todos", todoCols, "TEXT", db);
+
+ let attendeeCols = [
+ "item_id",
+ "recurrence_id_tz",
+ "attendee_id",
+ "common_name",
+ "role",
+ "status",
+ "type",
+ ];
+ alterTypes(tbl, "cal_attendees", attendeeCols, "TEXT", db);
+
+ let recurrenceCols = [
+ "item_id",
+ "recur_type",
+ "dates",
+ "second",
+ "minute",
+ "hour",
+ "day",
+ "monthday",
+ "yearday",
+ "weekno",
+ "month",
+ "setpos",
+ ];
+ alterTypes(tbl, "cal_recurrence", recurrenceCols, "TEXT", db);
+
+ let propertyCols = ["item_id", "recurrence_id_tz", "key"];
+ alterTypes(tbl, "cal_properties", propertyCols, "TEXT", db);
+ setDbVersionAndCommit(db, 6);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 369010: Migrate all old tzids in storage to new one.
+ * r=ctalbert,dmose p=lilmatt
+ */
+// eslint-disable-next-line id-length
+upgrade.v7 = function (db, version) {
+ // No schema changes in v7
+ let tbl = upgrade.v6(db, version);
+ LOGdb(db, "Storage: Upgrading to v7");
+ return tbl;
+};
+
+/**
+ * Bug 410931 - Update internal timezone definitions
+ * r=ctalbert, p=dbo,nth10sd,hb
+ */
+// eslint-disable-next-line id-length
+upgrade.v8 = function (db, version) {
+ // No schema changes in v8
+ let tbl = upgrade.v7(db, version);
+ LOGdb(db, "Storage: Upgrading to v8");
+ return tbl;
+};
+
+/**
+ * Bug 363191 - Handle Timezones more efficiently (Timezone Database)
+ * r=philipp,ctalbert, p=dbo
+ */
+// eslint-disable-next-line id-length
+upgrade.v9 = function (db, version) {
+ // No schema changes in v9
+ let tbl = upgrade.v8(db, version);
+ LOGdb(db, "Storage: Upgrading to v9");
+ return tbl;
+};
+
+/**
+ * Bug 413908 – Events using internal timezones are no longer updated to
+ * recent timezone version;
+ * r=philipp, p=dbo
+ */
+upgrade.v10 = function (db, version) {
+ let tbl = upgrade.v9(version < 9 && db, version);
+ LOGdb(db, "Storage: Upgrading to v10");
+
+ beginTransaction(db);
+ try {
+ addTable(tbl, "cal_tz_version", { version: "TEXT" }, db);
+ setDbVersionAndCommit(db, 10);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Fix bug 319909 - Failure to properly serialize/unserialize ics ATTACH
+ * properties.
+ * r=philipp,p=fred.jen@web.de
+ */
+upgrade.v11 = function (db, version) {
+ let tbl = upgrade.v10(version < 10 && db, version);
+ LOGdb(db, "Storage: Upgrading to v11");
+
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_attachments",
+ {
+ item_id: "TEXT",
+ data: "BLOB",
+ format_type: "TEXT",
+ encoding: "TEXT",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 11);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 449031 - Add meta data API to memory/storage
+ * r=philipp, p=dbo
+ */
+upgrade.v12 = function (db, version) {
+ let tbl = upgrade.v11(version < 11 && db, version);
+ LOGdb(db, "Storage: Upgrading to v12");
+
+ beginTransaction(db);
+ try {
+ addColumn(tbl, "cal_attendees", "is_organizer", "BOOLEAN", db);
+ addColumn(tbl, "cal_attendees", "properties", "BLOB", db);
+
+ addTable(
+ tbl,
+ "cal_metadata",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT UNIQUE",
+ value: "BLOB",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 12);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 449401 - storage provider doesn't cleanly separate items of the same id
+ * across different calendars
+ * r=dbo,philipp, p=wsourdeau@inverse.ca
+ */
+upgrade.v13 = function (db, version) {
+ let tbl = upgrade.v12(version < 12 && db, version);
+ LOGdb(db, "Storage: Upgrading to v13");
+
+ beginTransaction(db);
+ try {
+ alterTypes(tbl, "cal_metadata", ["item_id"], "TEXT", db);
+
+ let calIds = {};
+ if (db) {
+ for (let itemTable of ["events", "todos"]) {
+ let stmt = createStatement(db, `SELECT id, cal_id FROM cal_${itemTable}`);
+ try {
+ while (stmt.executeStep()) {
+ calIds[stmt.row.id] = stmt.row.cal_id;
+ }
+ } finally {
+ stmt.finalize();
+ }
+ }
+ }
+ let tables = ["attendees", "recurrence", "properties", "attachments"];
+ for (let tblid of tables) {
+ addColumn(tbl, "cal_" + tblid, "cal_id", "INTEGER", db);
+
+ for (let itemId in calIds) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE cal_${tblid}` +
+ ` SET cal_id = ${calIds[itemId]}` +
+ ` WHERE item_id = '${itemId}'`
+ );
+ }
+ }
+
+ executeSimpleSQL(db, "DROP INDEX IF EXISTS idx_cal_properies_item_id");
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "CREATE INDEX IF NOT EXISTS" +
+ " idx_cal_properies_item_id" +
+ " ON cal_properties(cal_id, item_id);"
+ );
+ setDbVersionAndCommit(db, 13);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 446303 - use the "RELATED-TO" property.
+ * r=philipp,dbo, p=fred.jen@web.de
+ */
+upgrade.v14 = function (db, version) {
+ let tbl = upgrade.v13(version < 13 && db, version);
+ LOGdb(db, "Storage: Upgrading to v14");
+
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_relations",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT",
+ rel_type: "TEXT",
+ rel_id: "TEXT",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 14);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 463282 - Tasks cannot be created or imported (regression).
+ * r=philipp,berend, p=dbo
+ */
+upgrade.v15 = function (db, version) {
+ let tbl = upgrade.v14(version < 14 && db, version);
+ LOGdb(db, "Storage: Upgrading to v15");
+
+ beginTransaction(db);
+ try {
+ addColumn(tbl, "cal_todos", "todo_stamp", "INTEGER", db);
+ setDbVersionAndCommit(db, 15);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 353492 - support multiple alarms per events/task, support
+ * absolute alarms with fixed date/time - Storage Provider support for multiple
+ * alarms.
+ * r=dbo,ssitter, p=philipp
+ *
+ * This upgrader is a bit special. To fix bug 494140, we decided to change the
+ * upgrading code afterwards to make sure no data is lost for people upgrading
+ * from 0.9 -> 1.0b1 and later. The v17 upgrader will merely take care of the
+ * upgrade if a user is upgrading from 1.0pre -> 1.0b1 or later.
+ */
+upgrade.v16 = function (db, version) {
+ let tbl = upgrade.v15(version < 15 && db, version);
+ LOGdb(db, "Storage: Upgrading to v16");
+ beginTransaction(db);
+ try {
+ createFunction(db, "translateAlarm", 4, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aOffset, aRelated, aAlarmTime, aTzId] = mapStorageArgs(storArgs);
+
+ let alarm = new lazy.CalAlarm();
+ if (aOffset) {
+ alarm.related = parseInt(aRelated, 10) + 1;
+ alarm.offset = cal.createDuration();
+ alarm.offset.inSeconds = aOffset;
+ } else if (aAlarmTime) {
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ let alarmDate = cal.createDateTime();
+ alarmDate.nativeTime = aAlarmTime;
+ if (aTzId == "floating") {
+ // The current calDateTime code assumes that if a
+ // date is floating then we can just assign the new
+ // timezone. I have the feeling this is wrong so I
+ // filed bug 520463. Since we want to release 1.0b1
+ // soon, I will just fix this on the "client side"
+ // and do the conversion here.
+ alarmDate.timezone = cal.timezoneService.defaultTimezone;
+ alarmDate = alarmDate.getInTimezone(cal.dtz.UTC);
+ } else {
+ alarmDate.timezone = cal.timezoneService.getTimezone(aTzId);
+ }
+ alarm.alarmDate = alarmDate;
+ }
+ return alarm.icalString;
+ } catch (e) {
+ // Errors in this function are not really logged. Do this
+ // separately.
+ cal.ERROR("Error converting alarms: " + e);
+ throw e;
+ }
+ },
+ });
+
+ addTable(
+ tbl,
+ "cal_alarms",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT",
+ // Note the following two columns were not originally part of the
+ // v16 upgrade, see note above function.
+ recurrence_id: "INTEGER",
+ recurrence_id_tz: "TEXT",
+ icalString: "TEXT",
+ },
+ db
+ );
+
+ let copyDataOver = function (tblName) {
+ const transAlarm = "translateAlarm(alarm_offset, alarm_related, alarm_time, alarm_time_tz)";
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "INSERT INTO cal_alarms (cal_id, item_id," +
+ " recurrence_id, " +
+ " recurrence_id_tz, " +
+ " icalString)" +
+ " SELECT cal_id, id, recurrence_id," +
+ ` recurrence_id_tz, ${transAlarm}` +
+ ` FROM ${tblName}` +
+ " WHERE alarm_offset IS NOT NULL" +
+ " OR alarm_time IS NOT NULL;"
+ );
+ };
+ copyDataOver("cal_events");
+ copyDataOver("cal_todos");
+ removeFunction(db, "translateAlarm");
+
+ // Make sure the alarm flag is set on the item
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_events " +
+ ` SET flags = flags | ${CAL_ITEM_FLAG.HAS_ALARMS}` +
+ " WHERE id IN" +
+ " (SELECT item_id " +
+ " FROM cal_alarms " +
+ " WHERE cal_alarms.cal_id = cal_events.cal_id)"
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_todos " +
+ ` SET flags = flags | ${CAL_ITEM_FLAG.HAS_ALARMS}` +
+ " WHERE id IN" +
+ " (SELECT item_id " +
+ " FROM cal_alarms " +
+ " WHERE cal_alarms.cal_id = cal_todos.cal_id)"
+ );
+
+ // Remote obsolete columns
+ let cols = ["alarm_time", "alarm_time_tz", "alarm_offset", "alarm_related"];
+ for (let tblid of ["events", "todos"]) {
+ deleteColumns(tbl, "cal_" + tblid, cols, db);
+ }
+
+ setDbVersionAndCommit(db, 16);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 494140 - Multiple reminders,relations,attachments created by modifying
+ * repeating event.
+ * r=dbo,ssitter, p=philipp
+ *
+ * This upgrader is special. In bug 494140 we decided it would be better to fix
+ * the v16 upgrader so 0.9 users can update to 1.0b1 and later without dataloss.
+ * Therefore all this upgrader does is handle users of 1.0pre before the
+ * mentioned bug.
+ */
+upgrade.v17 = function (db, version) {
+ let tbl = upgrade.v16(version < 16 && db, version);
+ LOGdb(db, "Storage: Upgrading to v17");
+ beginTransaction(db);
+ try {
+ for (let tblName of ["alarms", "relations", "attachments"]) {
+ let hasColumns = true;
+ let stmt;
+ try {
+ // Stepping this statement will fail if the columns don't exist.
+ // We don't use the delegate here since it would show an error to
+ // the user, even through we expect the error. If the db is null,
+ // then swallowing the error is ok too since the cols will
+ // already be added in v16.
+ stmt = db.createStatement(
+ `SELECT recurrence_id_tz, recurrence_id FROM cal_${tblName} LIMIT 1`
+ );
+ stmt.executeStep();
+ } catch (e) {
+ // An error happened, which means the cols don't exist
+ hasColumns = false;
+ } finally {
+ if (stmt) {
+ stmt.finalize();
+ }
+ }
+
+ // Only add the columns if they are not there yet (i.e added in v16)
+ // Since relations were broken all along, also make sure and add the
+ // columns to the javascript object if there is no database.
+ if (!hasColumns || !db) {
+ addColumn(tbl, "cal_" + tblName, "recurrence_id", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblName, "recurrence_id_tz", "TEXT", db);
+ }
+
+ // Clear out entries that are exactly the same. This corrects alarms
+ // created in 1.0pre and relations and attachments created in 0.9.
+ copyTable(tbl, "cal_" + tblName, "cal_" + tblName + "_v17", db, null, "DISTINCT");
+ renameTable(tbl, "cal_" + tblName + "_v17", "cal_" + tblName, db, true);
+ }
+ setDbVersionAndCommit(db, 17);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 529326 - Create indexes for the local calendar
+ * r=mschroeder, p=philipp
+ *
+ * This bug adds some indexes to improve performance. If you would like to add
+ * additional indexes, please read http://www.sqlite.org/optoverview.html first.
+ */
+upgrade.v18 = function (db, version) {
+ let tbl = upgrade.v17(version < 17 && db, version);
+ LOGdb(db, "Storage: Upgrading to v18");
+ beginTransaction(db);
+ try {
+ // These fields are often indexed over
+ let simpleIds = ["cal_id", "item_id"];
+ let allIds = simpleIds.concat(["recurrence_id", "recurrence_id_tz"]);
+
+ // Alarms, Attachments, Attendees, Relations
+ for (let tblName of ["alarms", "attachments", "attendees", "relations"]) {
+ createIndex(tbl, "cal_" + tblName, allIds, db);
+ }
+
+ // Events and Tasks
+ for (let tblName of ["events", "todos"]) {
+ createIndex(tbl, "cal_" + tblName, ["flags", "cal_id", "recurrence_id"], db);
+ createIndex(tbl, "cal_" + tblName, ["id", "cal_id", "recurrence_id"], db);
+ }
+
+ // Metadata
+ createIndex(tbl, "cal_metadata", simpleIds, db);
+
+ // Properties. Remove the index we used to create first, since our index
+ // is much more complete.
+ executeSimpleSQL(db, "DROP INDEX IF EXISTS idx_cal_properies_item_id");
+ createIndex(tbl, "cal_properties", allIds, db);
+
+ // Recurrence
+ createIndex(tbl, "cal_recurrence", simpleIds, db);
+
+ setDbVersionAndCommit(db, 18);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 479867 - Cached calendars don't set id correctly, causing duplicate
+ * events to be shown for multiple cached calendars
+ * r=simon.at.orcl, p=philipp,dbo
+ */
+upgrade.v19 = function (db, version) {
+ let tbl = upgrade.v18(version < 18 && db, version);
+ LOGdb(db, "Storage: Upgrading to v19");
+ beginTransaction(db);
+ try {
+ let tables = [
+ "cal_alarms",
+ "cal_attachments",
+ "cal_attendees",
+ "cal_events",
+ "cal_metadata",
+ "cal_properties",
+ "cal_recurrence",
+ "cal_relations",
+ "cal_todos",
+ ];
+ // Change types of column to TEXT.
+ for (let tblName of tables) {
+ alterTypes(tbl, tblName, ["cal_id"], "TEXT", db);
+ }
+ setDbVersionAndCommit(db, 19);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 380060 - Offline Sync feature for calendar
+ * Setting a offline_journal column in cal_events tables
+ * r=philipp, p=redDragon
+ */
+upgrade.v20 = function (db, version) {
+ let tbl = upgrade.v19(version < 19 && db, version);
+ LOGdb(db, "Storage: Upgrading to v20");
+ beginTransaction(db);
+ try {
+ // Adding a offline_journal column
+ for (let tblName of ["cal_events", "cal_todos"]) {
+ addColumn(tbl, tblName, ["offline_journal"], "INTEGER", db);
+ }
+ setDbVersionAndCommit(db, 20);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 785659 - Get rid of calIRecurrenceDateSet
+ * Migrate x-dateset to x-date in the storage database
+ * r=mmecca, p=philipp
+ */
+upgrade.v21 = function (db, version) {
+ let tbl = upgrade.v20(version < 20 && db, version);
+ LOGdb(db, "Storage: Upgrading to v21");
+ beginTransaction(db);
+
+ try {
+ // The following operation is only important on a live DB, since we are
+ // changing only the values on the DB, not the schema itself.
+ if (db) {
+ // Oh boy, here we go :-)
+ // Insert a new row with the following columns...
+ let insertSQL =
+ "INSERT INTO cal_recurrence " +
+ " (item_id, cal_id, recur_type, recur_index," +
+ " is_negative, dates, end_date, count," +
+ " interval, second, minute, hour, day," +
+ " monthday, yearday, weekno, month, setpos)" +
+ // ... by selecting some columns from the existing table ...
+ ' SELECT item_id, cal_id, "x-date" AS recur_type, ' +
+ // ... like a new recur_index, we need it to be maximum for this item ...
+ " (SELECT MAX(recur_index)+1" +
+ " FROM cal_recurrence AS rinner " +
+ " WHERE rinner.item_id = router.item_id" +
+ " AND rinner.cal_id = router.cal_id) AS recur_index," +
+ " is_negative," +
+ // ... the string until the first comma in the current dates field
+ ' SUBSTR(dates, 0, LENGTH(dates) - LENGTH(LTRIM(dates, REPLACE(dates, ",", ""))) + 1) AS dates,' +
+ " end_date, count, interval, second, minute," +
+ " hour, day, monthday, yearday, weekno, month," +
+ " setpos" +
+ // ... from the recurrence table ...
+ " FROM cal_recurrence AS router " +
+ // ... but only on fields that are x-datesets ...
+ ' WHERE recur_type = "x-dateset" ' +
+ // ... and are not already empty.
+ ' AND dates != ""';
+ dump(insertSQL + "\n");
+
+ // Now we need to remove the first segment from the dates field
+ let updateSQL =
+ "UPDATE cal_recurrence" +
+ ' SET dates = SUBSTR(dates, LENGTH(dates) - LENGTH(LTRIM(dates, REPLACE(dates, ",", ""))) + 2)' +
+ ' WHERE recur_type = "x-dateset"' +
+ ' AND dates != ""';
+
+ // Create the statements
+ let insertStmt = createStatement(db, insertSQL);
+ let updateStmt = createStatement(db, updateSQL);
+
+ // Repeat these two statements until the update affects 0 rows
+ // (because the dates field on all x-datesets is empty)
+ do {
+ insertStmt.execute();
+ updateStmt.execute();
+ } while (db.affectedRows > 0);
+
+ // Finally we can delete the x-dateset rows. Note this will leave
+ // gaps in recur_index, but that's ok since its only used for
+ // ordering anyway and will be overwritten on the next item write.
+ executeSimpleSQL(db, 'DELETE FROM cal_recurrence WHERE recur_type = "x-dateset"');
+ }
+
+ setDbVersionAndCommit(db, 21);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 785733 - Move some properties to use icalString in database.
+ * Use the full icalString in attendees, attachments, relations and recurrence
+ * tables.
+ * r=mmecca, p=philipp
+ */
+upgrade.v22 = function (db, version) {
+ let tbl = upgrade.v21(version < 21 && db, version);
+ LOGdb(db, "Storage: Upgrading to v22");
+ beginTransaction(db);
+ try {
+ // Update attachments to using icalString directly
+ createFunction(db, "translateAttachment", 3, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aData, aFmtType, aEncoding] = mapStorageArgs(storArgs);
+
+ let attach = new lazy.CalAttachment();
+ attach.uri = Services.io.newURI(aData);
+ attach.formatType = aFmtType;
+ attach.encoding = aEncoding;
+ return attach.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting attachment: " + e);
+ throw e;
+ }
+ },
+ });
+ migrateToIcalString(
+ tbl,
+ "cal_attachments",
+ "translateAttachment",
+ ["data", "format_type", "encoding"],
+ db
+ );
+
+ // Update relations to using icalString directly
+ createFunction(db, "translateRelation", 2, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aRelType, aRelId] = mapStorageArgs(storArgs);
+ let relation = new lazy.CalRelation();
+ relation.relType = aRelType;
+ relation.relId = aRelId;
+ return relation.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting relation: " + e);
+ throw e;
+ }
+ },
+ });
+ migrateToIcalString(tbl, "cal_relations", "translateRelation", ["rel_type", "rel_id"], db);
+
+ // Update attendees table to using icalString directly
+ createFunction(db, "translateAttendee", 8, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aAttendeeId, aCommonName, aRsvp, aRole, aStatus, aType, aIsOrganizer, aProperties] =
+ mapStorageArgs(storArgs);
+
+ let attendee = new lazy.CalAttendee();
+
+ attendee.id = aAttendeeId;
+ attendee.commonName = aCommonName;
+
+ switch (aRsvp) {
+ case 0:
+ attendee.rsvp = "FALSE";
+ break;
+ case 1:
+ attendee.rsvp = "TRUE";
+ break;
+ // default: keep undefined
+ }
+
+ attendee.role = aRole;
+ attendee.participationStatus = aStatus;
+ attendee.userType = aType;
+ attendee.isOrganizer = !!aIsOrganizer;
+ if (aProperties) {
+ for (let pair of aProperties.split(",")) {
+ let [key, value] = pair.split(":");
+ attendee.setProperty(decodeURIComponent(key), decodeURIComponent(value));
+ }
+ }
+
+ return attendee.icalString;
+ } catch (e) {
+ // There are some attendees with a null ID. We are taking
+ // the opportunity to remove them here.
+ cal.ERROR("Error converting attendee, removing: " + e);
+ return null;
+ }
+ },
+ });
+ migrateToIcalString(
+ tbl,
+ "cal_attendees",
+ "translateAttendee",
+ [
+ "attendee_id",
+ "common_name",
+ "rsvp",
+ "role",
+ "status",
+ "type",
+ "is_organizer",
+ "properties",
+ ],
+ db
+ );
+
+ // Update recurrence table to using icalString directly
+ createFunction(db, "translateRecurrence", 17, {
+ onFunctionCall(storArgs) {
+ function parseInt10(x) {
+ return parseInt(x, 10);
+ }
+ try {
+ let [
+ // eslint-disable-next-line no-unused-vars
+ aIndex,
+ aType,
+ aIsNegative,
+ aDates,
+ aCount,
+ aEndDate,
+ aInterval,
+ aSecond,
+ aMinute,
+ aHour,
+ aDay,
+ aMonthday,
+ aYearday,
+ aWeekno,
+ aMonth,
+ aSetPos,
+ aTmpFlags,
+ ] = mapStorageArgs(storArgs);
+
+ let ritem;
+ if (aType == "x-date") {
+ ritem = cal.createRecurrenceDate();
+ ritem.date = textToDate(aDates);
+ ritem.isNegative = !!aIsNegative;
+ } else {
+ ritem = cal.createRecurrenceRule();
+ ritem.type = aType;
+ ritem.isNegative = !!aIsNegative;
+ if (aCount) {
+ try {
+ ritem.count = aCount;
+ } catch (exc) {
+ // Don't fail if setting an invalid count
+ }
+ } else if (aEndDate) {
+ let allday = (aTmpFlags & CAL_ITEM_FLAG.EVENT_ALLDAY) != 0;
+ let untilDate = newDateTime(aEndDate, allday ? "" : "UTC");
+ if (allday) {
+ untilDate.isDate = true;
+ }
+ ritem.untilDate = untilDate;
+ } else {
+ ritem.untilDate = null;
+ }
+
+ try {
+ ritem.interval = aInterval;
+ } catch (exc) {
+ // Don't fail if setting an invalid interval
+ }
+
+ let rtypes = {
+ SECOND: aSecond,
+ MINUTE: aMinute,
+ HOUR: aHour,
+ DAY: aDay,
+ MONTHDAY: aMonthday,
+ YEARDAY: aYearday,
+ WEEKNO: aWeekno,
+ MONTH: aMonth,
+ SETPOS: aSetPos,
+ };
+
+ for (let rtype in rtypes) {
+ if (rtypes[rtype]) {
+ let comp = "BY" + rtype;
+ let rstr = rtypes[rtype].toString();
+ let rarray = rstr.split(",").map(parseInt10);
+ ritem.setComponent(comp, rarray);
+ }
+ }
+ }
+
+ return ritem.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting recurrence: " + e);
+ throw e;
+ }
+ },
+ });
+
+ // The old code relies on the item allday state, we need to temporarily
+ // copy this into the rec table so the above function can update easier.
+ // This column will be deleted during the migrateToIcalString call.
+ addColumn(tbl, "cal_recurrence", ["tmp_date_tz"], "", db);
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_recurrence SET tmp_date_tz = " +
+ "(SELECT e.flags FROM cal_events AS e " +
+ " WHERE e.id = cal_recurrence.item_id " +
+ " AND e.cal_id = cal_recurrence.cal_id " +
+ " UNION SELECT t.flags FROM cal_todos AS t " +
+ " WHERE t.id = cal_recurrence.item_id " +
+ " AND t.cal_id = cal_recurrence.cal_id)"
+ );
+
+ migrateToIcalString(
+ tbl,
+ "cal_recurrence",
+ "translateRecurrence",
+ [
+ "recur_index",
+ "recur_type",
+ "is_negative",
+ "dates",
+ "count",
+ "end_date",
+ "interval",
+ "second",
+ "minute",
+ "hour",
+ "day",
+ "monthday",
+ "yearday",
+ "weekno",
+ "month",
+ "setpos",
+ "tmp_date_tz",
+ ],
+ db
+ );
+
+ setDbVersionAndCommit(db, 22);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+upgrade.v23 = function (db, version) {
+ let tbl = upgrade.v22(version < 22 && db, version);
+ LOGdb(db, "Storage: Upgrading to v23");
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_parameters",
+ {
+ cal_id: "TEXT",
+ item_id: "TEXT",
+ recurrence_id: "INTEGER",
+ recurrence_id_tz: "TEXT",
+ key1: "TEXT",
+ key2: "TEXT",
+ value: "BLOB",
+ },
+ db
+ );
+ let allIds = ["cal_id", "item_id", "recurrence_id", "recurrence_id_tz"];
+ createIndex(tbl, "cal_parameters", allIds, db);
+ setDbVersionAndCommit(db, 23);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
diff --git a/comm/calendar/providers/storage/components.conf b/comm/calendar/providers/storage/components.conf
new file mode 100644
index 0000000000..a040500694
--- /dev/null
+++ b/comm/calendar/providers/storage/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/
+
+Classes = [
+ {
+ 'cid': '{b3eaa1c4-5dfe-4c0a-b62a-b3a514218461}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=storage'],
+ 'jsm': 'resource:///modules/CalStorageCalendar.jsm',
+ 'constructor': 'CalStorageCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/storage/moz.build b/comm/calendar/providers/storage/moz.build
new file mode 100644
index 0000000000..01343a30b5
--- /dev/null
+++ b/comm/calendar/providers/storage/moz.build
@@ -0,0 +1,28 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+ "CalStorageCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXTRA_JS_MODULES.calendar += [
+ "CalStorageCachedItemModel.jsm",
+ "CalStorageDatabase.jsm",
+ "calStorageHelpers.jsm",
+ "CalStorageItemModel.jsm",
+ "CalStorageMetaDataModel.jsm",
+ "CalStorageModelBase.jsm",
+ "CalStorageModelFactory.jsm",
+ "CalStorageOfflineModel.jsm",
+ "CalStorageStatements.jsm",
+ "calStorageUpgrade.jsm",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: Local Storage")
diff --git a/comm/calendar/test/.eslintrc.js b/comm/calendar/test/.eslintrc.js
new file mode 100644
index 0000000000..fc89c84bb1
--- /dev/null
+++ b/comm/calendar/test/.eslintrc.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Calendar tests run with the pref calendar.timezone.local set to UTC. This
+// works fine on the CI, where the system clock is also UTC, but on developers'
+// machines the time difference causes some problems. If you have to use the
+// Date object, make sure that you use UTC methods.
+
+module.exports = {
+ rules: {
+ "no-restricted-properties": [
+ "error",
+ {
+ property: "getFullYear",
+ message: "These tests run in UTC. Use 'getUTCFullYear' instead.",
+ },
+ {
+ property: "getMonth",
+ message: "These tests run in UTC. Use 'getUTCMonth' instead.",
+ },
+ {
+ property: "getDay",
+ message: "These tests run in UTC. Use 'getUTCDay' instead.",
+ },
+ {
+ property: "getDate",
+ message: "These tests run in UTC. Use 'getUTCDate' instead.",
+ },
+ {
+ property: "getHours",
+ message: "These tests run in UTC. Use 'getUTCHours' instead.",
+ },
+ {
+ property: "getMinutes",
+ message: "These tests run in UTC. Use 'getUTCMinutes' instead.",
+ },
+ {
+ property: "setFullYear",
+ message: "These tests run in UTC. Use 'setUTCFullYear' instead.",
+ },
+ {
+ property: "setMonth",
+ message: "These tests run in UTC. Use 'setUTCMonth' instead.",
+ },
+ {
+ property: "setDay",
+ message: "These tests run in UTC. Use 'setUTCDay' instead.",
+ },
+ {
+ property: "setDate",
+ message: "These tests run in UTC. Use 'setUTCDate' instead.",
+ },
+ {
+ property: "setHours",
+ message: "These tests run in UTC. Use 'setUTCHours' instead.",
+ },
+ {
+ property: "setMinutes",
+ message: "These tests run in UTC. Use 'setUTCMinutes' instead.",
+ },
+ ],
+ "no-restricted-syntax": [
+ "error",
+ {
+ selector: "[callee.name='Date'][arguments.length>=2]",
+ message:
+ "These tests run in UTC. Use 'new Date(Date.UTC(...))' to construct a Date with arguments.",
+ },
+ ],
+ },
+};
diff --git a/comm/calendar/test/CalDAVServer.jsm b/comm/calendar/test/CalDAVServer.jsm
new file mode 100644
index 0000000000..aff2242f43
--- /dev/null
+++ b/comm/calendar/test/CalDAVServer.jsm
@@ -0,0 +1,627 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["CalDAVServer"];
+
+const PREFIX_BINDINGS = {
+ c: "urn:ietf:params:xml:ns:caldav",
+ cs: "http://calendarserver.org/ns/",
+ d: "DAV:",
+ i: "http://apple.com/ns/ical/",
+};
+const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
+ .map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
+ .join(" ");
+
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+const logger = console.createInstance({
+ prefix: "CalDAVServer",
+ maxLogLevel: "Log",
+});
+
+// The response bodies Google sends if you exceed its rate limit.
+let MULTIGET_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?>
+<D:error xmlns:D="DAV:"/>
+`;
+let PROPFIND_RATELIMIT_ERROR = `<?xml version="1.0" encoding="UTF-8"?>
+<errors xmlns="http://schemas.google.com/g/2005">
+ <error>
+ <domain>GData</domain>
+ <code>rateLimitExceeded</code>
+ <internalReason>Some text we're not looking at anyway</internalReason>
+ </error>
+</errors>
+`;
+
+var CalDAVServer = {
+ items: new Map(),
+ deletedItems: new Map(),
+ changeCount: 0,
+ server: null,
+ isOpen: false,
+
+ /**
+ * The "current-user-privilege-set" in responses. Set to null to have no privilege set.
+ */
+ privileges: "<d:privilege><d:all/></d:privilege>",
+
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+
+ this.reset();
+ },
+
+ reset() {
+ this.items.clear();
+ this.deletedItems.clear();
+ this.changeCount = 0;
+ this.privileges = "<d:privilege><d:all/></d:privilege>";
+ this.resetHandlers();
+ },
+
+ resetHandlers() {
+ this.server.registerPathHandler("/.well-known/caldav", this.wellKnown.bind(this));
+ this.server.registerPathHandler("/principals/", this.principals.bind(this));
+ this.server.registerPathHandler("/principals/me/", this.myPrincipal.bind(this));
+ this.server.registerPathHandler("/calendars/me/", this.myCalendars.bind(this));
+
+ this.server.registerPathHandler(this.path, this.directoryHandler.bind(this));
+ this.server.registerPrefixHandler(this.path, this.itemHandler.bind(this));
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/calendars/me/test/";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ wellKnown(request, response) {
+ response.setStatusLine("1.1", 301, "Moved Permanently");
+ response.setHeader("Location", "/principals/");
+ },
+
+ principals(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:current-user-principal": "<href>/principals/me/</href>",
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/principals/</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ myPrincipal(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<principal/>",
+ "c:calendar-home-set": "<d:href>/calendars/me/</d:href>",
+ "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
+ "c:schedule-inbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
+ "c:schedule-outbox-URL": "<d:href>/calendars/me/inbox/</d:href>",
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/principals/me/</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ myCalendars(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method == "OPTIONS") {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("DAV", "1,2,3, calendar-access, calendar-schedule");
+ return;
+ }
+
+ let input = new DOMParser().parseFromString(
+ CommonUtils.readBytesFromInputStream(request.bodyInputStream),
+ "text/xml"
+ );
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<collection/><c:calendar/>",
+ "d:displayname": "CalDAV Test",
+ "i:calendar-color": "#ff8000",
+ "d:current-user-privilege-set": this.privileges,
+ };
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(
+ `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>/addressbooks/me/</href>
+ ${this._outputProps(propNames, {
+ "d:resourcetype": "<collection/>",
+ "d:displayname": "#calendars",
+ })}
+ </response>
+ <response>
+ <href>${this.path}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>
+ </multistatus>`.replace(/>\s+</g, "><")
+ );
+ },
+
+ /** Handle any requests to the calendar itself. */
+
+ directoryHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method == "OPTIONS") {
+ response.setStatusLine("1.1", 204, "No Content");
+ return;
+ }
+
+ let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ logger.log("C: " + input);
+ input = new DOMParser().parseFromString(input, "text/xml");
+
+ switch (input.documentElement.localName) {
+ case "calendar-query":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarQuery(input, response);
+ return;
+ case "calendar-multiget":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.c);
+ this.calendarMultiGet(input, response);
+ return;
+ case "propfind":
+ Assert.equal(request.method, "PROPFIND");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.propFind(input, request.hasHeader("Depth") ? request.getHeader("Depth") : 0, response);
+ return;
+ case "sync-collection":
+ Assert.equal(request.method, "REPORT");
+ Assert.equal(input.documentElement.namespaceURI, PREFIX_BINDINGS.d);
+ this.syncCollection(input, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`No handler found for <${input.documentElement.localName}>`);
+ },
+
+ calendarQuery(input, response) {
+ let propNames = this._inputProps(input);
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ async calendarMultiGet(input, response) {
+ let propNames = this._inputProps(input);
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ for (let href of input.querySelectorAll("href")) {
+ href = href.textContent;
+ let item = this.items.get(href);
+ if (item) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ propFind(input, depth, response) {
+ if (this.throwRateLimitErrors) {
+ response.setStatusLine("1.1", 403, "Forbidden");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(PROPFIND_RATELIMIT_ERROR);
+ logger.log("S: " + PROPFIND_RATELIMIT_ERROR);
+ return;
+ }
+
+ let propNames = this._inputProps(input);
+ let propValues = {
+ "d:resourcetype": "<d:collection/><c:calendar/>",
+ "d:owner": "/principals/me/",
+ "d:current-user-principal": "<href>/principals/me/</href>",
+ "d:current-user-privilege-set": this.privileges,
+ "d:supported-report-set":
+ "<d:supported-report><d:report><c:calendar-multiget/></d:report></d:supported-report>" +
+ "<d:supported-report><d:report><sync-collection/></d:report></d:supported-report>",
+ "c:supported-calendar-component-set": "",
+ "d:getcontenttype": "text/calendar; charset=utf-8",
+ "c:calendar-home-set": `<d:href>/calendars/me/</d:href>`,
+ "c:calendar-user-address-set": `<d:href preferred="1">mailto:me@invalid</d:href>`,
+ "c:schedule-inbox-url": `<d:href>/calendars/me/inbox/</d:href>`,
+ "c:schedule-outbox-url": `<d:href>/calendars/me/outbox/</d:href>`,
+ "cs:getctag": this.changeCount,
+ "d:getetag": this.changeCount,
+ };
+
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
+ <response>
+ <href>${this.path}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>`;
+ if (depth == 1) {
+ for (let [href, item] of this.items) {
+ output += this._itemResponse(href, item, propNames);
+ }
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ syncCollection(input, response) {
+ if (this.throwRateLimitErrors) {
+ response.setStatusLine("1.1", 403, "Forbidden");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(MULTIGET_RATELIMIT_ERROR);
+ logger.log("S: " + MULTIGET_RATELIMIT_ERROR);
+ return;
+ }
+
+ // The maximum number of responses to make at any one request.
+ let pageSize = 3;
+ // The last-seen token. Changes before this won't be returned.
+ let token = 0;
+ // Which page of responses to return.
+ let page = 0;
+
+ let tokenStr = input.querySelector("sync-token")?.textContent.replace(/.*\//g, "");
+ if (tokenStr?.includes("#")) {
+ [token, page] = tokenStr.split("#");
+ token = parseInt(token, 10);
+ page = parseInt(page, 10);
+ } else if (tokenStr) {
+ token = parseInt(tokenStr, 10);
+ }
+
+ let nextPage = page + 1;
+
+ // Collect all responses, even if we know some won't be returned.
+ // This is a test, who cares about performance?
+ let propNames = this._inputProps(input);
+ let responses = [];
+ for (let [href, item] of this.items) {
+ if (item.changed > token) {
+ responses.push(this._itemResponse(href, item, propNames));
+ }
+ }
+ for (let [href, deleted] of this.deletedItems) {
+ if (deleted > token) {
+ responses.push(`<response>
+ <status>HTTP/1.1 404 Not Found</status>
+ <href>${href}</href>
+ <propstat>
+ <prop/>
+ <status>HTTP/1.1 418 I'm a teapot</status>
+ </propstat>
+ </response>`);
+ }
+ }
+
+ let output = `<multistatus xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>`;
+ // Use only the responses that match those requested.
+ output += responses.slice(page * pageSize, nextPage * pageSize).join("");
+ if (responses.length > nextPage * pageSize) {
+ output += `<response>
+ <status>HTTP/1.1 507 Insufficient Storage</status>
+ <href>${this.path}</href>
+ </response>`;
+ output += `<sync-token>http://mochi.test/sync/${token}#${nextPage}</sync-token>`;
+ } else {
+ output += `<sync-token>http://mochi.test/sync/${this.changeCount}</sync-token>`;
+ }
+ output += `</multistatus>`;
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml");
+ response.write(output.replace(/>\s+</g, "><"));
+ logger.log("S: " + output.replace(/>\s+</g, "><"));
+ },
+
+ _itemResponse(href, item, propNames) {
+ let propValues = {
+ "c:calendar-data": item.ics,
+ "d:getetag": item.etag,
+ "d:getcontenttype": "text/calendar; charset=utf-8; component=VEVENT",
+ };
+
+ let outString = `<response>
+ <href>${href}</href>
+ ${this._outputProps(propNames, propValues)}
+ </response>`;
+ return outString;
+ },
+
+ _inputProps(input) {
+ let props = input.querySelectorAll("prop > *");
+ let propNames = [];
+
+ for (let p of props) {
+ Assert.equal(p.childElementCount, 0);
+ switch (p.localName) {
+ case "calendar-home-set":
+ case "calendar-user-address-set":
+ case "schedule-inbox-URL":
+ case "schedule-outbox-URL":
+ case "supported-calendar-component-set":
+ case "calendar-data":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.c);
+ propNames.push(`c:${p.localName}`);
+ break;
+ case "getctag":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.cs);
+ propNames.push(`cs:${p.localName}`);
+ break;
+ case "getetag":
+ case "owner":
+ case "current-user-principal":
+ case "current-user-privilege-set":
+ case "supported-report-set":
+ case "displayname":
+ case "resourcetype":
+ case "sync-token":
+ case "getcontenttype":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.d);
+ propNames.push(`d:${p.localName}`);
+ break;
+ case "calendar-color":
+ Assert.equal(p.namespaceURI, PREFIX_BINDINGS.i);
+ propNames.push(`i:${p.localName}`);
+ break;
+ default:
+ Assert.report(true, undefined, undefined, `Unknown property requested: ${p.nodeName}`);
+ break;
+ }
+ }
+
+ return propNames;
+ },
+
+ _outputProps(propNames, propValues) {
+ let output = "";
+
+ let found = [];
+ let notFound = [];
+ for (let p of propNames) {
+ if (p in propValues && propValues[p] != null) {
+ found.push(`<${p}>${propValues[p]}</${p}>`);
+ } else {
+ notFound.push(`<${p}/>`);
+ }
+ }
+
+ if (found.length > 0) {
+ output += `<propstat>
+ <prop>
+ ${found.join("\n")}
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>`;
+ }
+ if (notFound.length > 0) {
+ output += `<propstat>
+ <prop>
+ ${notFound.join("\n")}
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>`;
+ }
+
+ return output;
+ },
+
+ /** Handle any requests to calendar items. */
+
+ itemHandler(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (!/\/[\w-]+\.ics$/.test(request.path)) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ switch (request.method) {
+ case "GET":
+ this.getItem(request, response);
+ return;
+ case "PUT":
+ this.putItem(request, response);
+ return;
+ case "DELETE":
+ this.deleteItem(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ async getItem(request, response) {
+ let item = this.items.get(request.path);
+ if (!item) {
+ response.setStatusLine("1.1", 404, "Not Found");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Item not found at ${request.path}`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", item.etag);
+ response.write(item.ics);
+ },
+
+ async putItem(request, response) {
+ if (request.hasHeader("If-Match")) {
+ let item = this.items.get(request.path);
+ if (!item || item.etag != request.getHeader("If-Match")) {
+ response.setStatusLine("1.1", 412, "Precondition Failed");
+ return;
+ }
+ }
+
+ response.processAsync();
+
+ let ics = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ await this.putItemInternal(request.path, ics);
+ response.setStatusLine("1.1", 204, "No Content");
+
+ response.finish();
+ },
+
+ async putItemInternal(name, ics) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(ics));
+ let etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ this.items.set(name, { etag, ics, changed: ++this.changeCount });
+ this.deletedItems.delete(name);
+ },
+
+ deleteItem(request, response) {
+ this.deleteItemInternal(request.path);
+ response.setStatusLine("1.1", 204, "No Content");
+ },
+
+ deleteItemInternal(name) {
+ if (!name.startsWith("/")) {
+ name = this.path + name;
+ }
+ this.items.delete(name);
+ this.deletedItems.set(name, ++this.changeCount);
+ },
+};
diff --git a/comm/calendar/test/CalendarTestUtils.jsm b/comm/calendar/test/CalendarTestUtils.jsm
new file mode 100644
index 0000000000..42e587f540
--- /dev/null
+++ b/comm/calendar/test/CalendarTestUtils.jsm
@@ -0,0 +1,1203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["CalendarTestUtils"];
+
+const EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm");
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+async function clickAndWait(win, button) {
+ EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, win);
+ await new Promise(resolve => win.setTimeout(resolve));
+}
+
+/**
+ * @typedef EditItemAtResult
+ * @property {Window} dialogWindow - The window of the dialog.
+ * @property {Document} dialogDocument - The document of the dialog window.
+ * @property {Window} iframeWindow - The contentWindow property of the embedded
+ * iframe.
+ * @property {Document} iframeDocument - The contentDocument of the embedded
+ * iframe.
+ */
+
+/**
+ * Helper class for testing the day view of the calendar.
+ */
+class CalendarDayViewTestUtils {
+ #helper = new CalendarWeekViewTestUtils("#day-view");
+
+ /**
+ * Provides the column container element for the displayed day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {HTMLElement} - The column container element.
+ */
+ getColumnContainer(win) {
+ return this.#helper.getColumnContainer(win, 1);
+ }
+
+ /**
+ * Provides the element containing the formatted date for the displayed day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {HTMLElement} - The column heading container.
+ */
+ getColumnHeading(win) {
+ return this.#helper.getColumnHeading(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-column for the day displayed.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {MozCalendarEventColumn} - The column.
+ */
+ getEventColumn(win) {
+ return this.#helper.getEventColumn(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-box elements for the day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {MozCalendarEventBox[]} - The event boxes.
+ */
+ getEventBoxes(win) {
+ return this.#helper.getEventBoxes(win, 1);
+ }
+
+ /**
+ * Provides the calendar-event-box at "index" located in the event column for
+ * the day displayed.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox|undefined} - The event box, if it exists.
+ */
+ getEventBoxAt(win, index) {
+ return this.#helper.getEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Provides the .multiday-hour-box element for the specified hour. This
+ * element can be double clicked to create a new event at that hour.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} hour - Must be between 0-23.
+ *
+ * @returns {XULElement} - The hour box.
+ */
+ getHourBoxAt(win, hour) {
+ return this.#helper.getHourBoxAt(win, 1, hour);
+ }
+
+ /**
+ * Provides the all-day header, which can be double clicked to create a new
+ * all-day event.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ *
+ * @returns {CalendarHeaderContainer} - The all-day header.
+ */
+ getAllDayHeader(win) {
+ return this.#helper.getAllDayHeader(win, 1);
+ }
+
+ /**
+ * Provides the all-day calendar-editable-item located at index for the
+ * current day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it
+ * exists.
+ */
+ getAllDayItemAt(win, index) {
+ return this.#helper.getAllDayItemAt(win, 1, index);
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event
+ * column for the day displayed to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {Promise<MozCalendarEventBox>} - The event box.
+ */
+ async waitForEventBoxAt(win, index) {
+ return this.#helper.waitForEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event column
+ * for the current day to disappear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates the event box (1-based).
+ */
+ async waitForNoEventBoxAt(win, index) {
+ return this.#helper.waitForNoEventBoxAt(win, 1, index);
+ }
+
+ /**
+ * Wait for the all-day calendar-editable-item for the day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which item to select (1-based).
+ *
+ * @returns {Promise<MozCalendarEditableItem>} - The all-day item.
+ */
+ async waitForAllDayItemAt(win, index) {
+ return this.#helper.waitForAllDayItemAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for viewing for the event box located at the
+ * specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<Window>} - The summary event dialog window.
+ */
+ async viewEventAt(win, index) {
+ return this.#helper.viewEventAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing for the event box located at the
+ * specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventAt(win, index) {
+ return this.#helper.editEventAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the event
+ * box located at the specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrenceAt(win, index) {
+ return this.#helper.editEventOccurrenceAt(win, 1, index);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the event box
+ * located at the specified index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrencesAt(win, index) {
+ return this.#helper.editEventOccurrencesAt(win, 1, index);
+ }
+}
+
+/**
+ * Helper class for testing the week view of the calendar.
+ */
+class CalendarWeekViewTestUtils {
+ constructor(rootSelector = "#week-view") {
+ this.rootSelector = rootSelector;
+ }
+
+ /**
+ * Provides the column container element for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day parameter is out of range.
+ * @returns {HTMLElement} - The column container element.
+ */
+ getColumnContainer(win, day) {
+ if (day < 1 || day > 7) {
+ throw new Error(
+ `Invalid parameter to #getColumnContainer(): expected day=1-7, got day=${day}.`
+ );
+ }
+
+ let containers = win.document.documentElement.querySelectorAll(
+ `${this.rootSelector} .day-column-container`
+ );
+ return containers[day - 1];
+ }
+
+ /**
+ * Provides the element containing the formatted date for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day parameter is out of range.
+ * @returns {HTMLElement} - The column heading container element.
+ */
+ getColumnHeading(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector(".day-column-heading");
+ }
+
+ /**
+ * Provides the calendar-event-column for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7
+ *
+ * @throws - If the day parameter is out of range.
+ * @returns {MozCalendarEventColumn} - The column.
+ */
+ getEventColumn(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector("calendar-event-column");
+ }
+
+ /**
+ * Provides the calendar-event-box elements for the day specified.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @returns {MozCalendarEventBox[]} - The event boxes.
+ */
+ getEventBoxes(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelectorAll(".multiday-events-list calendar-event-box");
+ }
+
+ /**
+ * Provides the calendar-event-box at "index" located in the event column for
+ * the specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox|undefined} - The event box, if it exists.
+ */
+ getEventBoxAt(win, day, index) {
+ return this.getEventBoxes(win, day)[index - 1];
+ }
+
+ /**
+ * Provides the .multiday-hour-box element for the specified hour. This
+ * element can be double clicked to create a new event at that hour.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} hour - Must be between 0-23.
+ *
+ * @throws If the day or hour are out of range.
+ * @returns {XULElement} - The hour box.
+ */
+ getHourBoxAt(win, day, hour) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelectorAll(".multiday-hour-box")[hour];
+ }
+
+ /**
+ * Provides the all-day header, which can be double clicked to create a new
+ * all-day event for the specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ *
+ * @throws If the day is out of range.
+ * @returns {CalendarHeaderContainer} - The all-day header.
+ */
+ getAllDayHeader(win, day) {
+ let container = this.getColumnContainer(win, day);
+ return container.querySelector("calendar-header-container");
+ }
+
+ /**
+ * Provides the all-day calendar-editable-item located at "index" for the
+ * specified day.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which item to select (starting from 1).
+ *
+ * @throws If the day or index are out of range.
+ * @returns {MozCalendarEditableItem|undefined} - The all-day item, if it
+ * exists.
+ */
+ getAllDayItemAt(win, day, index) {
+ let allDayHeader = this.getAllDayHeader(win, day);
+ return allDayHeader.querySelectorAll("calendar-editable-item")[index - 1];
+ }
+
+ /**
+ * Waits for the calendar-event-box at "index", located in the event column
+ * for the day specified to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {MozCalendarEventBox} - The event box.
+ */
+ async waitForEventBoxAt(win, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getEventBoxAt(win, day, index),
+ `calendar-event-box at day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Waits until the calendar-event-box at "index", located in the event column
+ * for the day specified disappears.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ */
+ async waitForNoEventBoxAt(win, day, index) {
+ await TestUtils.waitForCondition(
+ () => !this.getEventBoxAt(win, day, index),
+ `calendar-event-box at day=${day}, index=${index} still present`
+ );
+ }
+
+ /**
+ * Waits for the all-day calendar-editable-item at "index", located in the
+ * event column for the day specified to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Day of the week, between 1-7.
+ * @param {number} index - Indicates which item to select (starting from 1).
+ *
+ * @returns {Promise<MozCalendarEditableItem>} - The all-day item.
+ */
+ async waitForAllDayItemAt(win, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getAllDayItemAt(win, day, index),
+ `All-day calendar-editable-item at day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Opens the event dialog for viewing for the event box located at the
+ * specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<Window>} - The summary event dialog window.
+ */
+ async viewEventAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.viewItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for the event box located at the
+ * specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the event
+ * box located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrenceAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItemOccurrence(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the event box
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which event box to select.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editEventOccurrencesAt(win, day, index) {
+ let item = await this.waitForEventBoxAt(win, day, index);
+ return CalendarTestUtils.editItemOccurrences(win, item);
+ }
+}
+
+/**
+ * Helper class for testing the multiweek and month views of the calendar.
+ */
+class CalendarMonthViewTestUtils {
+ /**
+ * @param {string} rootSelector
+ */
+ constructor(rootSelector) {
+ this.rootSelector = rootSelector;
+ }
+
+ /**
+ * Provides the calendar-month-day-box element located at the specified day,
+ * week combination.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6. The cap may be as low as 1
+ * depending on the user preference calendar.weeks.inview.
+ * @param {number} day - Must be between 1-7.
+ *
+ * @throws If the day or week parameters are out of range.
+ * @returns {MozCalendarMonthDayBox}
+ */
+ getDayBox(win, week, day) {
+ if (!(week >= 1 && week <= 6 && day >= 1 && day <= 7)) {
+ throw new Error(
+ `Invalid parameters to getDayBox(): ` +
+ `expected week=1-6, day=1-7, got week=${week}, day=${day},`
+ );
+ }
+
+ return win.document.documentElement.querySelector(
+ `${this.rootSelector} .monthbody > tr:nth-of-type(${week}) >
+ td:nth-of-type(${day}) > calendar-month-day-box`
+ );
+ }
+
+ /**
+ * Get the calendar-month-day-box-item located in the specified day box, at
+ * the target index.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @throws If the index, day or week parameters are out of range.
+ * @returns {MozCalendarMonthDayBoxItem}
+ */
+ getItemAt(win, week, day, index) {
+ if (!(index >= 1)) {
+ throw new Error(`Invalid parameters to getItemAt(): expected index>=1, got index=${index}.`);
+ }
+
+ let dayBox = this.getDayBox(win, week, day);
+ return dayBox.querySelector(`li:nth-of-type(${index}) calendar-month-day-box-item`);
+ }
+
+ /**
+ * Waits for the calendar-month-day-box-item at "index", located in the
+ * specified week,day combination to appear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {MozCalendarMonthDayBoxItem}
+ */
+ async waitForItemAt(win, week, day, index) {
+ return TestUtils.waitForCondition(
+ () => this.getItemAt(win, week, day, index),
+ `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} did not appear in time`
+ );
+ }
+
+ /**
+ * Waits for the calendar-month-day-box-item at "index", located in the
+ * specified week,day combination to disappear.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates the item that should no longer be present.
+ */
+ async waitForNoItemAt(win, week, day, index) {
+ await TestUtils.waitForCondition(
+ () => !this.getItemAt(win, week, day, index),
+ `calendar-month-day-box-item at week=${week}, day=${day}, index=${index} still present`
+ );
+ }
+
+ /**
+ * Opens the event dialog for viewing for the item located at the specified
+ * parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {Window} - The summary event dialog window.
+ */
+ async viewItemAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.viewItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for the item located at the specified
+ * parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItem(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing for a single occurrence of the item
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemOccurrenceAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItemOccurrence(win, item);
+ }
+
+ /**
+ * Opens the event dialog for editing all occurrences of the item
+ * located at the specified parameters.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} week - Must be between 1-6.
+ * @param {number} day - Must be between 1-7.
+ * @param {number} index - Indicates which item to select.
+ *
+ * @returns {EditItemAtResult}
+ */
+ async editItemOccurrencesAt(win, week, day, index) {
+ let item = await this.waitForItemAt(win, week, day, index);
+ return CalendarTestUtils.editItemOccurrences(win, item);
+ }
+}
+
+/**
+ * Non-mozmill calendar helper utility.
+ */
+const CalendarTestUtils = {
+ /**
+ * Helper methods for item editing.
+ */
+ items: {
+ cancelItemDialog,
+ saveAndCloseItemDialog,
+ setData,
+ },
+
+ /**
+ * Helpers specific to the day view.
+ */
+ dayView: new CalendarDayViewTestUtils(),
+
+ /**
+ * Helpers specific to the week view.
+ */
+ weekView: new CalendarWeekViewTestUtils(),
+
+ /**
+ * Helpers specific to the multiweek view.
+ */
+ multiweekView: new CalendarMonthViewTestUtils("#multiweek-view"),
+
+ /**
+ * Helpers specific to the month view.
+ */
+ monthView: new CalendarMonthViewTestUtils("#month-view"),
+
+ /**
+ * Dedent the template string tagged with this function to make indented data
+ * easier to read. Usage:
+ *
+ * let data = dedent`
+ * This is indented data it will be unindented so that the first line has
+ * no leading spaces and the second is indented by two spaces.
+ * `;
+ *
+ * @param strings The string fragments from the template string
+ * @param ...values The interpolated values
+ * @returns The interpolated, dedented string
+ */
+ dedent(strings, ...values) {
+ let parts = [];
+ // Perform variable interpolation
+ let minIndent = Infinity;
+ for (let [i, string] of strings.entries()) {
+ let innerparts = string.split("\n");
+ if (i == 0) {
+ innerparts.shift();
+ }
+ if (i == strings.length - 1) {
+ innerparts.pop();
+ }
+ for (let [j, ip] of innerparts.entries()) {
+ let match = ip.match(/^(\s*)\S*/);
+ if (j != 0) {
+ minIndent = Math.min(minIndent, match[1].length);
+ }
+ }
+ parts.push(innerparts);
+ }
+
+ return parts
+ .map((part, i) => {
+ return (
+ part
+ .map((line, j) => {
+ return j == 0 && i > 0 ? line : line.substr(minIndent);
+ })
+ .join("\n") + (i < values.length ? values[i] : "")
+ );
+ })
+ .join("");
+ },
+
+ /**
+ * Creates and registers a new calendar with the calendar manager. The
+ * created calendar will be set as the default calendar.
+ *
+ * @param {string} - name
+ * @param {string} - type
+ *
+ * @returns {calICalendar}
+ */
+ createCalendar(name = "Test", type = "storage") {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(`moz-${type}-calendar://`));
+ calendar.name = name;
+ calendar.setProperty("calendar-main-default", true);
+ cal.manager.registerCalendar(calendar);
+ return calendar;
+ },
+
+ /**
+ * Convenience method for removing a calendar using its proxy.
+ *
+ * @param {calICalendar} calendar - A calendar to remove.
+ */
+ removeCalendar(calendar) {
+ cal.manager.unregisterCalendar(calendar);
+ },
+
+ /**
+ * Ensures the calendar tab is open
+ *
+ * @param {Window} win
+ */
+ async openCalendarTab(win) {
+ let tabmail = win.document.getElementById("tabmail");
+ let calendarMode = tabmail.tabModes.calendar;
+
+ if (calendarMode.tabs.length == 1) {
+ tabmail.selectedTab = calendarMode.tabs[0];
+ } else {
+ let calendarTabButton = win.document.getElementById("calendarButton");
+ EventUtils.synthesizeMouseAtCenter(calendarTabButton, { clickCount: 1 }, win);
+ }
+
+ Assert.equal(calendarMode.tabs.length, 1, "calendar tab is open");
+ Assert.equal(tabmail.selectedTab, calendarMode.tabs[0], "calendar tab is selected");
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+
+ /**
+ * Make sure the current view has finished loading.
+ *
+ * @param {Window} win
+ */
+ async ensureViewLoaded(win) {
+ await win.currentView().ready;
+ },
+
+ /**
+ * Ensures the calendar view is in the specified mode.
+ *
+ * @param {Window} win
+ * @param {string} viewName
+ */
+ async setCalendarView(win, viewName) {
+ await CalendarTestUtils.openCalendarTab(win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+
+ let viewTabButton = win.document.querySelector(
+ `.calview-toggle-item[aria-controls="${viewName}-view"]`
+ );
+ EventUtils.synthesizeMouseAtCenter(viewTabButton, { clickCount: 1 }, win);
+ Assert.equal(win.currentView().id, `${viewName}-view`);
+
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Step forward in the calendar view.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} n - Number of times to move the view forward.
+ */
+ async calendarViewForward(win, n) {
+ let viewForwardButton = win.document.getElementById("nextViewButton");
+ for (let i = 0; i < n; i++) {
+ await clickAndWait(win, viewForwardButton);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ }
+ },
+
+ /**
+ * Step backward in the calendar view.
+ *
+ * @param {Window} win - The window the calendar is displayed in.
+ * @param {number} n - Number of times to move the view backward.
+ */
+ async calendarViewBackward(win, n) {
+ let viewBackwardButton = win.document.getElementById("previousViewButton");
+ for (let i = 0; i < n; i++) {
+ await clickAndWait(win, viewBackwardButton);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ }
+ },
+
+ /**
+ * Ensures the calendar tab is not open.
+ *
+ * @param {Window} win
+ */
+ async closeCalendarTab(win) {
+ let tabmail = win.document.getElementById("tabmail");
+ let calendarMode = tabmail.tabModes.calendar;
+
+ if (calendarMode.tabs.length == 1) {
+ tabmail.closeTab(calendarMode.tabs[0]);
+ }
+
+ Assert.equal(calendarMode.tabs.length, 0, "calendar tab is not open");
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+
+ /**
+ * Opens the event dialog for viewing by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Promise<Window>}
+ */
+ async viewItem(win, item) {
+ if (Services.focus.activeWindow != win) {
+ await BrowserTestUtils.waitForEvent(win, "focus");
+ }
+
+ let promise = this.waitForEventDialog("view");
+ EventUtils.synthesizeMouseAtCenter(item, { clickCount: 2 }, win);
+ return promise;
+ },
+
+ async _editNewItem(win, target, type) {
+ let dialogPromise = CalendarTestUtils.waitForEventDialog("edit");
+
+ if (target) {
+ this.scrollViewToTarget(target, true);
+ EventUtils.synthesizeMouse(target, 1, 1, { clickCount: 2 }, win);
+ } else {
+ let buttonId = `sidePanelNew${type[0].toUpperCase()}${type.slice(1).toLowerCase()}`;
+ EventUtils.synthesizeMouseAtCenter(win.document.getElementById(buttonId), {}, win);
+ }
+
+ let dialogWindow = await dialogPromise;
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ await new Promise(resolve => iframe.contentWindow.setTimeout(resolve));
+ Assert.report(false, undefined, undefined, `New ${type} dialog opened`);
+ return {
+ dialogWindow,
+ dialogDocument: dialogWindow.document,
+ iframeWindow: iframe.contentWindow,
+ iframeDocument: iframe.contentDocument,
+ };
+ },
+
+ /**
+ * Opens the dialog for editing a new event. An optional day/week view
+ * hour box or multiweek/month view calendar-month-day-box can be specified
+ * to simulate creation of the event at that target.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {XULElement?} target - The <spacer> or <calendar-month-day-box>
+ * to click on, if not specified, the new event
+ * button is used.
+ */
+ async editNewEvent(win, target) {
+ return this._editNewItem(win, target, "event");
+ },
+
+ /**
+ * Opens the dialog for editing a new task.
+ *
+ * @param {Promise<Window>} win - The window containing the task tree.
+ */
+ async editNewTask(win) {
+ return this._editNewItem(win, null, "task");
+ },
+
+ async _editItem(win, item, selector) {
+ let summaryWin = await this.viewItem(win, item);
+ let promise = this.waitForEventDialog("edit");
+ let button = summaryWin.document.querySelector(selector);
+ button.click();
+
+ let dialogWindow = await promise;
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ return {
+ dialogWindow,
+ dialogDocument: dialogWindow.document,
+ iframeWindow: iframe.contentWindow,
+ iframeDocument: iframe.contentDocument,
+ };
+ },
+
+ /**
+ * Opens the event dialog for editing by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Promise<EditItemAtResult>}
+ */
+ async editItem(win, item) {
+ return this._editItem(win, item, "#calendar-summary-dialog-edit-button");
+ },
+
+ /**
+ * Opens the event dialog for editing a single occurrence of a repeating event
+ * by clicking on the provided event item.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Window}
+ */
+ async editItemOccurrence(win, item) {
+ return this._editItem(win, item, "#edit-button-context-menu-this-occurrence");
+ },
+
+ /**
+ * Opens the event dialog for editing all occurrences of a repeating event
+ * by clicking on the provided event box.
+ *
+ * @param {Window} win - The window containing the calendar.
+ * @param {MozCalendarEditableItem} item - An event box item that can be
+ * clicked on to open the dialog.
+ *
+ * @returns {Window}
+ */
+ async editItemOccurrences(win, item) {
+ return this._editItem(win, item, "#edit-button-context-menu-all-occurrences");
+ },
+
+ /**
+ * This produces a Promise for waiting on an event dialog to open.
+ * The mode parameter can be specified to indicate which of the dialogs to
+ * wait for.
+ *
+ * @param {string} [mode="view"] Determines which dialog we are waiting on,
+ * can be "view" for the summary or "edit" for the editing one.
+ *
+ * @returns {Promise<Window>}
+ */
+ waitForEventDialog(mode = "view") {
+ let uri =
+ mode === "edit"
+ ? "chrome://calendar/content/calendar-event-dialog.xhtml"
+ : "chrome://calendar/content/calendar-summary-dialog.xhtml";
+
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ if (win.document.documentURI != uri) {
+ return false;
+ }
+
+ Assert.report(false, undefined, undefined, "Event dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == win,
+ "event dialog active"
+ );
+
+ if (mode === "edit") {
+ let iframe = win.document.getElementById("calendar-item-panel-iframe");
+ await TestUtils.waitForCondition(
+ () => iframe.contentWindow?.onLoad?.hasLoaded,
+ "waiting for iframe to be loaded"
+ );
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == iframe.contentWindow,
+ "waiting for iframe to be focused"
+ );
+ }
+ return true;
+ });
+ },
+
+ /**
+ * Go to a specific date using the minimonth.
+ *
+ * @param {Window} win - Main window
+ * @param {number} year - Four-digit year
+ * @param {number} month - 1-based index of a month
+ * @param {number} day - 1-based index of a day
+ */
+ async goToDate(win, year, month, day) {
+ let miniMonth = win.document.getElementById("calMinimonth");
+
+ let activeYear = miniMonth.querySelector(".minimonth-year-name").getAttribute("value");
+
+ let activeMonth = miniMonth.querySelector(".minimonth-month-name").getAttribute("monthIndex");
+
+ async function doScroll(name, difference, sleepTime) {
+ if (difference === 0) {
+ return;
+ }
+ let query = `.${name}s-${difference > 0 ? "back" : "forward"}-button`;
+ let scrollArrow = await TestUtils.waitForCondition(
+ () => miniMonth.querySelector(query),
+ `Query for scroll: ${query}`
+ );
+
+ for (let i = 0; i < Math.abs(difference); i++) {
+ EventUtils.synthesizeMouseAtCenter(scrollArrow, {}, win);
+ await new Promise(resolve => win.setTimeout(resolve, sleepTime));
+ }
+ }
+
+ await doScroll("year", activeYear - year, 10);
+ await doScroll("month", activeMonth - (month - 1), 25);
+
+ function getMiniMonthDay(week, day) {
+ return miniMonth.querySelector(
+ `.minimonth-cal-box > tr.minimonth-row-body:nth-of-type(${week + 1}) > ` +
+ `td.minimonth-day:nth-of-type(${day})`
+ );
+ }
+
+ let positionOfFirst = 7 - getMiniMonthDay(1, 7).textContent;
+ let weekDay = ((positionOfFirst + day - 1) % 7) + 1;
+ let week = Math.floor((positionOfFirst + day - 1) / 7) + 1;
+
+ // Pick day.
+ EventUtils.synthesizeMouseAtCenter(getMiniMonthDay(week, weekDay), {}, win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Go to today.
+ *
+ * @param {Window} win - Main window
+ */
+ async goToToday(win) {
+ EventUtils.synthesizeMouseAtCenter(this.getNavBarTodayButton(win), {}, win);
+ await CalendarTestUtils.ensureViewLoaded(win);
+ },
+
+ /**
+ * Assert whether the given event box's edges are visually draggable (and
+ * hence, editable) at its edges or not.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event box to test.
+ * @param {boolean} startDraggable - Whether we expect the start edge to be
+ * draggable.
+ * @param {boolean} endDraggable - Whether we expect the end edge to be
+ * draggable.
+ * @param {string} message - A message for assertions.
+ */
+ async assertEventBoxDraggable(eventBox, startDraggable, endDraggable, message) {
+ this.scrollViewToTarget(eventBox, true);
+ // Hover to see if the drag gripbars appear.
+ let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter");
+ // Hover over start.
+ EventUtils.synthesizeMouse(eventBox, 8, 8, { type: "mouseover" }, eventBox.ownerGlobal);
+ await enterPromise;
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.startGripbar),
+ startDraggable,
+ `Start gripbar should be ${startDraggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.endGripbar),
+ endDraggable,
+ `End gripbar should be ${endDraggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ },
+
+ /**
+ * Scroll the calendar view to show the given target.
+ *
+ * @param {Element} target - The target to scroll to. A descendent of a
+ * calendar view.
+ * @param {boolean} alignStart - Whether to scroll the inline and block start
+ * edges of the target into view, else scrolls the end edges into view.
+ */
+ scrollViewToTarget(target, alignStart) {
+ let multidayView = target.closest("calendar-day-view, calendar-week-view");
+ if (multidayView) {
+ // Multiday view has sticky headers, so scrollIntoView doesn't actually
+ // scroll far enough.
+ let scrollRect = multidayView.getScrollAreaRect();
+ let targetRect = target.getBoundingClientRect();
+ // We want to move the view by the difference between the starting/ending
+ // edge of the view and the starting/ending edge of the target.
+ let yDiff = alignStart
+ ? targetRect.top - scrollRect.top
+ : targetRect.bottom - scrollRect.bottom;
+ // In left-to-right, starting edge is the left edge. Otherwise, it is the
+ // right edge.
+ let xDiff =
+ alignStart == (target.ownerDocument.dir == "ltr")
+ ? targetRect.left - scrollRect.left
+ : targetRect.right - scrollRect.right;
+ multidayView.grid.scrollBy(xDiff, yDiff);
+ } else {
+ target.scrollIntoView(alignStart);
+ }
+ },
+
+ /**
+ * Save the current calendar views' UI states to be restored later.
+ *
+ * This is used with restoreCalendarViewsState to reset the view back to its
+ * initial loaded state after a test, so that later tests in the same group
+ * will receive the calendar view as if it was first opened after launching.
+ *
+ * @param {Window} win - The window that contains the calendar views.
+ *
+ * @returns {object} - An opaque object with data to pass to
+ * restoreCalendarViewsState.
+ */
+ saveCalendarViewsState(win) {
+ return {
+ multidayViewsData: ["day", "week"].map(viewName => {
+ // Save the scroll state since test utilities may change the scroll
+ // position, and this is currently not reset on re-opening the tab.
+ let view = win.document.getElementById(`${viewName}-view`);
+ return { view, viewName, scrollMinute: view.scrollMinute };
+ }),
+ };
+ },
+
+ /**
+ * Clean up the calendar views after a test by restoring their UI to the saved
+ * state, and close the calendar tab.
+ *
+ * @param {Window} win - The window that contains the calendar views.
+ * @param {object} data - The data returned by saveCalendarViewsState.
+ */
+ async restoreCalendarViewsState(win, data) {
+ for (let { view, viewName, scrollMinute } of data.multidayViewsData) {
+ await this.setCalendarView(win, viewName);
+ // The scrollMinute is rounded to the nearest integer.
+ // As is the scroll pixels.
+ // When we scrollToMinute, the scroll position is rounded to the nearest
+ // integer, as is the subsequent scroll minute. So calling
+ // scrollToMinute(min)
+ // will set
+ // scrollMinute = round(round(min * P) / P)
+ // where P is the pixelsPerMinute of the view. Thus
+ // scrollMinute = min +- round(0.5 / P)
+ let roundingError = Math.round(0.5 / view.pixelsPerMinute);
+ view.scrollToMinute(scrollMinute);
+ await TestUtils.waitForCondition(
+ () => Math.abs(view.scrollMinute - scrollMinute) <= roundingError,
+ "Waiting for scroll minute to restore"
+ );
+ }
+ await CalendarTestUtils.closeCalendarTab(win);
+ },
+
+ /**
+ * Get the Today button from the navigation bar.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLElement} - The today button.
+ */
+ getNavBarTodayButton(win) {
+ return win.document.getElementById("todayViewButton");
+ },
+
+ /**
+ * Get the label element containing a human-readable description of the
+ * displayed interval.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLLabelElement} - The interval description label.
+ */
+ getNavBarIntervalDescription(win) {
+ return win.document.getElementById("intervalDescription");
+ },
+
+ /**
+ * Get the label element containing an indication of which week or weeks are
+ * displayed.
+ *
+ * @param {Window} win - The window which contains the calendar.
+ *
+ * @returns {HTMLLabelElement} - The calendar week label.
+ */
+ getNavBarCalendarWeekBox(win) {
+ return win.document.getElementById("calendarWeek");
+ },
+};
diff --git a/comm/calendar/test/CalendarUtils.jsm b/comm/calendar/test/CalendarUtils.jsm
new file mode 100644
index 0000000000..708a65ad4e
--- /dev/null
+++ b/comm/calendar/test/CalendarUtils.jsm
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "SHORT_SLEEP",
+ "MID_SLEEP",
+ "TIMEOUT_MODAL_DIALOG",
+ "handleDeleteOccurrencePrompt",
+ "execEventDialogCallback",
+ "checkMonthAlarmIcon",
+ "closeAllEventDialogs",
+];
+
+var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var EventUtils = ChromeUtils.import("resource://testing-common/mozmill/EventUtils.jsm");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "CalendarTestUtils",
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var SHORT_SLEEP = 100;
+var MID_SLEEP = 500;
+var TIMEOUT_MODAL_DIALOG = 30000;
+var EVENT_DIALOG_NAME = "Calendar:EventDialog";
+
+/**
+ * Delete one or all occurrences using the prompt.
+ *
+ * @param {Window} window - Main window.
+ * @param {Element} element - Element which will open the dialog.
+ * @param {boolean} selectParent - true if all occurrences should be deleted.
+ */
+async function handleDeleteOccurrencePrompt(window, element, selectParent) {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ callback(dialogWindow) {
+ let buttonId;
+ if (selectParent) {
+ buttonId = "accept-parent-button";
+ } else {
+ buttonId = "accept-occurrence-button";
+ }
+ let acceptButton = dialogWindow.document.getElementById(buttonId);
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, dialogWindow);
+ },
+ }
+ );
+
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dialogPromise;
+}
+
+async function execEventDialogCallback(callback) {
+ let eventWindow = Services.wm.getMostRecentWindow(EVENT_DIALOG_NAME);
+
+ if (!eventWindow) {
+ eventWindow = await lazy.CalendarTestUtils.waitForEventDialog("edit");
+ }
+
+ let iframe = eventWindow.document.getElementById("calendar-item-panel-iframe");
+ await TestUtils.waitForCondition(() => iframe.contentWindow.onLoad?.hasLoaded);
+
+ await callback(eventWindow, iframe.contentWindow);
+}
+
+/**
+ * Checks if Alarm-Icon is shown on a given Event-Box.
+ *
+ * @param {Window} window - Main window.
+ * @param {number} week - Week to check between 1-6.
+ * @param {number} day - Day to check between 1-7.
+ */
+function checkMonthAlarmIcon(window, week, day) {
+ let dayBox = lazy.CalendarTestUtils.monthView.getItemAt(window, week, day, 1);
+ Assert.ok(dayBox.querySelector(".alarm-icons-box > .reminder-icon"));
+}
diff --git a/comm/calendar/test/ICSServer.jsm b/comm/calendar/test/ICSServer.jsm
new file mode 100644
index 0000000000..c0f120ec2a
--- /dev/null
+++ b/comm/calendar/test/ICSServer.jsm
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = ["ICSServer"];
+
+const { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+const { CommonUtils } = ChromeUtils.importESModule("resource://services-common/utils.sys.mjs");
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var ICSServer = {
+ server: null,
+ isOpen: false,
+
+ ics: "",
+ etag: "",
+ open(username, password) {
+ this.server = new HttpServer();
+ this.server.start(-1);
+ this.isOpen = true;
+
+ this.username = username;
+ this.password = password;
+ this.server.registerPathHandler("/ping", this.ping);
+ this.server.registerPathHandler(this.path, this.handleICS.bind(this));
+
+ this.reset();
+ },
+
+ reset() {
+ this.ics = "";
+ this.etag = "";
+ },
+
+ close() {
+ if (!this.isOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve =>
+ this.server.stop({
+ onStopped: () => {
+ this.isOpen = false;
+ resolve();
+ },
+ })
+ );
+ },
+
+ get origin() {
+ return `http://localhost:${this.server.identity.primaryPort}`;
+ },
+
+ get path() {
+ return "/test.ics";
+ },
+
+ get url() {
+ return `${this.origin}${this.path}`;
+ },
+
+ get altPath() {
+ return "/addressbooks/me/default/";
+ },
+
+ get altURL() {
+ return `${this.origin}${this.altPath}`;
+ },
+
+ checkAuth(request, response) {
+ if (!this.username || !this.password) {
+ return true;
+ }
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let value = request.getHeader("Authorization");
+ if (!value.startsWith("Basic ")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ let [username, password] = atob(value.substring(6)).split(":");
+ if (username != this.username || password != this.password) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return false;
+ }
+
+ return true;
+ },
+
+ ping(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("pong");
+ },
+
+ handleICS(request, response) {
+ if (!this.checkAuth(request, response)) {
+ return;
+ }
+
+ switch (request.method) {
+ case "HEAD":
+ this.headICS(request, response);
+ return;
+ case "GET":
+ this.getICS(request, response);
+ return;
+ case "PUT":
+ this.putICS(request, response);
+ return;
+ }
+
+ Assert.report(true, undefined, undefined, "Should not have reached here");
+ response.setStatusLine("1.1", 405, "Method Not Allowed");
+ response.setHeader("Content-Type", "text/plain");
+ response.write(`Method not allowed: ${request.method}`);
+ },
+
+ headICS(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/calendar");
+ response.setHeader("ETag", this.etag);
+ },
+
+ getICS(request, response) {
+ this.headICS(request, response);
+ response.write(this.ics);
+ },
+
+ async putICS(request, response) {
+ response.processAsync();
+
+ await this.putICSInternal(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ response.setStatusLine("1.1", 204, "No Content");
+ response.setHeader("ETag", this.etag);
+
+ response.finish();
+ },
+
+ async putICSInternal(ics) {
+ this.ics = ics;
+
+ let hash = await crypto.subtle.digest("sha-1", new TextEncoder().encode(this.ics));
+ this.etag = Array.from(new Uint8Array(hash), c => c.toString(16).padStart(2, "0")).join("");
+ },
+};
diff --git a/comm/calendar/test/ItemEditingHelpers.jsm b/comm/calendar/test/ItemEditingHelpers.jsm
new file mode 100644
index 0000000000..812f5008cb
--- /dev/null
+++ b/comm/calendar/test/ItemEditingHelpers.jsm
@@ -0,0 +1,681 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXPORTED_SYMBOLS = [
+ "cancelItemDialog",
+ "formatDate",
+ "formatTime",
+ "menulistSelect",
+ "saveAndCloseItemDialog",
+ "setData",
+];
+
+var { Assert } = ChromeUtils.importESModule("resource://testing-common/Assert.sys.mjs");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+var { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+var { sendString, synthesizeKey, synthesizeMouseAtCenter } = ChromeUtils.import(
+ "resource://testing-common/mozmill/EventUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalDateTime } = ChromeUtils.import("resource:///modules/CalDateTime.jsm");
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+function sleep(window, time = 0) {
+ return new Promise(resolve => window.setTimeout(resolve, time));
+}
+
+var dateFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "short" });
+var dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeZone: "UTC",
+});
+var timeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "short",
+ timeZone: "UTC",
+});
+
+/**
+ * Formats a date for input in a datepicker. Don't use cal.dtz.formatter methods
+ * for this as they use the application locale but datepicker uses the OS locale.
+ *
+ * @param {calIDateTime} date
+ * @returns {string}
+ */
+function formatDate(date) {
+ if (date.isDate) {
+ return dateFormatter.format(cal.dtz.dateTimeToJsDate(date));
+ }
+
+ return dateTimeFormatter.format(cal.dtz.dateTimeToJsDate(date));
+}
+
+/**
+ * Formats a time for input in a timepicker. Don't use cal.dtz.formatter methods
+ * for this as they use the application locale but timepicker uses the OS locale.
+ *
+ * @param {calIDateTime} time
+ * @returns {string}
+ */
+function formatTime(time) {
+ return timeFormatter.format(cal.dtz.dateTimeToJsDate(time));
+}
+
+/**
+ * @callback dialogCallback
+ * @param {Window} - The calendar-event-dialog-recurrence.xhtml dialog.
+ */
+
+/**
+ * Helper function to enter event/task dialog data.
+ *
+ * @param {Window} dialogWindow - Item dialog outer window.
+ * @param {Window} iframeWindow - Item dialog inner iframe.
+ * @param {object} data
+ * @param {string} data.title - Item title.
+ * @param {string} data.location - Item location.
+ * @param {string} data.description - Item description.
+ * @param {string[]} data.categories - Category names to set - leave empty to clear.
+ * @param {string} data.calendar - ID of the calendar the item should be in.
+ * @param {boolean} data.allday
+ * @param {calIDateTime} data.startdate
+ * @param {calIDateTime} data.starttime
+ * @param {calIDateTime} data.enddate
+ * @param {calIDateTime} data.endtime
+ * @param {boolean} data.timezonedisplay - false for hidden, true for shown.
+ * @param {string} data.timezone - String identifying the timezone.
+ * @param {string|dialogCallback} data.repeat - Recurrence value, one of
+ * none/daily/weekly/every.weekday/bi.weekly/monthly/yearly or a callback function to set a
+ * custom value.
+ * @param {calIDateTime} data.repeatuntil
+ * @param {string} data.reminder -
+ * none/0minutes/5minutes/15minutes/30minutes/1hour/2hours/12hours/1day/2days/1week
+ * (Custom is not supported.)
+ * @param {string} data.priority - none/low/normal/high
+ * @param {string} data.privacy - public/confidential/private
+ * @param {string} data.status - none/tentative/confirmed/canceled for events
+ * none/needs-action/in-process/completed/cancelled for tasks
+ * @param {calIDateTime} data.completed - Completion date (tasks only)
+ * @param {string} data.percent - Percentage complete (tasks only)
+ * @param {string} data.freebusy - free/busy
+ * @param {string} data.attachment.add - URL to add
+ * @param {string} data.attachment.remove - Label of url to remove. (without http://)
+ * @param {string} data.attendees.add - Email of attendees to add, comma separated.
+ * @param {string} data.attendees.remove - Email of attendees to remove, comma separated.
+ */
+async function setData(dialogWindow, iframeWindow, data) {
+ function replaceText(input, text) {
+ synthesizeMouseAtCenter(input, {}, iframeWindow);
+ synthesizeKey("a", { accelKey: true }, iframeWindow);
+ sendString(text, iframeWindow);
+ }
+
+ let dialogDocument = dialogWindow.document;
+ let iframeDocument = iframeWindow.document;
+
+ let isEvent = iframeWindow.calendarItem.isEvent();
+ let startPicker = iframeDocument.getElementById(isEvent ? "event-starttime" : "todo-entrydate");
+ let endPicker = iframeDocument.getElementById(isEvent ? "event-endtime" : "todo-duedate");
+
+ let startdateInput = startPicker._datepicker._inputField;
+ let enddateInput = endPicker._datepicker._inputField;
+ let starttimeInput = startPicker._timepicker._inputField;
+ let endtimeInput = endPicker._timepicker._inputField;
+ let completeddateInput = iframeDocument.getElementById("completed-date-picker")._inputField;
+ let untilDateInput = iframeDocument.getElementById("repeat-until-datepicker")._inputField;
+
+ // Wait for input elements' values to be populated.
+ await sleep(iframeWindow, 500);
+
+ // title
+ if (data.title !== undefined) {
+ let titleInput = iframeDocument.getElementById("item-title");
+ replaceText(titleInput, data.title);
+ }
+
+ // location
+ if (data.location !== undefined) {
+ let locationInput = iframeDocument.getElementById("item-location");
+ replaceText(locationInput, data.location);
+ }
+
+ // categories
+ if (data.categories !== undefined) {
+ await setCategories(iframeWindow, data.categories);
+ await sleep(iframeWindow);
+ }
+
+ // calendar
+ if (data.calendar !== undefined) {
+ await menulistSelect(iframeDocument.getElementById("item-calendar"), data.calendar);
+ await sleep(iframeWindow);
+ }
+
+ // all-day
+ if (data.allday !== undefined && isEvent) {
+ let checkbox = iframeDocument.getElementById("event-all-day");
+ if (checkbox.checked != data.allday) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+
+ // timezonedisplay
+ if (data.timezonedisplay !== undefined) {
+ let menuitem = dialogDocument.getElementById("options-timezones-menuitem");
+ if (menuitem.getAttribute("checked") != data.timezonedisplay) {
+ synthesizeMouseAtCenter(menuitem, {}, iframeWindow);
+ }
+ }
+
+ // timezone
+ if (data.timezone !== undefined) {
+ await setTimezone(dialogWindow, iframeWindow, data.timezone);
+ }
+
+ // startdate
+ if (
+ data.startdate !== undefined &&
+ (data.startdate instanceof CalDateTime || data.startdate instanceof Ci.calIDateTime)
+ ) {
+ let startdate = formatDate(data.startdate);
+
+ if (!isEvent) {
+ let checkbox = iframeDocument.getElementById("todo-has-entrydate");
+ if (!checkbox.checked) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+ replaceText(startdateInput, startdate);
+ }
+
+ // starttime
+ if (
+ data.starttime !== undefined &&
+ (data.starttime instanceof CalDateTime || data.starttime instanceof Ci.calIDateTime)
+ ) {
+ let starttime = formatTime(data.starttime);
+ replaceText(starttimeInput, starttime);
+ await sleep(iframeWindow);
+ }
+
+ // enddate
+ if (
+ data.enddate !== undefined &&
+ (data.enddate instanceof CalDateTime || data.enddate instanceof Ci.calIDateTime)
+ ) {
+ let enddate = formatDate(data.enddate);
+ if (!isEvent) {
+ let checkbox = iframeDocument.getElementById("todo-has-duedate");
+ if (!checkbox.checked) {
+ synthesizeMouseAtCenter(checkbox, {}, iframeWindow);
+ }
+ }
+ replaceText(enddateInput, enddate);
+ }
+
+ // endtime
+ if (
+ data.endtime !== undefined &&
+ (data.endtime instanceof CalDateTime || data.endtime instanceof Ci.calIDateTime)
+ ) {
+ let endtime = formatTime(data.endtime);
+ replaceText(endtimeInput, endtime);
+ }
+
+ // recurrence
+ if (data.repeat !== undefined) {
+ if (typeof data.repeat == "function") {
+ let repeatWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(recurrenceWindow) {
+ Assert.report(false, undefined, undefined, "Recurrence dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == recurrenceWindow,
+ "recurrence dialog active"
+ );
+
+ await new Promise(resolve => recurrenceWindow.setTimeout(resolve, 500));
+ await data.repeat(recurrenceWindow);
+ },
+ }
+ );
+ await Promise.all([
+ menulistSelect(iframeDocument.getElementById("item-repeat"), "custom"),
+ repeatWindowPromise,
+ ]);
+ Assert.report(false, undefined, undefined, "Recurrence dialog closed");
+ } else {
+ await menulistSelect(iframeDocument.getElementById("item-repeat"), data.repeat);
+ }
+ }
+ if (
+ data.repeatuntil !== undefined &&
+ (data.repeatuntil instanceof CalDateTime || data.repeatuntil instanceof Ci.calIDateTime)
+ ) {
+ // Only fill in date, when the Datepicker is visible.
+ if (!iframeDocument.getElementById("repeat-untilDate").hidden) {
+ let untildate = formatDate(data.repeatuntil);
+ replaceText(untilDateInput, untildate);
+ }
+ }
+
+ // reminder
+ if (data.reminder !== undefined) {
+ await setReminderMenulist(iframeWindow, data.reminder);
+ }
+
+ // priority
+ if (data.priority !== undefined) {
+ dialogDocument.getElementById(`options-priority-${data.priority}-label`).click();
+ }
+
+ // privacy
+ if (data.privacy !== undefined) {
+ let button = dialogDocument.getElementById("button-privacy");
+ let shownPromise = BrowserTestUtils.waitForEvent(button, "popupshown");
+ synthesizeMouseAtCenter(button, {}, dialogWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(button, "popuphidden");
+ synthesizeMouseAtCenter(
+ dialogDocument.getElementById(`event-privacy-${data.privacy}-menuitem`),
+ {},
+ dialogWindow
+ );
+ await hiddenPromise;
+ await sleep(iframeWindow);
+ }
+
+ // status
+ if (data.status !== undefined) {
+ if (isEvent) {
+ dialogDocument.getElementById(`options-status-${data.status}-menuitem`).click();
+ } else {
+ await menulistSelect(iframeDocument.getElementById("todo-status"), data.status.toUpperCase());
+ }
+ }
+
+ let currentStatus = iframeDocument.getElementById("todo-status").value;
+
+ // completed on
+ if (
+ data.completed !== undefined &&
+ (data.completed instanceof CalDateTime || data.completed instanceof Ci.calIDateTime) &&
+ !isEvent
+ ) {
+ let completeddate = formatDate(data.completed);
+ if (currentStatus == "COMPLETED") {
+ replaceText(completeddateInput, completeddate);
+ }
+ }
+
+ // percent complete
+ if (
+ data.percent !== undefined &&
+ (currentStatus == "NEEDS-ACTION" ||
+ currentStatus == "IN-PROCESS" ||
+ currentStatus == "COMPLETED")
+ ) {
+ let percentCompleteInput = iframeDocument.getElementById("percent-complete-textbox");
+ replaceText(percentCompleteInput, data.percent);
+ }
+
+ // free/busy
+ if (data.freebusy !== undefined) {
+ dialogDocument.getElementById(`options-freebusy-${data.freebusy}-menuitem`).click();
+ }
+
+ // description
+ if (data.description !== undefined) {
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-description"),
+ {},
+ iframeWindow
+ );
+ let descField = iframeDocument.getElementById("item-description");
+ replaceText(descField, data.description);
+ }
+
+ // attachment
+ if (data.attachment !== undefined) {
+ if (data.attachment.add !== undefined) {
+ await handleAddingAttachment(dialogWindow, data.attachment.add);
+ }
+ if (data.attachment.remove !== undefined) {
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+ let attachmentBox = iframeDocument.getElementById("attachment-link");
+ let attachments = attachmentBox.children;
+ for (let attachment of attachments) {
+ if (attachment.tooltipText.includes(data.attachment.remove)) {
+ synthesizeMouseAtCenter(attachment, {}, iframeWindow);
+ synthesizeKey("VK_DELETE", {}, dialogWindow);
+ }
+ }
+ }
+ }
+
+ // attendees
+ if (data.attendees !== undefined) {
+ // Display attendees Tab.
+ synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attendees"),
+ {},
+ iframeWindow
+ );
+ // Make sure no notifications are sent, since handling this dialog is
+ // not working when deleting a parent of a recurring event.
+ let attendeeCheckbox = iframeDocument.getElementById("notify-attendees-checkbox");
+ if (!attendeeCheckbox.disabled && attendeeCheckbox.checked) {
+ synthesizeMouseAtCenter(attendeeCheckbox, {}, iframeWindow);
+ }
+
+ // add
+ if (data.attendees.add !== undefined) {
+ await addAttendees(dialogWindow, iframeWindow, data.attendees.add);
+ }
+ // delete
+ if (data.attendees.remove !== undefined) {
+ await deleteAttendees(iframeWindow, data.attendees.remove);
+ }
+ }
+
+ await sleep(iframeWindow);
+}
+
+/**
+ * Closes an event dialog window, saving the event.
+ *
+ * @param {Window} dialogWindow
+ */
+async function saveAndCloseItemDialog(dialogWindow) {
+ let dialogClosing = BrowserTestUtils.domWindowClosed(dialogWindow);
+ synthesizeMouseAtCenter(
+ dialogWindow.document.getElementById("button-saveandclose"),
+ {},
+ dialogWindow
+ );
+ await dialogClosing;
+ Assert.report(false, undefined, undefined, "Item dialog closed");
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Closes an event dialog window, discarding any changes.
+ *
+ * @param {Window} dialogWindow
+ */
+function cancelItemDialog(dialogWindow) {
+ synthesizeKey("VK_ESCAPE", {}, dialogWindow);
+}
+
+/**
+ * Select an item in the reminder menulist.
+ * Custom reminders are not supported.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} id - Identifying string of menuitem id.
+ */
+async function setReminderMenulist(iframeWindow, id) {
+ let iframeDocument = iframeWindow.document;
+ let menulist = iframeDocument.querySelector(".item-alarm");
+ let menuitem = iframeDocument.getElementById(`reminder-${id}-menuitem`);
+
+ Assert.ok(menulist, `<menulist id=${menulist.id}> exists`);
+ Assert.ok(menuitem, `<menuitem id=${id}> exists`);
+
+ menulist.focus();
+
+ synthesizeMouseAtCenter(menulist, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ synthesizeMouseAtCenter(menuitem, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ await sleep(iframeWindow);
+}
+
+/**
+ * Set the categories in the event-dialog menulist-panel.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string[]} categories - Category names to set - leave empty to clear.
+ */
+async function setCategories(iframeWindow, categories) {
+ let iframeDocument = iframeWindow.document;
+ let menulist = iframeDocument.getElementById("item-categories");
+ let menupopup = iframeDocument.getElementById("item-categories-popup");
+
+ synthesizeMouseAtCenter(menulist, {}, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+
+ // Iterate over categories and check if needed.
+ for (let item of menupopup.children) {
+ if (categories.includes(item.label)) {
+ item.setAttribute("checked", "true");
+ } else {
+ item.removeAttribute("checked");
+ }
+ }
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ menupopup.hidePopup();
+ await hiddenPromise;
+}
+
+/**
+ * Add an URL attachment.
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {string} url - URL to be added.
+ */
+async function handleAddingAttachment(dialogWindow, url) {
+ let dialogDocument = dialogWindow.document;
+ let attachButton = dialogDocument.querySelector("#button-url");
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(undefined, undefined, {
+ async callback(attachmentWindow) {
+ Assert.report(false, undefined, undefined, "Attachment dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == attachmentWindow,
+ "attachment dialog active"
+ );
+
+ let attachmentDocument = attachmentWindow.document;
+ attachmentDocument.getElementById("loginTextbox").value = url;
+ attachmentDocument.querySelector("dialog").getButton("accept").click();
+ },
+ });
+ synthesizeMouseAtCenter(dialogDocument.querySelector("#button-attach-url"), {}, dialogWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Attachment dialog closed");
+ await sleep(dialogWindow);
+}
+
+/**
+ * Add attendees to the event.
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} attendeesString - Comma separated list of email-addresses to add.
+ */
+async function addAttendees(dialogWindow, iframeWindow, attendeesString) {
+ let dialogDocument = dialogWindow.document;
+
+ let attendees = attendeesString.split(",");
+ for (let attendee of attendees) {
+ let calAttendee = iframeWindow.attendees.find(aAtt => aAtt.id == `mailto:${attendee}`);
+ // Only add if not already present.
+ if (!calAttendee) {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ {
+ async callback(attendeesWindow) {
+ Assert.report(false, undefined, undefined, "Attendees dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == attendeesWindow,
+ "attendees dialog active"
+ );
+
+ let attendeesDocument = attendeesWindow.document;
+ Assert.equal(attendeesDocument.activeElement.localName, "input");
+ Assert.equal(
+ attendeesDocument.activeElement.value,
+ "",
+ "active input value should be empty"
+ );
+ sendString(attendee, attendeesWindow);
+ Assert.report(false, undefined, undefined, `Sent attendee ${attendee}`);
+ // Windows needs the focus() call here.
+ attendeesDocument.querySelector("dialog").getButton("accept").focus();
+ synthesizeMouseAtCenter(
+ attendeesDocument.querySelector("dialog").getButton("accept"),
+ {},
+ attendeesWindow
+ );
+ },
+ }
+ );
+ synthesizeMouseAtCenter(dialogDocument.getElementById("button-attendees"), {}, dialogWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Attendees dialog closed");
+ await sleep(iframeWindow);
+ }
+ }
+}
+
+/**
+ * Delete attendees from the event.
+ *
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} attendeesString - Comma separated list of email-addresses to delete.
+ */
+async function deleteAttendees(iframeWindow, attendeesString) {
+ let iframeDocument = iframeWindow.document;
+ let menupopup = iframeDocument.getElementById("attendee-popup");
+
+ // Now delete the attendees.
+ let attendees = attendeesString.split(",");
+ for (let attendee of attendees) {
+ let attendeeToDelete = iframeDocument.querySelector(
+ `.attendee-list [attendeeid="mailto:${attendee}"]`
+ );
+ if (attendeeToDelete) {
+ attendeeToDelete.focus();
+ synthesizeMouseAtCenter(attendeeToDelete, { type: "contextmenu" }, iframeWindow);
+ await BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ menupopup.activateItem(
+ iframeDocument.getElementById("attendee-popup-removeattendee-menuitem")
+ );
+ await BrowserTestUtils.waitForEvent(menupopup, "popuphidden");
+ }
+ }
+ await sleep(iframeWindow);
+}
+
+/**
+ * Set the timezone for the item
+ *
+ * @param {Window} dialogWindow - The event dialog.
+ * @param {Window} iframeWindow - The event dialog iframe.
+ * @param {string} timezone - String identifying the timezone.
+ */
+async function setTimezone(dialogWindow, iframeWindow, timezone) {
+ let dialogDocument = dialogWindow.document;
+ let iframeDocument = iframeWindow.document;
+
+ let menuitem = dialogDocument.getElementById("options-timezones-menuitem");
+ let label = iframeDocument.getElementById("timezone-starttime");
+ let menupopup = iframeDocument.getElementById("timezone-popup");
+ let customMenuitem = iframeDocument.getElementById("timezone-custom-menuitem");
+
+ if (!BrowserTestUtils.is_visible(label)) {
+ menuitem.click();
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(label),
+ "Timezone label should become visible"
+ );
+ }
+
+ await TestUtils.waitForCondition(() => !label.disabled, "Tiemzone label should become enabled");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-event-dialog-timezone.xhtml",
+ {
+ async callback(timezoneWindow) {
+ Assert.report(false, undefined, undefined, "Timezone dialog opened");
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == timezoneWindow,
+ "timezone dialog active"
+ );
+
+ let timezoneDocument = timezoneWindow.document;
+ let timezoneMenulist = timezoneDocument.getElementById("timezone-menulist");
+ let timezoneMenuitem = timezoneMenulist.querySelector(`[value="${timezone}"]`);
+
+ let popupshown = BrowserTestUtils.waitForEvent(timezoneMenulist, "popupshown");
+ synthesizeMouseAtCenter(timezoneMenulist, {}, timezoneWindow);
+ await popupshown;
+
+ timezoneMenuitem.scrollIntoView();
+
+ let popuphidden = BrowserTestUtils.waitForEvent(timezoneMenulist, "popuphidden");
+ synthesizeMouseAtCenter(timezoneMenuitem, {}, timezoneWindow);
+ await popuphidden;
+
+ synthesizeMouseAtCenter(
+ timezoneDocument.querySelector("dialog").getButton("accept"),
+ {},
+ timezoneWindow
+ );
+ },
+ }
+ );
+
+ synthesizeMouseAtCenter(label, {}, iframeWindow);
+ await shownPromise;
+
+ synthesizeMouseAtCenter(customMenuitem, {}, iframeWindow);
+ await dialogPromise;
+ Assert.report(false, undefined, undefined, "Timezone dialog closed");
+
+ await new Promise(resolve => iframeWindow.setTimeout(resolve, 500));
+}
+
+/**
+ * Selects an item from a menulist.
+ *
+ * @param {Element} menulist
+ * @param {string} value
+ */
+async function menulistSelect(menulist, value) {
+ let win = menulist.ownerGlobal;
+ Assert.ok(menulist, `<menulist id=${menulist.id}> exists`);
+ let menuitem = menulist.querySelector(`menupopup > menuitem[value='${value}']`);
+ Assert.ok(menuitem, `<menuitem value=${value}> exists`);
+
+ menulist.focus();
+
+ let shownPromise = BrowserTestUtils.waitForEvent(menulist, "popupshown");
+ synthesizeMouseAtCenter(menulist, {}, win);
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(menulist, "popuphidden");
+ synthesizeMouseAtCenter(menuitem, {}, win);
+ await hiddenPromise;
+
+ await new Promise(resolve => win.setTimeout(resolve));
+ Assert.equal(menulist.value, value);
+}
diff --git a/comm/calendar/test/browser/browser.ini b/comm/calendar/test/browser/browser.ini
new file mode 100644
index 0000000000..5eb780310b
--- /dev/null
+++ b/comm/calendar/test/browser/browser.ini
@@ -0,0 +1,39 @@
+[default]
+head = head.js
+prefs =
+ calendar.debug.log=true
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.oauth.loglevel=Debug
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+ signon.rememberSignons=true
+subsuite = thunderbird
+support-files = data/**
+
+[browser_basicFunctionality.js]
+[browser_calDAV_discovery.js]
+[browser_calDAV_oAuth.js]
+tags = oauth
+[browser_calendarList.js]
+[browser_calendarTelemetry.js]
+[browser_calendarUnifinder.js]
+[browser_dragEventItem.js]
+[browser_eventDisplay_dayView.js]
+[browser_eventDisplay_multiWeekView.js]
+[browser_eventDisplay_weekView.js]
+[browser_eventUndoRedo.js]
+[browser_import.js]
+[browser_localICS.js]
+[browser_taskDelete.js]
+[browser_taskUndoRedo.js]
+[browser_tabs.js]
+[browser_taskDisplay.js]
+[browser_todayPane.js]
+[browser_todayPane_dragAndDrop.js]
+[browser_todayPane_visibility.js]
diff --git a/comm/calendar/test/browser/browser_basicFunctionality.js b/comm/calendar/test/browser/browser_basicFunctionality.js
new file mode 100644
index 0000000000..c8f0fd68b7
--- /dev/null
+++ b/comm/calendar/test/browser/browser_basicFunctionality.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createCalendarUsingDialog */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+add_task(async function testBasicFunctionality() {
+ const calendarName = "Mochitest";
+
+ registerCleanupFunction(() => {
+ for (let calendar of cal.manager.getCalendars()) {
+ if (calendar.name == calendarName) {
+ cal.manager.removeCalendar(calendar);
+ }
+ }
+ Services.focus.focusedWindow = window;
+ });
+
+ Services.focus.focusedWindow = window;
+
+ // Create test calendar.
+ await createCalendarUsingDialog(calendarName);
+
+ // Check for minimonth, every month has a day 1.
+ Assert.ok(
+ document.querySelector("#calMinimonth .minimonth-cal-box td[aria-label='1']"),
+ "day 1 exists in the minimonth"
+ );
+
+ // Check for calendar list.
+ Assert.ok(document.querySelector("#calendar-list-pane"), "calendar list pane exists");
+ Assert.ok(document.querySelector("#calendar-list"), "calendar list exists");
+
+ // Check for event search.
+ Assert.ok(document.querySelector("#bottom-events-box"), "event search box exists");
+
+ // There should be search field.
+ Assert.ok(document.querySelector("#unifinder-search-field"), "unifinded search field exists");
+
+ // Make sure the week view is the default selected view.
+ Assert.ok(
+ document
+ .querySelector(`.calview-toggle-item[aria-selected="true"]`)
+ .getAttribute("aria-controls") == "week-view",
+ "week-view toggle is the current default"
+ );
+
+ let dayViewButton = document.querySelector("#calTabDay");
+ dayViewButton.click();
+ Assert.ok(dayViewButton.getAttribute("aria-selected"), "day view button is selected");
+ await CalendarTestUtils.ensureViewLoaded(window);
+
+ // Day view should have 09:00 box.
+ let someTime = cal.createDateTime();
+ someTime.resetTo(someTime.year, someTime.month, someTime.day, 9, 0, 0, someTime.timezone);
+ let label = cal.dtz.formatter.formatTime(someTime);
+ let labelEl = document.querySelectorAll("#day-view .multiday-timebar .multiday-hour-box")[9];
+ Assert.ok(labelEl, "9th hour box should exist");
+ Assert.equal(labelEl.textContent, label, "9th hour box should show the correct time");
+ Assert.ok(CalendarTestUtils.dayView.getHourBoxAt(window, 9), "09:00 box exists");
+
+ // Open tasks view.
+ document.querySelector("#tasksButton").click();
+
+ // Should be possible to filter today's tasks.
+ Assert.ok(document.querySelector("#opt_today_filter"), "show today radio button exists");
+
+ // Check for task add button.
+ Assert.ok(document.querySelector("#calendar-add-task-button"), "task add button exists");
+
+ // Check for filtered tasks list.
+ Assert.ok(
+ document.querySelector("#calendar-task-tree .calendar-task-treechildren"),
+ "filtered tasks list exists"
+ );
+});
diff --git a/comm/calendar/test/browser/browser_calDAV_discovery.js b/comm/calendar/test/browser/browser_calDAV_discovery.js
new file mode 100644
index 0000000000..217bf76f55
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calDAV_discovery.js
@@ -0,0 +1,241 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+async function openWizard(...args) {
+ await CalendarTestUtils.openCalendarTab(window);
+ let wizardPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ {
+ callback: wizardWindow => handleWizard(wizardWindow, ...args),
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.querySelector("#newCalendarSidebarButton"),
+ {},
+ window
+ );
+ return wizardPromise;
+}
+
+async function handleWizard(wizardWindow, { username, url, password, expectedCalendars }) {
+ let wizardDocument = wizardWindow.document;
+ let acceptButton = wizardDocument.querySelector("dialog").getButton("accept");
+ let cancelButton = wizardDocument.querySelector("dialog").getButton("cancel");
+
+ // Select calendar type.
+
+ EventUtils.synthesizeMouseAtCenter(
+ wizardDocument.querySelector(`radio[value="network"]`),
+ {},
+ wizardWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, wizardWindow);
+
+ // Network calendar settings.
+
+ Assert.ok(acceptButton.disabled);
+ Assert.equal(wizardDocument.activeElement.id, "network-username-input");
+ if (username) {
+ EventUtils.sendString(username, wizardWindow);
+ }
+
+ if (username?.includes("@")) {
+ Assert.equal(
+ wizardDocument.getElementById("network-location-input").placeholder,
+ username.replace(/^.*@/, "")
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", {}, wizardWindow);
+ Assert.equal(wizardDocument.activeElement.id, "network-location-input");
+ if (url) {
+ EventUtils.sendString(url, wizardWindow);
+ }
+
+ Assert.ok(!acceptButton.disabled);
+
+ let promptPromise = handlePasswordPrompt(password);
+ EventUtils.synthesizeKey("VK_RETURN", {}, wizardWindow);
+ await promptPromise;
+
+ // Select calendars.
+
+ let list = wizardDocument.getElementById("network-calendar-list");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(list),
+ "waiting for calendar list to appear",
+ 200,
+ 100
+ );
+
+ Assert.equal(list.childElementCount, expectedCalendars.length);
+ for (let i = 0; i < expectedCalendars.length; i++) {
+ let item = list.children[i];
+
+ Assert.equal(item.calendar.uri.spec, expectedCalendars[i].uri);
+ Assert.equal(
+ item.querySelector(".calendar-color").style.backgroundColor,
+ expectedCalendars[i].color
+ );
+ Assert.equal(item.querySelector(".calendar-name").value, expectedCalendars[i].name);
+
+ if (expectedCalendars[i].hasOwnProperty("readOnly")) {
+ Assert.equal(
+ item.calendar.readOnly,
+ expectedCalendars[i].readOnly,
+ `calendar read-only property is ${expectedCalendars[i].readOnly}`
+ );
+ }
+ }
+ EventUtils.synthesizeMouseAtCenter(cancelButton, {}, wizardWindow);
+}
+
+async function handlePasswordPrompt(password) {
+ return BrowserTestUtils.promiseAlertDialog(null, undefined, {
+ async callback(prompt) {
+ await new Promise(resolve => prompt.setTimeout(resolve));
+
+ prompt.document.getElementById("password1Textbox").value = password;
+
+ let checkbox = prompt.document.getElementById("checkbox");
+ Assert.greater(checkbox.getBoundingClientRect().width, 0);
+ Assert.ok(checkbox.checked);
+
+ prompt.document.querySelector("dialog").getButton("accept").click();
+ },
+ });
+}
+
+/**
+ * Test that we correctly use DNS discovery. This uses the mochitest server
+ * (files in the data directory) instead of CalDAVServer because the latter
+ * can't speak HTTPS, and we only do DNS discovery for HTTPS.
+ */
+add_task(async function testDNS() {
+ var _srv = DNS.srv;
+ var _txt = DNS.txt;
+ DNS.srv = function (name) {
+ Assert.equal(name, "_caldavs._tcp.dnstest.invalid");
+ return [{ prio: 0, weight: 0, host: "example.org", port: 443 }];
+ };
+ DNS.txt = function (name) {
+ Assert.equal(name, "_caldavs._tcp.dnstest.invalid");
+ return [{ data: "path=/browser/comm/calendar/test/browser/data/dns.sjs" }];
+ };
+
+ await openWizard({
+ username: "carol@dnstest.invalid",
+ password: "carol",
+ expectedCalendars: [
+ {
+ uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar.sjs",
+ name: "You found me!",
+ color: "rgb(0, 128, 0)",
+ },
+ {
+ uri: "https://example.org/browser/comm/calendar/test/browser/data/calendar2.sjs",
+ name: "RΓΆda dagar",
+ color: "rgb(255, 0, 0)",
+ },
+ ],
+ });
+
+ DNS.srv = _srv;
+ DNS.txt = _txt;
+});
+
+/**
+ * Test that the magic URL /.well-known/caldav works.
+ */
+add_task(async function testWellKnown() {
+ CalDAVServer.open("alice", "alice");
+
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ },
+ ],
+ });
+
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars with only the "read" "current-user-privilege-set" are
+ * flagged read-only.
+ */
+add_task(async function testCalendarWithOnlyReadPriv() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = "<d:privilege><d:read/></d:privilege>";
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: true,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars that return none of the expected values for "current-user-privilege-set"
+ * are flagged read-only.
+ */
+add_task(async function testCalendarWithoutPrivs() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = "";
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: true,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.open("alice", "alice");
+ CalDAVServer.privileges = null;
+ await openWizard({
+ username: "alice",
+ url: CalDAVServer.origin,
+ password: "alice",
+ expectedCalendars: [
+ {
+ uri: CalDAVServer.url,
+ name: "CalDAV Test",
+ color: "rgb(255, 128, 0)",
+ readOnly: false,
+ },
+ ],
+ });
+ CalDAVServer.close();
+});
diff --git a/comm/calendar/test/browser/browser_calDAV_oAuth.js b/comm/calendar/test/browser/browser_calDAV_oAuth.js
new file mode 100644
index 0000000000..4d9c733076
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calDAV_oAuth.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Creates calendars in various configurations (current and legacy) and performs
+// requests in each of them to prove that OAuth2 authentication is working as expected.
+
+var { CalDavCalendar } = ChromeUtils.import("resource:///modules/CalDavCalendar.jsm");
+var { CalDavGenericRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var LoginInfo = Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+// Ideal login info. This is what would be saved if you created a new calendar.
+const ORIGIN = "oauth://mochi.test";
+const SCOPE = "test_scope";
+const USERNAME = "bob@test.invalid";
+const VALID_TOKEN = "bobs_refresh_token";
+
+/**
+ * Set a string pref for the given calendar.
+ *
+ * @param {string} calendarId
+ * @param {string} key
+ * @param {string} value
+ */
+function setPref(calendarId, key, value) {
+ Services.prefs.setStringPref(`calendar.registry.${calendarId}.${key}`, value);
+}
+
+/**
+ * Clear any existing saved logins and add the given ones.
+ *
+ * @param {string[][]} - Zero or more arrays consisting of origin, realm, username, and password.
+ */
+function setLogins(...logins) {
+ Services.logins.removeAllLogins();
+ for (let [origin, realm, username, password] of logins) {
+ Services.logins.addLogin(new LoginInfo(origin, null, realm, username, password, "", ""));
+ }
+}
+
+/**
+ * Create a calendar with the given id, perform a request, and check that the correct
+ * authorisation header was used. If the user is required to re-authenticate with the provider,
+ * check that the new token is stored in the right place.
+ *
+ * @param {string} calendarId - ID of the new calendar
+ * @param {string} [newTokenUsername] - If given, re-authentication must happen and the new token
+ * stored with this user name.
+ */
+async function subtest(calendarId, newTokenUsername) {
+ let calendar = new CalDavCalendar();
+ calendar.id = calendarId;
+
+ let request = new CalDavGenericRequest(
+ calendar.wrappedJSObject.session,
+ calendar,
+ "GET",
+ Services.io.newURI(
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/auth_headers.sjs"
+ )
+ );
+ let response = await request.commit();
+ let headers = JSON.parse(response.text);
+
+ if (newTokenUsername) {
+ Assert.equal(headers.authorization, "Bearer new_access_token");
+
+ let logins = Services.logins
+ .findLogins(ORIGIN, null, SCOPE)
+ .filter(l => l.username == newTokenUsername);
+ Assert.equal(logins.length, 1);
+ Assert.equal(logins[0].username, newTokenUsername);
+ Assert.equal(logins[0].password, "new_refresh_token");
+ } else {
+ Assert.equal(headers.authorization, "Bearer bobs_access_token");
+ }
+
+ Services.logins.removeAllLogins();
+}
+
+// Test making a request when there is no matching token stored.
+
+/** No token stored, no username or session ID set. */
+add_task(function testCalendarOAuth_id_none() {
+ let calendarId = "testCalendarOAuth_id_none";
+ return subtest(calendarId, calendarId);
+});
+
+/** No token stored, session ID set. */
+add_task(function testCalendarOAuth_sessionId_none() {
+ let calendarId = "testCalendarOAuth_sessionId_none";
+ setPref(calendarId, "sessionId", "test_session");
+ return subtest(calendarId, "test_session");
+});
+
+/** No token stored, username set. */
+add_task(function testCalendarOAuth_username_none() {
+ let calendarId = "testCalendarOAuth_username_none";
+ setPref(calendarId, "username", USERNAME);
+ return subtest(calendarId, USERNAME);
+});
+
+// Test making a request when there IS a matching token, but the server rejects it.
+// Currently a new token is not requested on failure.
+
+/** Expired token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_expired() {
+ let calendarId = "testCalendarOAuth_id_expired";
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+ return subtest(calendarId, calendarId);
+}).skip(); // Broken.
+
+/** Expired token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_expired() {
+ let calendarId = "testCalendarOAuth_sessionId_expired";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", "expired_token"]);
+ return subtest(calendarId, "test_session");
+}).skip(); // Broken.
+
+/** Expired token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_expired() {
+ let calendarId = "testCalendarOAuth_username_expired";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, "expired_token"]);
+ return subtest(calendarId, USERNAME);
+}).skip(); // Broken.
+
+// Test making a request with a valid token, using Lightning's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuth_id_valid() {
+ let calendarId = "testCalendarOAuth_id_valid";
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuth_sessionId_valid() {
+ let calendarId = "testCalendarOAuth_sessionId_valid";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins(["oauth:test_session", "Google CalDAV v2", "test_session", VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuth_username_valid() {
+ let calendarId = "testCalendarOAuth_username_valid";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([`oauth:${calendarId}`, "Google CalDAV v2", calendarId, VALID_TOKEN]);
+ return subtest(calendarId, USERNAME);
+});
+
+// Test making a request with a valid token, using Thunderbird's client ID and secret.
+
+/** Valid token stored with calendar ID. */
+add_task(function testCalendarOAuthTB_id_valid() {
+ let calendarId = "testCalendarOAuthTB_id_valid";
+ setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with session ID. */
+add_task(function testCalendarOAuthTB_sessionId_valid() {
+ let calendarId = "testCalendarOAuthTB_sessionId_valid";
+ setPref(calendarId, "sessionId", "test_session");
+ setLogins([ORIGIN, SCOPE, "test_session", VALID_TOKEN]);
+ return subtest(calendarId);
+});
+
+/** Valid token stored with calendar ID, username set. */
+add_task(function testCalendarOAuthTB_username_valid() {
+ let calendarId = "testCalendarOAuthTB_username_valid";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([ORIGIN, SCOPE, calendarId, VALID_TOKEN]);
+ return subtest(calendarId, USERNAME);
+});
+
+/** Valid token stored with username, exact scope. */
+add_task(function testCalendarOAuthTB_username_validSingle() {
+ let calendarId = "testCalendarOAuthTB_username_validSingle";
+ setPref(calendarId, "username", USERNAME);
+ setLogins(
+ [ORIGIN, SCOPE, USERNAME, VALID_TOKEN],
+ [ORIGIN, "other_scope", USERNAME, "other_refresh_token"]
+ );
+ return subtest(calendarId);
+});
+
+/** Valid token stored with username, many scopes. */
+add_task(function testCalendarOAuthTB_username_validMultiple() {
+ let calendarId = "testCalendarOAuthTB_username_validMultiple";
+ setPref(calendarId, "username", USERNAME);
+ setLogins([ORIGIN, "scope test_scope other_scope", USERNAME, VALID_TOKEN]);
+ return subtest(calendarId);
+});
diff --git a/comm/calendar/test/browser/browser_calendarList.js b/comm/calendar/test/browser/browser_calendarList.js
new file mode 100644
index 0000000000..b85ef5a56e
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarList.js
@@ -0,0 +1,341 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+async function calendarListContextMenu(target, menuItem) {
+ await new Promise(r => setTimeout(r));
+ window.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == window,
+ "waiting for window to be focused"
+ );
+
+ // The test frequently times out if we don't wait here. Unknown why.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 250));
+
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" });
+ await shownPromise;
+
+ if (menuItem) {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(document.getElementById(menuItem));
+ await hiddenPromise;
+ }
+}
+
+async function withMockPromptService(response, callback) {
+ let realPrompt = Services.prompt;
+ Services.prompt = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: (unused1, unused2, text) => {
+ info(text);
+ return response;
+ },
+ };
+ await callback();
+ Services.prompt = realPrompt;
+}
+
+add_task(async () => {
+ function checkProperties(index, expected) {
+ let calendarList = document.getElementById("calendar-list");
+ let item = calendarList.rows[index];
+ let colorImage = item.querySelector(".calendar-color");
+ for (let [key, expectedValue] of Object.entries(expected)) {
+ switch (key) {
+ case "id":
+ Assert.equal(item.getAttribute("calendar-id"), expectedValue);
+ break;
+ case "disabled":
+ Assert.equal(item.querySelector(".calendar-displayed").hidden, expectedValue);
+ break;
+ case "displayed":
+ Assert.equal(item.querySelector(".calendar-displayed").checked, expectedValue);
+ break;
+ case "color":
+ if (item.hasAttribute("calendar-disabled")) {
+ Assert.equal(getComputedStyle(colorImage).backgroundColor, "rgba(0, 0, 0, 0)");
+ } else {
+ Assert.equal(getComputedStyle(colorImage).backgroundColor, expectedValue);
+ }
+ break;
+ case "name":
+ Assert.equal(item.querySelector(".calendar-name").textContent, expectedValue);
+ break;
+ }
+ }
+ }
+
+ function checkDisplayed(...expected) {
+ let calendarList = document.getElementById("calendar-list");
+ Assert.greater(calendarList.rowCount, Math.max(...expected));
+ for (let i = 0; i < calendarList.rowCount; i++) {
+ Assert.equal(
+ calendarList.rows[i].querySelector(".calendar-displayed").checked,
+ expected.includes(i)
+ );
+ }
+ }
+
+ function checkSortOrder(...expected) {
+ let orderPref = Services.prefs.getStringPref("calendar.list.sortOrder", "wrong");
+ Assert.notEqual(orderPref, "wrong", "sort order pref has a value");
+ let order = orderPref.split(" ");
+ Assert.equal(order.length, expected.length, "sort order length");
+ for (let i = 0; i < expected.length; i++) {
+ Assert.equal(order[i], calendars[expected[i]].id, "sort order ids");
+ }
+ }
+
+ let calendarList = document.getElementById("calendar-list");
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let composite = cal.view.getCompositeCalendar(window);
+
+ await CalendarTestUtils.openCalendarTab(window);
+
+ // Check the default calendar.
+ let calendars = cal.manager.getCalendars();
+ Assert.equal(calendars.length, 1);
+ Assert.equal(calendarList.rowCount, 1);
+ checkProperties(0, {
+ color: "rgb(168, 194, 225)",
+ name: "Home",
+ });
+ checkSortOrder(0);
+
+ // Test adding calendars.
+
+ // Open and then cancel the 'create calendar' dialog, just to prove that the
+ // context menu works.
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ "cancel",
+ "chrome://calendar/content/calendar-creation.xhtml"
+ );
+ calendarListContextMenu(calendarList, "list-calendars-context-new");
+ await dialogPromise;
+
+ // Add some new calendars, check their properties.
+ for (let i = 1; i <= 3; i++) {
+ calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory");
+ }
+
+ Assert.equal(cal.manager.getCalendars().length, 4);
+ Assert.equal(calendarList.rowCount, 4);
+
+ for (let i = 1; i <= 3; i++) {
+ checkProperties(i, {
+ id: calendars[i].id,
+ displayed: true,
+ color: "rgb(168, 194, 225)",
+ name: `Mochitest ${i}`,
+ });
+ }
+ checkSortOrder(0, 1, 2, 3);
+
+ // Test the context menu.
+
+ await new Promise(resolve => setTimeout(resolve));
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await new Promise(resolve => setTimeout(resolve));
+ await calendarListContextMenu(calendarList.rows[1]);
+ await new Promise(resolve => setTimeout(resolve));
+ Assert.equal(
+ document.getElementById("list-calendars-context-togglevisible").label,
+ "Hide Mochitest 1"
+ );
+ Assert.equal(
+ document.getElementById("list-calendars-context-showonly").label,
+ "Show Only Mochitest 1"
+ );
+ Assert.ok(
+ document.getElementById("list-calendar-context-reload").hidden,
+ "Local calendar should have reload menu showing"
+ );
+ contextMenu.hidePopup();
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+
+ // Test show/hide.
+ // TODO: Check events on calendars are hidden/shown.
+
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {});
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]);
+ Assert.equal(composite.getCalendarById(calendars[2].id), null);
+ checkDisplayed(0, 1, 3);
+
+ composite.removeCalendar(calendars[1]);
+ checkDisplayed(0, 3);
+
+ await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible");
+ checkDisplayed(0);
+
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[2].querySelector(".calendar-displayed"), {});
+ Assert.equal(composite.getCalendarById(calendars[2].id), calendars[2]);
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[2]);
+ checkDisplayed(0, 2);
+
+ composite.addCalendar(calendars[1]);
+ checkDisplayed(0, 1, 2);
+
+ await calendarListContextMenu(calendarList.rows[3], "list-calendars-context-togglevisible");
+ checkDisplayed(0, 1, 2, 3);
+
+ await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-showonly");
+ checkDisplayed(1);
+
+ await calendarListContextMenu(calendarList, "list-calendars-context-showall");
+ checkDisplayed(0, 1, 2, 3);
+
+ // Test editing calendars.
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ let nameElement = doc.getElementById("calendar-name");
+ let colorElement = doc.getElementById("calendar-color");
+ Assert.equal(nameElement.value, "Mochitest 1");
+ Assert.equal(colorElement.value, "#a8c2e1");
+ nameElement.value = "A New Calendar!";
+ colorElement.value = "#009900";
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], { clickCount: 2 });
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+ checkProperties(1, {
+ color: "rgb(0, 153, 0)",
+ name: "A New Calendar!",
+ });
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ let nameElement = doc.getElementById("calendar-name");
+ let colorElement = doc.getElementById("calendar-color");
+ Assert.equal(nameElement.value, "A New Calendar!");
+ Assert.equal(colorElement.value, "#009900");
+ nameElement.value = "Mochitest 1";
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ calendarListContextMenu(calendarList.rows[1], "list-calendars-context-edit");
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[1]);
+ checkProperties(1, {
+ color: "rgb(0, 153, 0)",
+ name: "Mochitest 1",
+ });
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ {
+ callback(win) {
+ let doc = win.document;
+ Assert.equal(doc.getElementById("calendar-name").value, "Mochitest 3");
+ let enabledElement = doc.getElementById("calendar-enabled-checkbox");
+ Assert.ok(enabledElement.checked);
+ enabledElement.checked = false;
+ doc.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+ // We're clicking on an item that wasn't the selected one. Selection should be updated.
+ calendarListContextMenu(calendarList.rows[3], "list-calendars-context-edit");
+ await dialogPromise;
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[3]);
+ checkProperties(3, { disabled: true });
+
+ calendars[3].setProperty("disabled", false);
+ checkProperties(3, { disabled: false });
+
+ // Test reordering calendars.
+
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ await new Promise(resolve => window.setTimeout(resolve));
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ calendarList.rows[3],
+ calendarList.rows[0],
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ {
+ screenY: calendarList.rows[0].getBoundingClientRect().top + 1,
+ }
+ );
+ await new Promise(resolve => setTimeout(resolve));
+
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, calendarList.rows[0]);
+ EventUtils.sendDragEvent({ type: "dragend" }, calendarList.rows[0]);
+ dragSession.endDragSession(true);
+ await new Promise(resolve => setTimeout(resolve));
+
+ checkSortOrder(3, 0, 1, 2);
+
+ Assert.equal(document.activeElement, calendarList);
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]);
+
+ // Test deleting calendars.
+
+ // Delete a calendar by unregistering it.
+ CalendarTestUtils.removeCalendar(calendars[3]);
+ Assert.equal(cal.manager.getCalendars().length, 3);
+ Assert.equal(calendarList.rowCount, 3);
+ checkSortOrder(0, 1, 2);
+
+ // Start to remove a calendar. Cancel the prompt.
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await withMockPromptService(1, () => {
+ EventUtils.synthesizeKey("VK_DELETE");
+ });
+ Assert.equal(cal.manager.getCalendars().length, 3, "three calendars left in the manager");
+ Assert.equal(calendarList.rowCount, 3, "three calendars left in the list");
+ checkSortOrder(0, 1, 2);
+
+ // Remove a calendar with the keyboard.
+ await withMockPromptService(0, () => {
+ EventUtils.synthesizeKey("VK_DELETE");
+ });
+ Assert.equal(cal.manager.getCalendars().length, 2, "two calendars left in the manager");
+ Assert.equal(calendarList.rowCount, 2, "two calendars left in the list");
+ checkSortOrder(0, 2);
+
+ // Remove a calendar with the context menu.
+ await withMockPromptService(0, async () => {
+ EventUtils.synthesizeMouseAtCenter(calendarList.rows[1], {});
+ await calendarListContextMenu(calendarList.rows[1], "list-calendars-context-delete");
+ });
+
+ Assert.equal(cal.manager.getCalendars().length, 1, "one calendar left in the manager");
+ Assert.equal(calendarList.rowCount, 1, "one calendar left in the list");
+ checkSortOrder(0);
+
+ Assert.equal(composite.defaultCalendar.id, calendars[0].id, "default calendar id check");
+ Assert.equal(calendarList.rows[calendarList.selectedIndex], calendarList.rows[0]);
+ await CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/browser_calendarTelemetry.js b/comm/calendar/test/browser/browser_calendarTelemetry.js
new file mode 100644
index 0000000000..cf2dff7a55
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarTelemetry.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test telemetry related to calendar.
+ */
+
+let { MailTelemetryForTests } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Check that we're counting calendars and read only calendars.
+ */
+add_task(async function testCalendarCount() {
+ Services.telemetry.clearScalars();
+
+ let calendars = cal.manager.getCalendars();
+ let homeCal = calendars.find(cal => cal.name == "Home");
+ let readOnly = homeCal.readOnly;
+ homeCal.readOnly = true;
+
+ for (let i = 1; i <= 3; i++) {
+ calendars[i] = CalendarTestUtils.createCalendar(`Mochitest ${i}`, "memory");
+ if (i === 1 || i === 3) {
+ calendars[i].readOnly = true;
+ }
+ }
+
+ await MailTelemetryForTests.reportCalendars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.calendar.calendar_count"].memory,
+ 3,
+ "Count of calendars must be correct."
+ );
+ Assert.equal(
+ scalars["tb.calendar.read_only_calendar_count"].memory,
+ 2,
+ "Count of readonly calendars must be correct."
+ );
+
+ Assert.ok(
+ !scalars["tb.calendar.calendar_count"].storage,
+ "'Home' calendar not included in count while disabled"
+ );
+
+ Assert.ok(
+ !scalars["tb.calendar.read_only_calendar_count"].storage,
+ "'Home' calendar not included in read-only count while disabled"
+ );
+
+ for (let i = 1; i <= 3; i++) {
+ CalendarTestUtils.removeCalendar(calendars[i]);
+ }
+ homeCal.readOnly = readOnly;
+});
+
+/**
+ * Ensure the "Home" calendar is not ignored if it has been used.
+ */
+add_task(async function testHomeCalendar() {
+ let calendar = cal.manager.getCalendars().find(cal => cal.name == "Home");
+ let readOnly = calendar.readOnly;
+ let disabled = calendar.getProperty("disabled");
+
+ // Test when enabled with no events.
+ calendar.setProperty("disabled", false);
+ calendar.readOnly = true;
+ Services.telemetry.clearScalars();
+ await MailTelemetryForTests.reportCalendars();
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.ok(!scalars["tb.calendar.calendar_count"], "'Home' calendar not counted when unused");
+ Assert.ok(
+ !scalars["tb.calendar.read_only_calendar_count"],
+ "'Home' calendar not included in readonly count when unused"
+ );
+
+ // Now test with an event added to the calendar.
+ calendar.readOnly = false;
+
+ let event = new CalEvent();
+ event.id = "bacd";
+ event.title = "Test";
+ event.startDate = cal.dtz.now();
+ event = await calendar.addItem(event);
+
+ calendar.readOnly = true;
+
+ await TestUtils.waitForCondition(async () => {
+ let result = await calendar.getItem("bacd");
+ return result;
+ }, "item added to calendar");
+
+ Services.telemetry.clearScalars();
+ await MailTelemetryForTests.reportCalendars();
+
+ scalars = TelemetryTestUtils.getProcessScalars("parent", true);
+ Assert.equal(
+ scalars["tb.calendar.calendar_count"].storage,
+ 1,
+ "'Home' calendar counted when there are items"
+ );
+ Assert.equal(
+ scalars["tb.calendar.read_only_calendar_count"].storage,
+ 1,
+ "'Home' calendar included in read-only count when used"
+ );
+
+ calendar.readOnly = false;
+ await calendar.deleteItem(event);
+ calendar.readOnly = readOnly;
+ calendar.setProperty("disabled", disabled);
+});
diff --git a/comm/calendar/test/browser/browser_calendarUnifinder.js b/comm/calendar/test/browser/browser_calendarUnifinder.js
new file mode 100644
index 0000000000..7020b70f71
--- /dev/null
+++ b/comm/calendar/test/browser/browser_calendarUnifinder.js
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+/**
+ * Tests clicking on events opens in the summary dialog for both
+ * non-recurring and recurring events.
+ */
+add_task(async function testOpenEvent() {
+ let uri = Services.io.newURI("moz-memory-calendar://");
+ let calendar = cal.manager.createCalendar("memory", uri);
+
+ calendar.name = "Unifinder Test";
+ cal.manager.registerCalendar(calendar);
+ registerCleanupFunction(() => cal.manager.removeCalendar(calendar));
+
+ let now = cal.dtz.now();
+
+ let noRepeatEvent = new CalEvent();
+ noRepeatEvent.id = "no repeat event";
+ noRepeatEvent.title = "No Repeat Event";
+ noRepeatEvent.startDate = now;
+ noRepeatEvent.endDate = noRepeatEvent.startDate.clone();
+ noRepeatEvent.endDate.hour++;
+
+ let repeatEvent = new CalEvent();
+ repeatEvent.id = "repeated event";
+ repeatEvent.title = "Repeat Event";
+ repeatEvent.startDate = now;
+ repeatEvent.endDate = noRepeatEvent.startDate.clone();
+ repeatEvent.endDate.hour++;
+ repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent);
+ repeatEvent.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30")
+ );
+
+ await CalendarTestUtils.openCalendarTab(window);
+
+ if (window.isUnifinderHidden()) {
+ window.toggleUnifinder();
+
+ await BrowserTestUtils.waitForCondition(
+ () => window.isUnifinderHidden(),
+ "calendar unifinder is open"
+ );
+ }
+
+ for (let event of [noRepeatEvent, repeatEvent]) {
+ await calendar.addItem(event);
+
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog();
+ let tree = document.querySelector("#unifinder-search-results-tree");
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 });
+
+ let dialogWindow = await dialogWindowPromise;
+ let docUri = dialogWindow.document.documentURI;
+ Assert.ok(
+ docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml",
+ "event summary dialog did show"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ await calendar.deleteItem(event);
+ }
+});
diff --git a/comm/calendar/test/browser/browser_dragEventItem.js b/comm/calendar/test/browser/browser_dragEventItem.js
new file mode 100644
index 0000000000..204e429fdd
--- /dev/null
+++ b/comm/calendar/test/browser/browser_dragEventItem.js
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test dragging of events in the various calendar views.
+ */
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Drag Test", "memory");
+// Set a low number of hours to reduce pixel -> minute rounding errors.
+Services.prefs.setIntPref("calendar.view.visiblehours", 3);
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.view.visiblehours");
+ // Reset the spaces toolbar to its default visible state.
+ window.gSpacesToolbar.toggleToolbar(false);
+});
+
+/**
+ * Ensures that the window is maximised after switching dates.
+ *
+ * @param {calIDateTime} date - A date to navigate the view to.
+ */
+async function resetView(date, view) {
+ window.goToDate(date);
+
+ if (window.windowState != window.STATE_MAXIMIZED) {
+ // The multi-day views adjust scrolling dynamically when they detect a
+ // resize. Hook into the resize event and scroll after the adjustment.
+ let resizePromise = BrowserTestUtils.waitForEvent(window, "resize");
+ window.maximize();
+ await resizePromise;
+ }
+}
+
+/**
+ * End the dragging of the event at the specified location.
+ *
+ * @param {number} day - The day to drop into.
+ * @param {number} hour - The starting hour to drop to.
+ * @param {number} topOffset - An offset to apply to the mouse position.
+ */
+function endDrag(day, hour, topOffset) {
+ let view = window.currentView();
+ let hourElement;
+ if (view.id == "day-view") {
+ hourElement = CalendarTestUtils.dayView.getHourBoxAt(window, hour);
+ } else {
+ hourElement = CalendarTestUtils.weekView.getHourBoxAt(window, day, hour);
+ }
+ // We scroll to align the *end* of the hour element so we can avoid triggering
+ // the auto-scroll when we synthesize mousemove below.
+ // FIXME: Use and test auto scroll by holding mouseover at the view edges.
+ CalendarTestUtils.scrollViewToTarget(hourElement, false);
+
+ let hourRect = hourElement.getBoundingClientRect();
+
+ // We drop the event with some offset from the starting edge of the desired
+ // hourElement.
+ // NOTE: This may mean that the drop point may not be above the hourElement.
+ // NOTE: We assume that the drop point is however still above the view.
+ // Currently event "move" events get cancelled if the pointer leaves the view.
+ let top = Math.round(hourRect.top + topOffset);
+ let left = Math.round(hourRect.left + hourRect.width / 2);
+
+ EventUtils.synthesizeMouseAtPoint(left, top, { type: "mousemove", shiftKey: true }, window);
+ EventUtils.synthesizeMouseAtPoint(left, top, { type: "mouseup", shiftKey: true }, window);
+}
+
+/**
+ * Simulates the dragging of an event box in a multi-day view to another
+ * column, horizontally.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event to start moving.
+ * @param {number} day - The day to drop into.
+ * @param {number} hour - The starting hour to drop to.
+ */
+function simulateDragToColumn(eventBox, day, hour) {
+ // Scroll to align to the top of the view.
+ CalendarTestUtils.scrollViewToTarget(eventBox, true);
+
+ let sourceRect = eventBox.getBoundingClientRect();
+ // Start dragging from the center of the event box to avoid the gripbars.
+ // NOTE: We assume that the eventBox's center is in view.
+ let leftOffset = sourceRect.width / 2;
+ // We round the mouse position to try and reduce rounding errors when
+ // scrolling the view.
+ let sourceTop = Math.round(sourceRect.top + sourceRect.height / 2);
+ let sourceLeft = sourceRect.left + leftOffset;
+ // Keep track of the exact offset.
+ let topOffset = sourceTop - sourceRect.top;
+
+ EventUtils.synthesizeMouseAtPoint(
+ sourceLeft,
+ sourceTop,
+ // Hold shift to avoid snapping.
+ { type: "mousedown", shiftKey: true },
+ window
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ // We assume the location of the mouseout event does not matter, just as
+ // long as the event box receives it.
+ sourceLeft,
+ sourceTop,
+ { type: "mouseout", shiftKey: true },
+ window
+ );
+
+ // End drag with the same offset from the starting edge.
+ endDrag(day, hour, topOffset);
+}
+
+/**
+ * Simulates the dragging of an event box via one of the gripbars.
+ *
+ * @param {MozCalendarEventBox} eventBox - The event to resize.
+ * @param {"start"|"end"} - The side to grab.
+ * @param {number} day - The day to move into.
+ * @param {number} hour - The hour to move to.
+ */
+function simulateGripbarDrag(eventBox, side, day, hour) {
+ // Scroll the edge of the box into view.
+ CalendarTestUtils.scrollViewToTarget(eventBox, side == "start");
+
+ let gripbar = side == "start" ? eventBox.startGripbar : eventBox.endGripbar;
+
+ let sourceRect = gripbar.getBoundingClientRect();
+ let sourceTop = sourceRect.top + sourceRect.height / 2;
+ let sourceLeft = sourceRect.left + sourceRect.width / 2;
+
+ // Hover to make the gripbar visible.
+ EventUtils.synthesizeMouseAtPoint(sourceLeft, sourceTop, { type: "mouseover" }, window);
+ EventUtils.synthesizeMouseAtPoint(
+ sourceLeft,
+ sourceTop,
+ // Hold shift to avoid snapping.
+ { type: "mousedown", shiftKey: true },
+ window
+ );
+
+ // End the drag at the start of the hour.
+ endDrag(day, hour, 0);
+}
+
+/**
+ * Tests dragging an event item updates the event in the month view.
+ */
+add_task(async function testMonthViewDragEventItem() {
+ let event = new CalEvent();
+ event.id = "1";
+ event.title = "Month View Event";
+ event.startDate = cal.createDateTime("20210316T000000Z");
+ event.endDate = cal.createDateTime("20210316T110000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ // Hide the spaces toolbar since it interferes with the calendar
+ window.gSpacesToolbar.toggleToolbar(true);
+
+ let eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 3, 1);
+ let dayBox = await CalendarTestUtils.monthView.getDayBox(window, 3, 2);
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ eventItem,
+ dayBox,
+ undefined,
+ undefined,
+ eventItem.ownerGlobal,
+ dayBox.ownerGlobal
+ );
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox);
+ dragSession.endDragSession(true);
+
+ Assert.ok(
+ !CalendarTestUtils.monthView.getItemAt(window, 3, 3, 1),
+ "item removed from initial date"
+ );
+
+ eventItem = await CalendarTestUtils.monthView.waitForItemAt(window, 3, 2, 1);
+ Assert.ok(eventItem, "item moved to new date");
+
+ let { id, title, startDate, endDate } = eventItem.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct");
+ await calendar.deleteItem(eventItem.occurrence);
+});
+
+/**
+ * Tests dragging an event item updates the event in the multiweek view.
+ */
+add_task(async function testMultiWeekViewDragEventItem() {
+ let event = new CalEvent();
+ event.id = "2";
+ event.title = "Multiweek View Event";
+ event.startDate = cal.createDateTime("20210316T000000Z");
+ event.endDate = cal.createDateTime("20210316T110000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 3, 1);
+ let dayBox = await CalendarTestUtils.multiweekView.getDayBox(window, 1, 2);
+ let dragSession = Cc["@mozilla.org/widget/dragservice;1"].getService(Ci.nsIDragService);
+ dragSession.startDragSessionForTests(Ci.nsIDragService.DRAGDROP_ACTION_MOVE);
+
+ let [result, dataTransfer] = EventUtils.synthesizeDragOver(
+ eventItem,
+ dayBox,
+ undefined,
+ undefined,
+ eventItem.ownerGlobal,
+ dayBox.ownerGlobal
+ );
+ EventUtils.synthesizeDropAfterDragOver(result, dataTransfer, dayBox);
+ dragSession.endDragSession(true);
+
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 1, 3, 1),
+ "item removed from initial date"
+ );
+
+ eventItem = await CalendarTestUtils.multiweekView.waitForItemAt(window, 1, 2, 1);
+ Assert.ok(eventItem, "item moved to new date");
+
+ let { id, title, startDate, endDate } = eventItem.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T000000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T110000Z", "endDate is correct");
+ await calendar.deleteItem(eventItem.occurrence);
+});
+
+/**
+ * Tests dragging an event box to the previous day updates the event in the
+ * week view.
+ */
+add_task(async function testWeekViewDragEventBoxToPreviousDay() {
+ let event = new CalEvent();
+ event.id = "3";
+ event.title = "Week View Previous Day";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateDragToColumn(eventBox, 2, 2);
+
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 2, 1);
+ await TestUtils.waitForCondition(
+ () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1),
+ "Old position is empty"
+ );
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210315T020000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210315T030000Z", "endDate is correct");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging an event box to the following day updates the event in the
+ * week view.
+ */
+add_task(async function testWeekViewDragEventBoxToFollowingDay() {
+ let event = new CalEvent();
+ event.id = "4";
+ event.title = "Week View Following Day";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateDragToColumn(eventBox, 4, 2);
+
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 4, 1);
+ await TestUtils.waitForCondition(
+ () => !CalendarTestUtils.weekView.getEventBoxAt(window, 3, 1),
+ "Old position is empty"
+ );
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210317T020000Z", "startDate is correct");
+ Assert.equal(endDate.icalString, "20210317T030000Z", "endDate is correct");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the top of an event box updates the start time in the week
+ * view.
+ */
+add_task(async function testWeekViewDragEventBoxStartTime() {
+ let event = new CalEvent();
+ event.id = "5";
+ event.title = "Week View Start";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateGripbarDrag(eventBox, "start", 3, 1);
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed");
+ Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the end of an event box changes the time in the week view.
+ */
+add_task(async function testWeekViewDragEventBoxEndTime() {
+ let event = new CalEvent();
+ event.id = "6";
+ event.title = "Week View End";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+ simulateGripbarDrag(eventBox, "end", 3, 6);
+ eventBox = await CalendarTestUtils.weekView.waitForEventBoxAt(window, 3, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change");
+ Assert.equal(endDate.icalString, "20210316T060000Z", "endDate was changed");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the top of an event box changes the start time in the day view.
+ */
+add_task(async function testDayViewDragEventBoxStartTime() {
+ let event = new CalEvent();
+ event.id = "7";
+ event.title = "Day View Start";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ simulateGripbarDrag(eventBox, "start", 1, 1);
+ eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T010000Z", "startDate was changed");
+ Assert.equal(endDate.icalString, "20210316T030000Z", "endDate did not change");
+ await calendar.deleteItem(eventBox.occurrence);
+});
+
+/**
+ * Tests dragging the bottom of an event box changes the end time in the day
+ * view.
+ */
+add_task(async function testDayViewDragEventBoxEndTime() {
+ let event = new CalEvent();
+ event.id = "8";
+ event.title = "Day View End";
+ event.startDate = cal.createDateTime("20210316T020000Z");
+ event.endDate = cal.createDateTime("20210316T030000Z");
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await calendar.addItem(event);
+ await resetView(event.startDate);
+
+ let eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ simulateGripbarDrag(eventBox, "end", 1, 4);
+ eventBox = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let { id, title, startDate, endDate } = eventBox.occurrence;
+ Assert.equal(id, event.id, "id is correct");
+ Assert.equal(title, event.title, "title is correct");
+ Assert.equal(startDate.icalString, "20210316T020000Z", "startDate did not change");
+ Assert.equal(endDate.icalString, "20210316T040000Z", "endDate was changed");
+ await calendar.deleteItem(eventBox.occurrence);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_dayView.js b/comm/calendar/test/browser/browser_eventDisplay_dayView.js
new file mode 100644
index 0000000000..5f0941cac4
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_dayView.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {CalEvent} - The created event.
+ */
+async function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is an event shown on the given date in the day-view.
+ *
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertDayEvent(date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ let element = await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Event name should match: ${message}`
+ );
+ await CalendarTestUtils.assertEventBoxDraggable(
+ element,
+ expect.startInView,
+ expect.endInView,
+ message
+ );
+}
+
+/**
+ * Test an event that occurs within one day, in the day view.
+ */
+add_task(async function testInsideDayView() {
+ let event = await createEvent("Test Event", "20190403T123400", "20190403T234500");
+ await CalendarTestUtils.setCalendarView(window, "day");
+ Assert.equal(
+ document.querySelectorAll("#day-view calendar-event-column").length,
+ 1,
+ "1 day column in the day view"
+ );
+
+ // This event is fully within this view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: true },
+ "Single day event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the day view.
+ */
+add_task(async function testMidnightDayView() {
+ let event = await createEvent("Test Event", "20190403T000000", "20190404T000000");
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // This event is fully within this view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: true },
+ "Single midnight event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple days, in the day view.
+ */
+add_task(async function testOutsideDayView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190404T234500");
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // Go to the start of the event. The end of the event is beyond the current view.
+ await assertDayEvent(
+ { day: 2, month: 4, year: 2019 },
+ { name: "Test Event", startInView: true, endInView: false },
+ "First day"
+ );
+
+ // Go to the middle of the event. Both ends of the event are beyond the current view.
+ await assertDayEvent(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", startInView: false, endInView: false },
+ "Middle day"
+ );
+
+ // Go to the end of the event. The start of the event is beyond the current view.
+ await assertDayEvent(
+ { day: 4, month: 4, year: 2019 },
+ { name: "Test Event", startInView: false, endInView: true },
+ "Last day"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js
new file mode 100644
index 0000000000..91ddeec6ac
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_multiWeekView.js
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {Promise<CalEvent>} - The created event.
+ */
+function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is a an event in the multiweek or month view between the
+ * expected range, and no events on the other days.
+ *
+ * @param {"multiweek"|"month"} viewName - The view to test.
+ * @param {number} numWeeks - The number of weeks shown in the view.
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {number} expect.start - The day that the event should start in the
+ * week. Between 1 and 7.
+ * @param {number} expect.end - The day that the event should end in the week.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertMultiweekEvents(viewName, numWeeks, date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ let view =
+ viewName == "multiweek" ? CalendarTestUtils.multiweekView : CalendarTestUtils.monthView;
+
+ // start = (startWeek - 1) * 7 + startDay
+ let startWeek = Math.floor((expect.start - 1) / 7) + 1;
+ let startDay = ((expect.start - 1) % 7) + 1;
+ let endWeek = Math.floor((expect.end - 1) / 7) + 1;
+ let endDay = ((expect.end - 1) % 7) + 1;
+ for (let week = startWeek; week <= endWeek; week++) {
+ let start = week == startWeek ? startDay : 1;
+ let end = week == endWeek ? endDay : 7;
+ for (let day = start; day <= end; day++) {
+ let element = await view.waitForItemAt(window, week, day, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Week ${week}, day ${day} event name should match: ${message}`
+ );
+ let multidayIcon = element.querySelector(".item-type-icon");
+ if (startDay == endDay && week == startWeek && day == startDay) {
+ Assert.equal(multidayIcon.src, "", `Week ${week}, day ${day} icon has no source`);
+ } else if (expect.startInView && week == startWeek && day == startDay) {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-start.svg",
+ `Week ${week}, day ${day} icon src shows event start: ${message}`
+ );
+ } else if (expect.endInView && week == endWeek && day == endDay) {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-end.svg",
+ `Week ${week}, day ${day} icon src shows event end: ${message}`
+ );
+ } else {
+ Assert.equal(
+ multidayIcon.src,
+ "chrome://calendar/skin/shared/event-continue.svg",
+ `Week ${week}, day ${day} icon src shows event continue: ${message}`
+ );
+ }
+ }
+ }
+ Assert.equal(
+ numWeeks,
+ document.querySelectorAll(`#${viewName}-view .monthbody tr:not([hidden])`).length,
+ `Should show ${numWeeks} weeks in the view: ${message}`
+ );
+ // Test no events loaded on the other days.
+ for (let week = 1; week <= numWeeks; week++) {
+ for (let day = 1; day <= 7; day++) {
+ if (
+ (week > startWeek && week < endWeek) ||
+ (week == startWeek && day >= startDay) ||
+ (week == endWeek && day <= endDay)
+ ) {
+ continue;
+ }
+ Assert.ok(
+ !view.getItemAt(window, week, day, 1),
+ `Should be no events on day ${day}: ${message}`
+ );
+ }
+ }
+}
+
+/**
+ * Test an event that occurs fully within the multi-week view.
+ */
+add_task(async function testInsideMultiweekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190419T234500");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ Assert.equal(
+ document.querySelectorAll("#multiweek-view tr:not([hidden]) calendar-month-day-box").length,
+ 28,
+ "28 days in the multiweek view"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true },
+ "3 week event in multiweek view"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the multi-week view.
+ */
+add_task(async function testMidnightMultiweekView() {
+ // Event spans one day.
+ let event = await createEvent("Test Event", "20190402T000000", "20190403T000000");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 3, startInView: true, endInView: true },
+ "one day midnight event in multiweek"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts or ends outside the multi-week view.
+ */
+add_task(async function testOutsideMultiweekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190507T234500");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 11, month: 3, year: 2019 },
+ { name: "Test Event", start: 24, end: 28, startInView: true, endInView: false },
+ "First block in multiweek"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 8, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 28, startInView: false, endInView: false },
+ "Middle block in multiweek"
+ );
+
+ await assertMultiweekEvents(
+ "multiweek",
+ 4,
+ { day: 29, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true },
+ "End block in multiweek"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that occurs within one month, in the month view.
+ */
+add_task(async function testInsideMonthView() {
+ let event = await createEvent("Test Event", "20190702T123400", "20190719T234500");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ Assert.equal(
+ document.querySelectorAll("#month-view tr:not([hidden]) calendar-month-day-box").length,
+ 35,
+ "35 days in the month view"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 7, year: 2019 },
+ { name: "Test Event", start: 3, end: 20, startInView: true, endInView: true },
+ "Event in single month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the month view.
+ */
+add_task(async function testMidnightMonthView() {
+ // Event spans three days.
+ let event = await createEvent("Test Event", "20190702T000000", "20190705T000000");
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 7, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "3 day midnight event in single month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple months, in the month view.
+ */
+add_task(async function testOutsideMonthView() {
+ let event = await createEvent("Test Event", "20190320T123400", "20190507T234500");
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ await assertMultiweekEvents(
+ "month",
+ 6,
+ { day: 1, month: 3, year: 2019 },
+ { name: "Test Event", start: 25, end: 42, startInView: true, endInView: false },
+ "First month"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 35, startInView: false, endInView: false },
+ "Middle month"
+ );
+
+ await assertMultiweekEvents(
+ "month",
+ 5,
+ { day: 1, month: 5, year: 2019 },
+ { name: "Test Event", start: 1, end: 10, startInView: false, endInView: true },
+ "End month"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventDisplay_weekView.js b/comm/calendar/test/browser/browser_eventDisplay_weekView.js
new file mode 100644
index 0000000000..806105c29a
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventDisplay_weekView.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Create an event item in the calendar.
+ *
+ * @param {string} name - The name of the event.
+ * @param {string} start - The date time string for the start of the event.
+ * @param {string} end - The date time string for the end of the event.
+ *
+ * @returns {CalEvent} - The created event.
+ */
+async function createEvent(name, start, end) {
+ let event = new CalEvent();
+ event.title = name;
+ event.startDate = cal.createDateTime(start);
+ event.endDate = cal.createDateTime(end);
+ return calendar.addItem(event);
+}
+
+/**
+ * Assert that there is a an event in the week-view between the expected range,
+ * and no events on the other days.
+ *
+ * @param {object} date - The date to move to.
+ * @param {number} date.day - The day.
+ * @param {number} date.week - The week.
+ * @param {number} date.year - The year.
+ * @param {object} expect - Details about the expected event.
+ * @param {string} expect.name - The event name.
+ * @param {number} expect.start - The day that the event should start in the
+ * week. Between 1 and 7.
+ * @param {number} expect.end - The day that the event should end in the week.
+ * @param {boolean} expect.startInView - Whether the event starts within the
+ * view on the given date.
+ * @param {boolean} expect.endInView - Whether the event ends within the view
+ * on the given date.
+ * @param {string} message - A message to use in assertions.
+ */
+async function assertWeekEvents(date, expect, message) {
+ await CalendarTestUtils.goToDate(window, date.year, date.month, date.day);
+ // First test for expected events since these can take a short while to load,
+ // and we don't want to test for the absence of an event before they show.
+ for (let day = expect.start; day <= expect.end; day++) {
+ let element = await CalendarTestUtils.weekView.waitForEventBoxAt(window, day, 1);
+ Assert.equal(
+ element.querySelector(".event-name-label").textContent,
+ expect.name,
+ `Day ${day} event name should match: ${message}`
+ );
+ let icon = element.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "");
+ Assert.ok(icon.hidden);
+ await CalendarTestUtils.assertEventBoxDraggable(
+ element,
+ expect.startInView && day == expect.start,
+ expect.endInView && day == expect.end,
+ `Day ${day}: ${message}`
+ );
+ }
+ // Test no events loaded on the other days.
+ for (let day = 1; day <= 7; day++) {
+ if (day >= expect.start && day <= expect.end) {
+ continue;
+ }
+ Assert.equal(
+ CalendarTestUtils.weekView.getEventBoxes(window, day).length,
+ 0,
+ `Should be no events on day ${day}: ${message}`
+ );
+ }
+}
+
+/**
+ * Test an event that occurs within one week, in the week view.
+ */
+add_task(async function testInsideWeekView() {
+ let event = await createEvent("Test Event", "20190101T123400", "20190103T234500");
+ await CalendarTestUtils.setCalendarView(window, "week");
+ Assert.equal(
+ document.querySelectorAll("#week-view calendar-event-column").length,
+ 7,
+ "7 day columns in the week view"
+ );
+
+ await assertWeekEvents(
+ { day: 1, month: 1, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "Single week event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that starts and ends at midnight, in the week view.
+ */
+add_task(async function testMidnightWeekView() {
+ // Spans three days.
+ let event = await createEvent("Test Event", "20190101T000000", "20190104T000000");
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ // Midnight-to-midnight event only spans one day even though the end time
+ // matches the starting time of the next day (midnight).
+ await assertWeekEvents(
+ { day: 1, month: 1, year: 2019 },
+ { name: "Test Event", start: 3, end: 5, startInView: true, endInView: true },
+ "Midnight week event"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test an event that spans multiple weeks, in the week view.
+ */
+add_task(async function testOutsideWeekView() {
+ let event = await createEvent("Test Event", "20190402T123400", "20190418T234500");
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ await assertWeekEvents(
+ { day: 3, month: 4, year: 2019 },
+ { name: "Test Event", start: 3, end: 7, startInView: true, endInView: false },
+ "First week"
+ );
+ await assertWeekEvents(
+ { day: 10, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 7, startInView: false, endInView: false },
+ "Middle week"
+ );
+ await assertWeekEvents(
+ { day: 17, month: 4, year: 2019 },
+ { name: "Test Event", start: 1, end: 5, startInView: false, endInView: true },
+ "Last week"
+ );
+
+ await CalendarTestUtils.closeCalendarTab(window);
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/browser_eventUndoRedo.js b/comm/calendar/test/browser/browser_eventUndoRedo.js
new file mode 100644
index 0000000000..34ea8d0523
--- /dev/null
+++ b/comm/calendar/test/browser/browser_eventUndoRedo.js
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Tests for ensuring the undo/redo options are enabled properly when
+ * manipulating events.
+ */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTransactionManager: "resource:///modules/CalTransactionManager.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Undo Redo Test");
+const calTransManager = CalTransactionManager.getInstance();
+
+/**
+ * Checks the value of the "disabled" property for items in either the "Edit"
+ * menu bar or the app menu. Display of the relevant menu is triggered first so
+ * the UI code can update the respective items.
+ *
+ * @param {XULElement} element - The menu item we want to check, if its id begins
+ * with "menu" then we assume it is in the menu
+ * bar, if "appmenu" then the app menu.
+ */
+async function isDisabled(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden");
+ let status = element.disabled;
+ targetMenu.hidePopup();
+ await hiddenPromise;
+ return status;
+}
+
+async function clickItem(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ targetMenu.activateItem(element);
+}
+
+/**
+ * Removes CalTransaction items from the CalTransactionManager stacks so other
+ * tests are unhindered.
+ */
+function clearTransactions() {
+ calTransManager.undoStack = [];
+ calTransManager.redoStack = [];
+}
+
+/**
+ * Test the undo/redo functionality for event creation.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testAddUndoRedoEvent(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let newBtn = document.getElementById("sidePanelNewEvent");
+ let windowOpened = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newBtn, {});
+
+ let win = await windowOpened;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "A New Event" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "undo did not remove item in time");
+
+ Assert.ok(!eventItem, `#${undoId} reverses item creation`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, `${redoId} did not re-create item in time`);
+ Assert.ok(eventItem, `#${redoId} redos item creation`);
+
+ await calendar.deleteItem(eventItem.item);
+ clearTransactions();
+}
+
+/**
+ * Test the undo/redo functionality for event modification.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testModifyUndoRedoEvent(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let event = new CalEvent();
+ event.title = "Modifiable Event";
+ event.startDate = cal.dtz.now();
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventItem);
+ await CalendarTestUtils.items.setData(dialogWindow, iframeWindow, {
+ title: "Modified Event",
+ });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modified Event";
+ }, "event not modified in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modifiable Event";
+ }, `#${undoId} did not un-modify event in time`);
+
+ Assert.equal(eventItem.item.title, "Modifiable Event", `#${undoId} reverses item modification`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem && eventItem.item.title == "Modified Event";
+ }, `${redoId} did not re-modify item in time`);
+
+ Assert.equal(eventItem.item.title, "Modified Event", `#${redoId} redos item modification`);
+
+ clearTransactions();
+ await calendar.deleteItem(eventItem.item);
+}
+
+/**
+ * Test the undo/redo functionality for event deletion.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testDeleteUndoRedo(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let event = new CalEvent();
+ event.title = "Deletable Event";
+ event.startDate = cal.dtz.now();
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let eventItem;
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, "event not created in time");
+
+ EventUtils.synthesizeMouseAtCenter(eventItem, {});
+ EventUtils.synthesizeKey("VK_DELETE");
+
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "event not deleted in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ await clickItem(undo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return eventItem;
+ }, `#${undoId} did not add event in time`);
+ Assert.ok(eventItem, `#${undoId} reverses item deletion`);
+
+ // Test redo.
+ await clickItem(redo);
+ await TestUtils.waitForCondition(() => {
+ eventItem = document.querySelector("calendar-month-day-box-item");
+ return !eventItem;
+ }, "redo did not delete item in time");
+
+ Assert.ok(!eventItem, `#${redoId} redos item deletion`);
+ clearTransactions();
+}
+
+/**
+ * Ensure the menu bar is visible and navigate the calendar view to today.
+ */
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ clearTransactions();
+ document.getElementById("toolbar-menubar").setAttribute("autohide", null);
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.dtz.now());
+});
+
+/**
+ * Tests the menu bar's undo/redo after adding an event.
+ */
+add_task(async function testMenuBarAddEventUndoRedo() {
+ return testAddUndoRedoEvent("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after modifying an event.
+ */
+add_task(async function testMenuBarModifyEventUndoRedo() {
+ return testModifyUndoRedoEvent("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after deleting an event.
+ */
+add_task(async function testMenuBarDeleteEventUndoRedo() {
+ return testDeleteUndoRedo("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
diff --git a/comm/calendar/test/browser/browser_import.js b/comm/calendar/test/browser/browser_import.js
new file mode 100644
index 0000000000..86e605802b
--- /dev/null
+++ b/comm/calendar/test/browser/browser_import.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This tests importing an ICS file. Rather than using the UI to trigger the
+// import, loadEventsFromFile is called directly.
+
+/* globals loadEventsFromFile */
+
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);
+
+add_task(async () => {
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2019, 1, 1);
+
+ let chromeUrl = Services.io.newURI(getRootDirectory(gTestPath) + "data/import.ics");
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.returnValue = MockFilePicker.returnCancel;
+
+ let calendar = CalendarTestUtils.createCalendar();
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ MockFilePicker.cleanup();
+ });
+
+ let cancelReturn = await loadEventsFromFile();
+ ok(!cancelReturn, "loadEventsFromFile returns false on cancel");
+
+ // Prepare to test the import dialog.
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-ics-file-dialog.xhtml",
+ {
+ async callback(dialogWindow) {
+ let doc = dialogWindow.document;
+ let dialogElement = doc.querySelector("dialog");
+
+ let optionsPane = doc.getElementById("calendar-ics-file-dialog-options-pane");
+ let progressPane = doc.getElementById("calendar-ics-file-dialog-progress-pane");
+ let resultPane = doc.getElementById("calendar-ics-file-dialog-result-pane");
+
+ ok(!optionsPane.hidden);
+ ok(progressPane.hidden);
+ ok(resultPane.hidden);
+
+ // Check the initial import dialog state.
+ let displayedPath = doc.querySelector("#calendar-ics-file-dialog-file-path").value;
+ let pathFragment = "browser/comm/calendar/test/browser/data/import.ics";
+ if (Services.appinfo.OS == "WINNT") {
+ pathFragment = pathFragment.replace(/\//g, "\\");
+ }
+ is(
+ displayedPath.substring(displayedPath.length - pathFragment.length),
+ pathFragment,
+ "the displayed ics file path is correct"
+ );
+
+ let calendarMenu = doc.querySelector("#calendar-ics-file-dialog-calendar-menu");
+ // 0 is the Home calendar.
+ calendarMenu.selectedIndex = 1;
+ let calendarMenuItems = calendarMenu.querySelectorAll("menuitem");
+ is(calendarMenu.value, "Test", "correct calendar name is selected");
+ Assert.equal(calendarMenuItems.length, 1, "exactly one calendar is in the calendars menu");
+ is(calendarMenuItems[0].selected, true, "calendar menu item is selected");
+
+ let items;
+ await TestUtils.waitForCondition(() => {
+ items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+ return items.length == 4;
+ }, "four calendar items are displayed");
+ is(
+ items[0].querySelector(".item-title").textContent,
+ "Event One",
+ "event 1 title should be correct"
+ );
+ is(
+ items[1].querySelector(".item-title").textContent,
+ "Event Two",
+ "event 2 title should be correct"
+ );
+ is(
+ items[2].querySelector(".item-title").textContent,
+ "Event Three",
+ "event 3 title should be correct"
+ );
+ is(
+ items[3].querySelector(".item-title").textContent,
+ "Event Four",
+ "event 4 title should be correct"
+ );
+ is(
+ items[0].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T150000")),
+ "event 1 start date should be correct"
+ );
+ is(
+ items[0].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")),
+ "event 1 end date should be correct"
+ );
+ is(
+ items[1].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T160000")),
+ "event 2 start date should be correct"
+ );
+ is(
+ items[1].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")),
+ "event 2 end date should be correct"
+ );
+ is(
+ items[2].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T170000")),
+ "event 3 start date should be correct"
+ );
+ is(
+ items[2].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")),
+ "event 3 end date should be correct"
+ );
+ is(
+ items[3].querySelector(".item-date-row-start-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T180000")),
+ "event 4 start date should be correct"
+ );
+ is(
+ items[3].querySelector(".item-date-row-end-date").textContent,
+ cal.dtz.formatter.formatDateTime(cal.createDateTime("20190101T190000")),
+ "event 4 end date should be correct"
+ );
+
+ function check_displayed_titles(expectedTitles) {
+ let items = doc.querySelectorAll(
+ ".calendar-ics-file-dialog-item-frame:not([hidden]) > calendar-item-summary"
+ );
+ Assert.deepEqual(
+ [...items].map(summary => summary.item.title),
+ expectedTitles
+ );
+ }
+
+ let filterInput = doc.getElementById("calendar-ics-file-dialog-search-input");
+ async function check_filter(filterText, expectedTitles) {
+ let commandPromise = BrowserTestUtils.waitForEvent(filterInput, "command");
+
+ EventUtils.synthesizeMouseAtCenter(filterInput, {}, dialogWindow);
+ if (filterText) {
+ EventUtils.synthesizeKey("a", { accelKey: true }, dialogWindow);
+ EventUtils.sendString(filterText, dialogWindow);
+ } else {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, dialogWindow);
+ }
+
+ await commandPromise;
+
+ check_displayed_titles(expectedTitles);
+ }
+
+ await check_filter("event", ["Event One", "Event Two", "Event Three", "Event Four"]);
+ await check_filter("four", ["Event Four"]);
+ await check_filter("ONE", ["Event One"]);
+ await check_filter(`"event t"`, ["Event Two", "Event Three"]);
+ await check_filter("", ["Event One", "Event Two", "Event Three", "Event Four"]);
+
+ async function check_sort(order, expectedTitles) {
+ let sortButton = doc.getElementById("calendar-ics-file-dialog-sort-button");
+ let shownPromise = BrowserTestUtils.waitForEvent(sortButton, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(sortButton, {}, dialogWindow);
+ await shownPromise;
+ let hiddenPromise = BrowserTestUtils.waitForEvent(sortButton, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.getElementById(`calendar-ics-file-dialog-sort-${order}`),
+ {},
+ dialogWindow
+ );
+ await hiddenPromise;
+
+ let items = doc.querySelectorAll("calendar-item-summary");
+ is(items.length, 4, "four calendar items are displayed");
+ Assert.deepEqual(
+ [...items].map(summary => summary.item.title),
+ expectedTitles
+ );
+ }
+
+ await check_sort("title-ascending", [
+ "Event Four",
+ "Event One",
+ "Event Three",
+ "Event Two",
+ ]);
+ await check_sort("start-descending", [
+ "Event Four",
+ "Event Three",
+ "Event Two",
+ "Event One",
+ ]);
+ await check_sort("title-descending", [
+ "Event Two",
+ "Event Three",
+ "Event One",
+ "Event Four",
+ ]);
+ await check_sort("start-ascending", [
+ "Event One",
+ "Event Two",
+ "Event Three",
+ "Event Four",
+ ]);
+
+ items = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+
+ // Import just the first item, and check that the correct number of items remains.
+ let firstItemImportButton = items[0].querySelector(
+ ".calendar-ics-file-dialog-item-import-button"
+ );
+ EventUtils.synthesizeMouseAtCenter(firstItemImportButton, { clickCount: 1 }, dialogWindow);
+
+ await TestUtils.waitForCondition(() => {
+ let remainingItems = doc.querySelectorAll(".calendar-ics-file-dialog-item-frame");
+ return remainingItems.length == 3;
+ }, "three items remain after importing the first item");
+ check_displayed_titles(["Event Two", "Event Three", "Event Four"]);
+
+ // Filter and import the shown items.
+ await check_filter("four", ["Event Four"]);
+
+ dialogElement.getButton("accept").click();
+ ok(optionsPane.hidden);
+ ok(!progressPane.hidden);
+ ok(resultPane.hidden);
+
+ await TestUtils.waitForCondition(() => !optionsPane.hidden);
+ ok(progressPane.hidden);
+ ok(resultPane.hidden);
+
+ is(filterInput.value, "");
+ check_displayed_titles(["Event Two", "Event Three"]);
+
+ // Click the accept button to import the remaining items.
+ dialogElement.getButton("accept").click();
+ ok(optionsPane.hidden);
+ ok(!progressPane.hidden);
+ ok(resultPane.hidden);
+
+ await TestUtils.waitForCondition(() => !resultPane.hidden);
+ ok(optionsPane.hidden);
+ ok(progressPane.hidden);
+
+ let messageElement = doc.querySelector("#calendar-ics-file-dialog-result-message");
+ is(messageElement.textContent, "Import complete.", "import success message appeared");
+
+ dialogElement.getButton("accept").click();
+ },
+ }
+ );
+
+ await loadEventsFromFile();
+ await dialogWindowPromise;
+
+ // Check that the items were actually successfully imported.
+ let result = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ cal.createDateTime("20190101T000000"),
+ cal.createDateTime("20190102T000000")
+ );
+ is(result.length, 4, "all items that were imported were in fact imported");
+
+ await CalendarTestUtils.monthView.waitForItemAt(window, 1, 3, 4);
+
+ for (let item of result) {
+ await calendar.deleteItem(item);
+ }
+});
diff --git a/comm/calendar/test/browser/browser_localICS.js b/comm/calendar/test/browser/browser_localICS.js
new file mode 100644
index 0000000000..43e0299937
--- /dev/null
+++ b/comm/calendar/test/browser/browser_localICS.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createCalendarUsingDialog */
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const HOUR = 8;
+
+// Unique name needed as deleting a calendar only unsubscribes from it and
+// if same file were used on next testrun then previously created event
+// would show up.
+var calendarName = String(Date.now());
+var calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+calendarFile.append(calendarName + ".ics");
+
+add_task(async function testLocalICS() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await createCalendarUsingDialog(calendarName, { network: {} });
+
+ // Create new event.
+ let box = CalendarTestUtils.dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, box);
+ await setData(dialogWindow, iframeWindow, { title: calendarName, calendar: calendarName });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Assert presence in view.
+ await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ // Verify in file.
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ let cstream = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance(
+ Ci.nsIConverterInputStream
+ );
+
+ // Wait a moment until file is written.
+ await TestUtils.waitForCondition(() => calendarFile.exists());
+
+ // Read the calendar file and check for the summary.
+ fstream.init(calendarFile, -1, 0, 0);
+ cstream.init(fstream, "UTF-8", 0, 0);
+
+ let str = {};
+ cstream.readString(-1, str);
+ cstream.close();
+
+ Assert.ok(str.value.includes("SUMMARY:" + calendarName));
+});
+
+registerCleanupFunction(() => {
+ for (let calendar of cal.manager.getCalendars()) {
+ if (calendar.name == calendarName) {
+ cal.manager.removeCalendar(calendar);
+ }
+ }
+});
diff --git a/comm/calendar/test/browser/browser_tabs.js b/comm/calendar/test/browser/browser_tabs.js
new file mode 100644
index 0000000000..950b98a68c
--- /dev/null
+++ b/comm/calendar/test/browser/browser_tabs.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ // Test the calendar tab opens and closes.
+ await CalendarTestUtils.openCalendarTab(window);
+ await CalendarTestUtils.closeCalendarTab(window);
+
+ // Test the tasks tab opens and closes.
+ await openTasksTab();
+ await closeTasksTab();
+
+ // Test the calendar and tasks tabs at the same time.
+ await CalendarTestUtils.openCalendarTab(window);
+ await openTasksTab();
+ await CalendarTestUtils.closeCalendarTab(window);
+ await closeTasksTab();
+
+ // Test calendar view selection.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/browser_taskDelete.js b/comm/calendar/test/browser/browser_taskDelete.js
new file mode 100644
index 0000000000..dcbf7ab057
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskDelete.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for deleting tasks in the task view.
+ */
+const { mailTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MailTestUtils.jsm"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+let calendar = CalendarTestUtils.createCalendar("Task Delete Test", "memory");
+registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+/**
+ * Test ensures its possible to delete a task in the task view. Creates two task
+ * and deletes one.
+ */
+add_task(async function testTaskDeletion() {
+ let task1 = new CalTodo();
+ task1.id = "1";
+ task1.title = "Task 1";
+ task1.entryDate = cal.createDateTime("20210126T000001Z");
+
+ let task2 = new CalTodo();
+ task2.id = "2";
+ task2.title = "Task 2";
+ task2.entryDate = cal.createDateTime("20210127T000001Z");
+
+ await calendar.addItem(task1);
+ await calendar.addItem(task2);
+ await openTasksTab();
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let radio = document.querySelector("#opt_next7days_filter");
+ let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh");
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ tree.refresh();
+
+ await waitForRefresh;
+ Assert.equal(tree.view.rowCount, 2, "2 tasks are displayed");
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 1;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 1`);
+
+ let result = await calendar.getItem(task1.id);
+ Assert.ok(!result, "first task was deleted successfully");
+
+ result = await calendar.getItem(task2.id);
+ Assert.ok(result, "second task was not deleted");
+ await calendar.deleteItem(task2);
+ await closeTasksTab();
+});
+
+/**
+ * Test ensures it is possible to delete a recurring task from the task view.
+ * See bug 1688708.
+ */
+add_task(async function testRecurringTaskDeletion() {
+ let repeatTask = new CalTodo();
+ repeatTask.id = "1";
+ repeatTask.title = "Repeating Task";
+ repeatTask.entryDate = cal.createDateTime("20210125T000001Z");
+ repeatTask.recurrenceInfo = new CalRecurrenceInfo(repeatTask);
+ repeatTask.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")
+ );
+
+ let nonRepeatTask = new CalTodo();
+ nonRepeatTask.id = "2";
+ nonRepeatTask.title = "Non-Repeating Task";
+ nonRepeatTask.entryDate = cal.createDateTime("20210126T000001Z");
+
+ repeatTask = await calendar.addItem(repeatTask);
+ nonRepeatTask = await calendar.addItem(nonRepeatTask);
+
+ await openTasksTab();
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let radio = document.querySelector("#opt_next7days_filter");
+ let waitForRefresh = BrowserTestUtils.waitForEvent(tree, "refresh");
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ tree.refresh();
+
+ await waitForRefresh;
+ Assert.equal(tree.view.rowCount, 4, "4 tasks are displayed");
+
+ // Delete a single occurrence.
+ let handleSingleDelete = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ async callback(win) {
+ let dialog = win.document.querySelector("dialog");
+ let button = dialog.querySelector("#accept-occurrence-button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+ },
+ }
+ );
+ mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await handleSingleDelete;
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 3;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 3`);
+
+ repeatTask = await calendar.getItem(repeatTask.id);
+
+ Assert.equal(
+ repeatTask.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210126T000001Z"),
+ cal.createDateTime("20210126T000001Z"),
+ 10
+ ).length,
+ 0,
+ "a single occurrence was deleted successfully"
+ );
+
+ Assert.equal(
+ repeatTask.recurrenceInfo.getOccurrences(
+ repeatTask.entryDate,
+ cal.createDateTime("20210131T000001Z"),
+ 10
+ ).length,
+ 2,
+ "other occurrences were not removed"
+ );
+
+ // Delete all occurrences
+ let handleAllDelete = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-occurrence-prompt.xhtml",
+ {
+ async callback(win) {
+ let dialog = win.document.querySelector("dialog");
+ let button = dialog.querySelector("#accept-parent-button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+ },
+ }
+ );
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 1, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await handleAllDelete;
+
+ // Try and trigger a reflow
+ tree.getBoundingClientRect();
+ tree.invalidate();
+ await new Promise(r => setTimeout(r));
+
+ await TestUtils.waitForCondition(() => {
+ tree = document.querySelector("#calendar-task-tree");
+ return tree.view.rowCount == 1;
+ }, `task view displays ${tree.view.rowCount} tasks instead of 1`);
+
+ repeatTask = await calendar.getItem(repeatTask.id);
+ Assert.ok(!repeatTask, "all occurrences were removed");
+
+ let result = await calendar.getItem(nonRepeatTask.id);
+ Assert.ok(result, "non-recurring task was not deleted");
+ await closeTasksTab();
+});
diff --git a/comm/calendar/test/browser/browser_taskDisplay.js b/comm/calendar/test/browser/browser_taskDisplay.js
new file mode 100644
index 0000000000..1ccd0dac3f
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskDisplay.js
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+var calendar = CalendarTestUtils.createCalendar();
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+let tree = document.getElementById("calendar-task-tree");
+
+add_task(async () => {
+ async function createTask(title, attributes = {}) {
+ let task = new CalTodo();
+ task.title = title;
+ for (let [key, value] of Object.entries(attributes)) {
+ task[key] = value;
+ }
+ return calendar.addItem(task);
+ }
+
+ function treeRefresh() {
+ return BrowserTestUtils.waitForEvent(tree, "refresh");
+ }
+
+ async function setFilterGroup(name) {
+ info(`Setting filter to ${name}`);
+ let radio = document.getElementById(`opt_${name}_filter`);
+ EventUtils.synthesizeMouseAtCenter(radio, {});
+ await treeRefresh();
+ Assert.equal(
+ document.getElementById("calendar-task-tree").getAttribute("filterValue"),
+ radio.value,
+ "Filter group changed"
+ );
+ }
+
+ async function setFilterText(text) {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {});
+ EventUtils.sendString(text);
+ Assert.equal(document.getElementById("task-text-filter-field").value, text, "Filter text set");
+ await treeRefresh();
+ }
+
+ async function clearFilterText() {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-text-filter-field"), {});
+ EventUtils.synthesizeKey("VK_ESCAPE");
+ Assert.equal(
+ document.getElementById("task-text-filter-field").value,
+ "",
+ "Filter text cleared"
+ );
+ await treeRefresh();
+ }
+
+ async function checkVisibleTasks(...expectedTasks) {
+ function toPrettyString(task) {
+ if (task.recurrenceId) {
+ return `${task.title}#${task.recurrenceId}`;
+ }
+ return task.title;
+ }
+ tree.getBoundingClientRect(); // Try and trigger a reflow...
+ tree.invalidate();
+
+ // It seems that under certain conditions notifyOperationComplete() is
+ // called in CalStorageCalender.getItems before all the results have been
+ // retrieved. This results in the "refresh" event being fired prematurely in
+ // calendar-task-tree. After some investigation, the cause of this seems to
+ // be related to multiple calls of executeAsync() in CalStorageItemModel.
+ // getAdditionalDataForItemMap() not finishing before notifyOperationComplete()
+ // is called despite being awaited on.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 500));
+
+ let actualTasks = [];
+ for (let i = 0; i < tree.view.rowCount; i++) {
+ actualTasks.push(tree.getTaskAtRow(i));
+ }
+ info("Expected: " + expectedTasks.map(toPrettyString).join(", "));
+ info("Actual: " + actualTasks.map(toPrettyString).join(", "));
+
+ Assert.equal(tree.view.rowCount, expectedTasks.length, "Correct number of tasks");
+ await new Promise(r => setTimeout(r));
+
+ // Although the order of expectedTasks matches the observed behaviour when
+ // this test was written, order is NOT checked here. The order of the list
+ // is not well defined (particularly when changing the filter text).
+ for (let aTask of actualTasks) {
+ Assert.ok(
+ expectedTasks.some(eTask => eTask.hasSameIds(aTask)),
+ toPrettyString(aTask)
+ );
+ }
+ }
+
+ let today = cal.dtz.now();
+ today.hour = today.minute = today.second = 0;
+ let yesterday = today.clone();
+ yesterday.addDuration(cal.createDuration("-P1D"));
+ let tomorrow = today.clone();
+ tomorrow.addDuration(cal.createDuration("P1D"));
+ let later = today.clone();
+ later.addDuration(cal.createDuration("P2W"));
+
+ let tasks = {
+ incomplete: await createTask("Incomplete"),
+ started30: await createTask("30% started", { percentComplete: 30 }),
+ started60: await createTask("60% started", { percentComplete: 60 }),
+ complete: await createTask("Complete", { isCompleted: true }),
+ overdue: await createTask("Overdue", { dueDate: yesterday }),
+ startsToday: await createTask("Starts today", { entryDate: today }),
+ startsTomorrow: await createTask("Starts tomorrow", { entryDate: tomorrow }),
+ startsLater: await createTask("Starts later", { entryDate: later }),
+ };
+
+ let repeatingTask = new CalTodo();
+ repeatingTask.title = "Repeating";
+ repeatingTask.entryDate = yesterday;
+ repeatingTask.recurrenceInfo = new CalRecurrenceInfo(repeatingTask);
+ repeatingTask.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3")
+ );
+
+ let firstOccurrence = repeatingTask.recurrenceInfo.getOccurrenceFor(yesterday);
+ firstOccurrence.isCompleted = true;
+ firstOccurrence.completedDate = yesterday;
+ repeatingTask.recurrenceInfo.modifyException(firstOccurrence, true);
+
+ repeatingTask = await calendar.addItem(repeatingTask);
+
+ let occurrences = repeatingTask.recurrenceInfo.getOccurrences(yesterday, later, 10);
+ Assert.equal(occurrences.length, 3);
+
+ await openTasksTab();
+
+ await setFilterGroup("all");
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ repeatingTask
+ );
+
+ await setFilterGroup("open");
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ occurrences[1],
+ occurrences[2]
+ );
+
+ await setFilterGroup("completed");
+ await checkVisibleTasks(tasks.complete, occurrences[0]);
+
+ await setFilterGroup("overdue");
+ await checkVisibleTasks(tasks.overdue);
+
+ await setFilterGroup("notstarted");
+ await checkVisibleTasks(tasks.overdue, tasks.incomplete, tasks.startsToday, occurrences[1]);
+
+ await setFilterGroup("next7days");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.startsTomorrow,
+ occurrences[1],
+ occurrences[2]
+ );
+
+ await setFilterGroup("today");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ occurrences[1]
+ );
+
+ await setFilterGroup("throughcurrent");
+ await checkVisibleTasks(
+ tasks.overdue,
+ tasks.incomplete,
+ tasks.startsToday,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ occurrences[1]
+ );
+
+ await setFilterText("No matches");
+ await checkVisibleTasks();
+
+ await clearFilterText();
+ await checkVisibleTasks(
+ tasks.incomplete,
+ tasks.started30,
+ tasks.started60,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ occurrences[1]
+ );
+
+ await setFilterText("StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("today");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("next7days");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("notstarted");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("overdue");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("completed");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks();
+
+ await setFilterGroup("open");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await setFilterGroup("all");
+ Assert.equal(document.getElementById("task-text-filter-field").value, "StArTeD");
+ await checkVisibleTasks(tasks.started30, tasks.started60);
+
+ await clearFilterText();
+ await checkVisibleTasks(
+ tasks.started30,
+ tasks.started60,
+ tasks.incomplete,
+ tasks.complete,
+ tasks.overdue,
+ tasks.startsToday,
+ tasks.startsTomorrow,
+ tasks.startsLater,
+ repeatingTask
+ );
+
+ for (let task of Object.values(tasks)) {
+ await calendar.deleteItem(task);
+ }
+ await setFilterGroup("throughcurrent");
+});
diff --git a/comm/calendar/test/browser/browser_taskUndoRedo.js b/comm/calendar/test/browser/browser_taskUndoRedo.js
new file mode 100644
index 0000000000..09cf9a8de2
--- /dev/null
+++ b/comm/calendar/test/browser/browser_taskUndoRedo.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Tests for ensuring the undo/redo options are enabled properly when
+ * manipulating tasks.
+ */
+
+var { mailTestUtils } = ChromeUtils.import("resource://testing-common/mailnews/MailTestUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalTodo: "resource:///modules/CalTodo.jsm",
+ CalTransactionManager: "resource:///modules/CalTransactionManager.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Undo Redo Test", "memory");
+const calTransManager = CalTransactionManager.getInstance();
+
+/**
+ * Checks the value of the "disabled" property for items in either the "Edit"
+ * menu bar or the app menu. Display of the relevant menu is triggered first so
+ * the UI code can update the respective items.
+ *
+ * @param {XULElement} element - The menu item we want to check, if its id begins
+ * with "menu" then we assume it is in the menu
+ * bar, if "appmenu" then the app menu.
+ */
+async function isDisabled(element) {
+ let targetMenu = document.getElementById("menu_EditPopup");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(targetMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("menu_Edit"), {});
+ await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(targetMenu, "popuphidden");
+ let status = element.disabled;
+ EventUtils.synthesizeKey("VK_ESCAPE");
+ await hiddenPromise;
+ return status;
+}
+
+/**
+ * Removes CalTransaction items from the CalTransactionManager stacks so other
+ * tests are unhindered.
+ */
+function clearTransactions() {
+ calTransManager.undoStack = [];
+ calTransManager.redoStack = [];
+}
+
+/**
+ * Test the undo/redo functionality for task creation.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function taskAddUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let newBtn = document.getElementById("sidePanelNewTask");
+ let windowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newBtn, {});
+
+ let win = await windowPromise;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "New Task" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+
+ Assert.equal(tree.view.rowCount, 1);
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 0,
+ `${undoId} did not remove task in time`
+ );
+ Assert.equal(tree.view.rowCount, 0, `#${undoId} reverses task creation`);
+
+ // Test redo.
+ redo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 1,
+ `${redoId} did not re-create task in time`
+ );
+
+ let task = tree.getTaskAtRow(0);
+ Assert.equal(task.title, "New Task", `#${redoId} redos task creation`);
+ await calendar.deleteItem(task);
+ clearTransactions();
+}
+
+/**
+ * Test the undo/redo functionality for task modification.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testModifyUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let task = new CalTodo();
+ task.title = "Modifiable Task";
+ task.entryDate = cal.dtz.now();
+ await calendar.addItem(task);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+
+ let windowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 2 });
+
+ let win = await windowPromise;
+ let iframeWin = win.document.getElementById("calendar-item-panel-iframe").contentWindow;
+ await CalendarTestUtils.items.setData(win, iframeWin, { title: "Modified Task" });
+ await CalendarTestUtils.items.saveAndCloseItemDialog(win);
+
+ Assert.equal(tree.getTaskAtRow(0).title, "Modified Task");
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(
+ tree.getTaskAtRow(0).title,
+ "Modifiable Task",
+ `#${undoId} reverses task modification`
+ );
+
+ // Test redo.
+ redo.doCommand();
+ refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(tree.getTaskAtRow(0).title, "Modified Task", `#${redoId} redos task modification`);
+
+ clearTransactions();
+ await calendar.deleteItem(tree.getTaskAtRow(0));
+}
+
+/**
+ * Test the undo/redo functionality for task deletion.
+ *
+ * @param {string} undoId - The id of the "undo" menu item.
+ * @param {string} redoId - The id of the "redo" menu item.
+ */
+async function testDeleteUndoRedoTask(undoId, redoId) {
+ let undo = document.getElementById(undoId);
+ let redo = document.getElementById(redoId);
+ Assert.ok(await isDisabled(undo), `#${undoId} is disabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ let task = new CalTodo();
+ task.title = "Deletable Task";
+ task.startDate = cal.dtz.now();
+ task.entryDate = cal.dtz.now();
+ await calendar.addItem(task);
+
+ let tree = document.querySelector("#calendar-task-tree");
+ let refreshPromise = BrowserTestUtils.waitForEvent(tree, "refresh");
+ tree.refresh();
+ await refreshPromise;
+ Assert.equal(tree.view.rowCount, 1);
+
+ mailTestUtils.treeClick(EventUtils, window, tree, 0, 1, { clickCount: 1 });
+ EventUtils.synthesizeKey("VK_DELETE");
+ await TestUtils.waitForCondition(() => tree.view.rowCount == 0, "task was not removed in time");
+
+ Assert.ok(!(await isDisabled(undo)), `#${undoId} is enabled`);
+ Assert.ok(await isDisabled(redo), `#${redoId} is disabled`);
+
+ // Test undo.
+ undo.doCommand();
+ tree.refresh();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 1,
+ "undo did not restore task in time"
+ );
+ Assert.equal(tree.getTaskAtRow(0).title, "Deletable Task", `#${undoId} reverses item deletion`);
+
+ // Test redo.
+ redo.doCommand();
+ await TestUtils.waitForCondition(
+ () => tree.view.rowCount == 0,
+ `#${redoId} redo did not delete item in time`
+ );
+ Assert.ok(!tree.getTaskAtRow(0), `#${redoId} redos item deletion`);
+
+ clearTransactions();
+}
+
+/**
+ * Ensure the menu bar is visible and navigate to the task view.
+ */
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ clearTransactions();
+ document.getElementById("toolbar-menubar").setAttribute("autohide", null);
+ await openTasksTab();
+});
+
+/**
+ * Tests the menu bar's undo/redo after adding an event.
+ */
+add_task(async function testMenuBarAddTaskUndoRedo() {
+ return taskAddUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after modifying an event.
+ */
+add_task(async function testMenuBarModifyTaskUndoRedo() {
+ return testModifyUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
+
+/**
+ * Tests the menu bar's undo/redo after deleting an event.
+ */
+add_task(async function testMenuBarDeleteTaskUndoRedo() {
+ return testDeleteUndoRedoTask("menu_undo", "menu_redo");
+}).__skipMe = AppConstants.platform == "macosx"; // Can't click menu bar on Mac.
diff --git a/comm/calendar/test/browser/browser_todayPane.js b/comm/calendar/test/browser/browser_todayPane.js
new file mode 100644
index 0000000000..8ad9141815
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane.js
@@ -0,0 +1,820 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals TodayPane */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { formatDate, formatTime } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+let calendar = CalendarTestUtils.createCalendar();
+Services.prefs.setIntPref("calendar.agenda.days", 7);
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.agenda.days");
+});
+
+let today = cal.dtz.now();
+let startHour = today.hour;
+today.hour = today.minute = today.second = 0;
+
+let todayPanePanel = document.getElementById("today-pane-panel");
+let todayPaneStatusButton = document.getElementById("calendar-status-todaypane-button");
+
+// Go to mail tab.
+selectFolderTab();
+
+// Verify today pane open.
+if (todayPanePanel.hasAttribute("collapsed")) {
+ EventUtils.synthesizeMouseAtCenter(todayPaneStatusButton, {});
+}
+Assert.ok(!todayPanePanel.hasAttribute("collapsed"), "Today Pane is open");
+
+// Verify today pane's date.
+Assert.equal(document.getElementById("datevalue-label").value, today.day, "Today Pane shows today");
+
+async function addEvent(title, relativeStart, relativeEnd, isAllDay) {
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = title;
+ event.startDate = today.clone();
+ event.startDate.addDuration(cal.createDuration(relativeStart));
+ event.startDate.isDate = isAllDay;
+ event.endDate = today.clone();
+ event.endDate.addDuration(cal.createDuration(relativeEnd));
+ event.endDate.isDate = isAllDay;
+ return calendar.addItem(event);
+}
+
+function checkEvent(row, { dateHeader, time, title, relative, overlap, classes = [] }) {
+ let dateHeaderElement = row.querySelector(".agenda-date-header");
+ if (dateHeader) {
+ Assert.ok(BrowserTestUtils.is_visible(dateHeaderElement), "date header is visible");
+ if (dateHeader instanceof CalDateTime || dateHeader instanceof Ci.calIDateTime) {
+ dateHeader = cal.dtz.formatter.formatDateLongWithoutYear(dateHeader);
+ }
+ Assert.equal(dateHeaderElement.textContent, dateHeader, "date header has correct value");
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(dateHeaderElement), "date header is hidden");
+ }
+
+ let calendarElement = row.querySelector(".agenda-listitem-calendar");
+ let timeElement = row.querySelector(".agenda-listitem-time");
+ if (time) {
+ Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible");
+ Assert.ok(BrowserTestUtils.is_visible(timeElement), "time is visible");
+ if (time instanceof CalDateTime || time instanceof Ci.calIDateTime) {
+ time = cal.dtz.formatter.formatTime(time);
+ }
+ Assert.equal(timeElement.textContent, time, "time has correct value");
+ } else if (time === "") {
+ Assert.ok(BrowserTestUtils.is_visible(calendarElement), "calendar is visible");
+ Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden");
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(calendarElement), "calendar is hidden");
+ Assert.ok(BrowserTestUtils.is_hidden(timeElement), "time is hidden");
+ }
+
+ let titleElement = row.querySelector(".agenda-listitem-title");
+ Assert.ok(BrowserTestUtils.is_visible(titleElement), "title is visible");
+ Assert.equal(titleElement.textContent, title, "title has correct value");
+
+ let relativeElement = row.querySelector(".agenda-listitem-relative");
+ if (Array.isArray(relative)) {
+ Assert.ok(BrowserTestUtils.is_visible(relativeElement), "relative time is visible");
+ Assert.report(
+ !relative.includes(relativeElement.textContent),
+ relative,
+ relativeElement.textContent,
+ "relative time is correct",
+ "includes"
+ );
+ } else if (relative !== undefined) {
+ Assert.ok(BrowserTestUtils.is_hidden(relativeElement), "relative time is hidden");
+ }
+
+ let overlapElement = row.querySelector(".agenda-listitem-overlap");
+ if (overlap) {
+ Assert.ok(BrowserTestUtils.is_visible(overlapElement), "overlap is visible");
+ Assert.equal(
+ overlapElement.src,
+ `chrome://messenger/skin/icons/new/event-${overlap}.svg`,
+ "overlap has correct image"
+ );
+ Assert.equal(
+ overlapElement.dataset.l10nId,
+ `calendar-editable-item-multiday-event-icon-${overlap}`,
+ "overlap has correct alt text"
+ );
+ } else {
+ Assert.ok(BrowserTestUtils.is_hidden(overlapElement), "overlap is hidden");
+ }
+
+ for (let className of classes) {
+ Assert.ok(row.classList.contains(className), `row has ${className} class`);
+ }
+}
+
+function checkEvents(...expectedEvents) {
+ Assert.equal(TodayPane.agenda.rowCount, expectedEvents.length, "expected number of rows shown");
+ for (let i = 0; i < expectedEvents.length; i++) {
+ Assert.ok(TodayPane.agenda.rows[i].getAttribute("is"), "agenda-listitem");
+ checkEvent(TodayPane.agenda.rows[i], expectedEvents[i]);
+ }
+}
+
+add_task(async function testBasicAllDay() {
+ let todaysEvent = await addEvent("Today's Event", "P0D", "P1D", true);
+ checkEvents({ dateHeader: "Today", title: "Today's Event" });
+
+ let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Today's Event" },
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" }
+ );
+
+ let events = [];
+ for (let i = 2; i < 7; i++) {
+ events.push(await addEvent(`Event ${i + 1}`, `P${i}D`, `P${i + 1}D`, true));
+ checkEvents(
+ { dateHeader: "Today", title: "Today's Event" },
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ }
+
+ await calendar.deleteItem(todaysEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ await calendar.deleteItem(tomorrowsEvent);
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+
+ while (events.length) {
+ await calendar.deleteItem(events.shift());
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, title: e.title };
+ })
+ );
+ }
+});
+
+add_task(async function testBasic() {
+ let time = today.clone();
+ time.hour = 23;
+
+ let todaysEvent = await addEvent("Today's Event", "P0DT23H", "P1D");
+ checkEvents({ dateHeader: "Today", time, title: "Today's Event" });
+
+ let tomorrowsEvent = await addEvent("Tomorrow's Event", "P1DT23H", "P2D");
+ checkEvents(
+ { dateHeader: "Today", time, title: "Today's Event" },
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" }
+ );
+
+ let events = [];
+ for (let i = 2; i < 7; i++) {
+ events.push(await addEvent(`Event ${i + 1}`, `P${i}DT23H`, `P${i + 1}D`));
+ checkEvents(
+ { dateHeader: "Today", time, title: "Today's Event" },
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ }
+
+ await calendar.deleteItem(todaysEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", time, title: "Tomorrow's Event" },
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ await calendar.deleteItem(tomorrowsEvent);
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+
+ while (events.length) {
+ await calendar.deleteItem(events.shift());
+ checkEvents(
+ ...events.map(e => {
+ return { dateHeader: e.startDate, time, title: e.title };
+ })
+ );
+ }
+});
+
+/**
+ * Adds and removes events in a different order from which they occur.
+ * This checks that the events are inserted in the right place, and that the
+ * date header is shown/hidden appropriately.
+ */
+add_task(async function testSortOrder() {
+ let afternoonEvent = await addEvent("Afternoon Event", "P1DT13H", "P1DT17H");
+ checkEvents({
+ dateHeader: "Tomorrow",
+ time: afternoonEvent.startDate,
+ title: "Afternoon Event",
+ });
+
+ let morningEvent = await addEvent("Morning Event", "P1DT8H", "P1DT12H");
+ checkEvents(
+ { dateHeader: "Tomorrow", time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" }
+ );
+
+ let allDayEvent = await addEvent("All Day Event", "P1D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" }
+ );
+
+ let eveningEvent = await addEvent("Evening Event", "P1DT18H", "P1DT22H");
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: afternoonEvent.startDate, title: "Afternoon Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(afternoonEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: morningEvent.startDate, title: "Morning Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(morningEvent);
+ checkEvents(
+ { dateHeader: "Tomorrow", title: "All Day Event" },
+ { time: eveningEvent.startDate, title: "Evening Event" }
+ );
+
+ await calendar.deleteItem(allDayEvent);
+ checkEvents({
+ dateHeader: "Tomorrow",
+ time: eveningEvent.startDate,
+ title: "Evening Event",
+ });
+
+ await calendar.deleteItem(eveningEvent);
+ checkEvents();
+});
+
+/**
+ * Check events that begin and end on different days inside the date range.
+ * All-day events are still sorted ahead of non-all-day events.
+ */
+add_task(async function testOverlapInside() {
+ let allDayEvent = await addEvent("All Day Event", "P0D", "P2D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "All Day Event", overlap: "start" },
+ { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" }
+ );
+
+ let timedEvent = await addEvent("Timed Event", "P1H", "P1D23H");
+ checkEvents(
+ { dateHeader: "Today", title: "All Day Event", overlap: "start" },
+ { time: timedEvent.startDate, title: "Timed Event", overlap: "start" },
+ { dateHeader: "Tomorrow", title: "All Day Event", overlap: "end" },
+ { time: timedEvent.endDate, title: "Timed Event", overlap: "end" }
+ );
+
+ await calendar.deleteItem(allDayEvent);
+ await calendar.deleteItem(timedEvent);
+});
+
+/**
+ * Check events that begin and end on different days and that end at midnight.
+ * The list item for the end of the event should be the last one on the day
+ * before the end midnight, and its time label should display "24:00".
+ */
+add_task(async function testOverlapEndAtMidnight() {
+ // Start with an event that begins outside the displayed dates.
+
+ let timedEvent = await addEvent("Timed Event", "-P1D", "P1D");
+ // Ends an hour before `timedEvent` to prove the ordering is correct.
+ let duringEvent = await addEvent("During Event", "P22H", "P23H");
+ // Starts at the same time as `timedEvent` ends to prove the ordering is correct.
+ let nextEvent = await addEvent("Next Event", "P1D", "P2D", true);
+
+ checkEvents(
+ { dateHeader: "Today", time: duringEvent.startDate, title: "During Event" },
+ {
+ // Should show "24:00" as the time and end today.
+ time: cal.dtz.formatter.formatTime(timedEvent.endDate, true),
+ title: "Timed Event",
+ overlap: "end",
+ },
+ { dateHeader: "Tomorrow", title: "Next Event" }
+ );
+
+ // Move the event fully into the displayed range.
+
+ let timedClone = timedEvent.clone();
+ timedClone.startDate.day += 2;
+ timedClone.endDate.day += 2;
+ await calendar.modifyItem(timedClone, timedEvent);
+
+ let duringClone = duringEvent.clone();
+ duringClone.startDate.day += 2;
+ duringClone.endDate.day += 2;
+ await calendar.modifyItem(duringClone, duringEvent);
+
+ let nextClone = nextEvent.clone();
+ nextClone.startDate.day += 2;
+ nextClone.endDate.day += 2;
+ await calendar.modifyItem(nextClone, nextEvent);
+
+ let realEndDate = today.clone();
+ realEndDate.day += 2;
+ checkEvents(
+ {
+ dateHeader: "Tomorrow",
+ time: timedClone.startDate,
+ title: "Timed Event",
+ overlap: "start",
+ },
+ { dateHeader: realEndDate, time: duringClone.startDate, title: "During Event" },
+ {
+ // Should show "24:00" as the time and end on the day after tomorrow.
+ time: cal.dtz.formatter.formatTime(timedClone.endDate, true),
+ title: "Timed Event",
+ overlap: "end",
+ },
+ { dateHeader: nextClone.startDate, title: "Next Event" }
+ );
+
+ await calendar.deleteItem(timedClone);
+ await calendar.deleteItem(duringClone);
+ await calendar.deleteItem(nextClone);
+});
+
+/**
+ * Check events that begin and/or end outside the date range. Events that have
+ * already started are listed as "Today", but still sorted by start time.
+ * All-day events are still sorted ahead of non-all-day events.
+ */
+add_task(async function testOverlapOutside() {
+ let before = await addEvent("Starts Before", "-P1D", "P1D", true);
+ checkEvents({ dateHeader: "Today", title: "Starts Before", overlap: "end" });
+
+ let after = await addEvent("Ends After", "P0D", "P9D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ let both = await addEvent("Beyond Start and End", "-P2D", "P9D", true);
+ checkEvents(
+ { dateHeader: "Today", title: "Beyond Start and End", overlap: "continue" },
+ { title: "Starts Before", overlap: "end" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ // Change `before` to begin earlier than `both`. They should swap places.
+
+ let startClone = before.clone();
+ startClone.startDate.day -= 2;
+ await calendar.modifyItem(startClone, before);
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" }
+ );
+
+ let beforeWithTime = await addEvent("Starts Before with time", "-PT5H", "PT15H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ let afterWithTime = await addEvent("Ends After with time", "PT6H", "P8DT12H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ let bothWithTime = await addEvent("Beyond Start and End with time", "-P2DT10H", "P9DT1H");
+ checkEvents(
+ { dateHeader: "Today", title: "Starts Before", overlap: "end" },
+ { title: "Beyond Start and End", overlap: "continue" },
+ { title: "Ends After", overlap: "start" },
+ { time: "", title: "Beyond Start and End with time", overlap: "continue" },
+ { time: afterWithTime.startDate, title: "Ends After with time", overlap: "start" },
+ // This is the end of the event so the end time is used.
+ { time: beforeWithTime.endDate, title: "Starts Before with time", overlap: "end" }
+ );
+
+ await calendar.deleteItem(before);
+ await calendar.deleteItem(after);
+ await calendar.deleteItem(both);
+ await calendar.deleteItem(beforeWithTime);
+ await calendar.deleteItem(afterWithTime);
+ await calendar.deleteItem(bothWithTime);
+});
+
+/**
+ * Checks that events that happened earlier today are marked as in the past,
+ * and events happening now are marked as such.
+ *
+ * This test may fail if run within a minute either side of midnight.
+ *
+ * It would be nice to test that as time passes events are changed
+ * appropriately, but that means waiting around for minutes and probably won't
+ * be very reliable, so we don't do that.
+ */
+add_task(async function testActive() {
+ let now = cal.dtz.now();
+
+ let pastEvent = await addEvent("Past Event", "PT0M", "PT1M");
+ let presentEvent = await addEvent("Present Event", `PT${now.hour}H`, `PT${now.hour + 1}H`);
+ let futureEvent = await addEvent("Future Event", "PT23H59M", "PT24H");
+ checkEvents(
+ { dateHeader: "Today", time: pastEvent.startDate, title: "Past Event" },
+ { time: presentEvent.startDate, title: "Present Event" },
+ { time: futureEvent.startDate, title: "Future Event" }
+ );
+
+ let [pastRow, presentRow, futureRow] = TodayPane.agenda.rows;
+ Assert.ok(pastRow.classList.contains("agenda-listitem-past"), "past event is marked past");
+ Assert.ok(!pastRow.classList.contains("agenda-listitem-now"), "past event is not marked now");
+ Assert.ok(
+ !presentRow.classList.contains("agenda-listitem-past"),
+ "present event is not marked past"
+ );
+ Assert.ok(presentRow.classList.contains("agenda-listitem-now"), "present event is marked now");
+ Assert.ok(
+ !futureRow.classList.contains("agenda-listitem-past"),
+ "future event is not marked past"
+ );
+ Assert.ok(!futureRow.classList.contains("agenda-listitem-now"), "future event is not marked now");
+
+ await calendar.deleteItem(pastEvent);
+ await calendar.deleteItem(presentEvent);
+ await calendar.deleteItem(futureEvent);
+});
+
+/**
+ * Checks events in different time zones are displayed correctly.
+ */
+add_task(async function testOtherTimeZones() {
+ // Johannesburg is UTC+2.
+ let johannesburg = cal.timezoneService.getTimezone("Africa/Johannesburg");
+ // Panama is UTC-5.
+ let panama = cal.timezoneService.getTimezone("America/Panama");
+
+ // All-day events are displayed on the day of the event, the time zone is ignored.
+
+ let allDayEvent = new CalEvent();
+ allDayEvent.id = cal.getUUID();
+ allDayEvent.title = "All-day event in Johannesburg";
+ allDayEvent.startDate = cal.createDateTime();
+ allDayEvent.startDate.resetTo(today.year, today.month, today.day + 1, 0, 0, 0, johannesburg);
+ allDayEvent.startDate.isDate = true;
+ allDayEvent.endDate = cal.createDateTime();
+ allDayEvent.endDate.resetTo(today.year, today.month, today.day + 2, 0, 0, 0, johannesburg);
+ allDayEvent.endDate.isDate = true;
+ allDayEvent = await calendar.addItem(allDayEvent);
+
+ checkEvents({
+ dateHeader: "Tomorrow",
+ title: "All-day event in Johannesburg",
+ });
+
+ await calendar.deleteItem(allDayEvent);
+
+ // The event time must be displayed in the local time zone, and the event must be sorted correctly.
+
+ let beforeEvent = await addEvent("Before", "P1DT5H", "P1DT6H");
+ let afterEvent = await addEvent("After", "P1DT7H", "P1DT8H");
+
+ let timedEvent = new CalEvent();
+ timedEvent.id = cal.getUUID();
+ timedEvent.title = "Morning in Johannesburg";
+ timedEvent.startDate = cal.createDateTime();
+ timedEvent.startDate.resetTo(today.year, today.month, today.day + 1, 8, 0, 0, johannesburg);
+ timedEvent.endDate = cal.createDateTime();
+ timedEvent.endDate.resetTo(today.year, today.month, today.day + 1, 12, 0, 0, johannesburg);
+ timedEvent = await calendar.addItem(timedEvent);
+
+ checkEvents(
+ {
+ dateHeader: "Tomorrow",
+ time: beforeEvent.startDate,
+ title: "Before",
+ },
+ {
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T060000Z")), // The date used here is irrelevant.
+ title: "Morning in Johannesburg",
+ },
+ {
+ time: afterEvent.startDate,
+ title: "After",
+ }
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T08:00:00+02:00"
+ );
+
+ await calendar.deleteItem(beforeEvent);
+ await calendar.deleteItem(afterEvent);
+ await calendar.deleteItem(timedEvent);
+
+ // Events that cross midnight in the local time zone (but not in the event time zone)
+ // must have a start row and an end row.
+
+ let overnightEvent = new CalEvent();
+ overnightEvent.id = cal.getUUID();
+ overnightEvent.title = "Evening in Panama";
+ overnightEvent.startDate = cal.createDateTime();
+ overnightEvent.startDate.resetTo(today.year, today.month, today.day, 17, 0, 0, panama);
+ overnightEvent.endDate = cal.createDateTime();
+ overnightEvent.endDate.resetTo(today.year, today.month, today.day, 23, 0, 0, panama);
+ overnightEvent = await calendar.addItem(overnightEvent);
+
+ checkEvents(
+ {
+ dateHeader: "Today",
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T220000Z")), // The date used here is irrelevant.
+ title: "Evening in Panama",
+ overlap: "start",
+ },
+ {
+ dateHeader: "Tomorrow",
+ time: cal.dtz.formatter.formatTime(cal.createDateTime("20000101T040000Z")), // The date used here is irrelevant.
+ title: "Evening in Panama",
+ overlap: "end",
+ }
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[0].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T17:00:00-05:00"
+ );
+ Assert.stringContains(
+ TodayPane.agenda.rows[1].querySelector(".agenda-listitem-time").getAttribute("datetime"),
+ "T23:00:00-05:00"
+ );
+
+ await calendar.deleteItem(overnightEvent);
+});
+
+/**
+ * Checks events in different time zones are displayed correctly.
+ */
+add_task(async function testRelativeTime() {
+ let formatter = new Intl.RelativeTimeFormat(undefined, { style: "short" });
+ let now = cal.dtz.now();
+ now.second = 0;
+ info(`The time is now ${now}`);
+
+ let testData = [
+ {
+ name: "two hours ago",
+ start: "-PT1H55M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 2,
+ },
+ {
+ name: "one hour ago",
+ start: "-PT1H5M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 2,
+ },
+ {
+ name: "23 minutes ago",
+ start: "-PT23M",
+ expected: {
+ classes: ["agenda-listitem-past"],
+ },
+ minHour: 1,
+ },
+ {
+ name: "now",
+ start: "-PT5M",
+ expected: {
+ relative: ["now"],
+ classes: ["agenda-listitem-now"],
+ },
+ minHour: 1,
+ maxHour: 22,
+ },
+ {
+ name: "19 minutes ahead",
+ start: "PT19M",
+ expected: {
+ relative: [formatter.format(19, "minute"), formatter.format(18, "minute")],
+ },
+ maxHour: 22,
+ },
+ {
+ name: "one hour ahead",
+ start: "PT1H25M",
+ expected: {
+ relative: [formatter.format(85, "minute"), formatter.format(84, "minute")],
+ },
+ maxHour: 21,
+ },
+ {
+ name: "one and half hours ahead",
+ start: "PT1H35M",
+ expected: {
+ relative: [formatter.format(2, "hour")],
+ },
+ maxHour: 21,
+ },
+ {
+ name: "two hours ahead",
+ start: "PT1H49M",
+ expected: {
+ relative: [formatter.format(2, "hour")],
+ },
+ maxHour: 21,
+ },
+ ];
+
+ let events = [];
+ let expectedEvents = [];
+ for (let { name, start, expected, minHour, maxHour } of testData) {
+ if (minHour && now.hour < minHour) {
+ info(`Skipping ${name} because it's too early.`);
+ continue;
+ }
+ if (maxHour && now.hour > maxHour) {
+ info(`Skipping ${name} because it's too late.`);
+ continue;
+ }
+
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = name;
+ event.startDate = now.clone();
+ event.startDate.addDuration(cal.createDuration(start));
+ event.endDate = event.startDate.clone();
+ event.endDate.addDuration(cal.createDuration("PT10M"));
+ events.push(await calendar.addItem(event));
+
+ expectedEvents.push({ ...expected, title: name, time: event.startDate });
+ }
+
+ expectedEvents[0].dateHeader = "Today";
+ checkEvents(...expectedEvents);
+
+ for (let event of events) {
+ await calendar.deleteItem(event);
+ }
+});
+
+/**
+ * Tests the today pane opens events in the summary dialog for both
+ * non-recurring and recurring events.
+ */
+add_task(async function testOpenEvent() {
+ let noRepeatEvent = new CalEvent();
+ noRepeatEvent.id = "no repeat event";
+ noRepeatEvent.title = "No Repeat Event";
+ noRepeatEvent.startDate = today.clone();
+ noRepeatEvent.startDate.hour = startHour;
+ noRepeatEvent.endDate = noRepeatEvent.startDate.clone();
+ noRepeatEvent.endDate.hour++;
+
+ let repeatEvent = new CalEvent();
+ repeatEvent.id = "repeated event";
+ repeatEvent.title = "Repeated Event";
+ repeatEvent.startDate = today.clone();
+ repeatEvent.startDate.hour = startHour;
+ repeatEvent.endDate = noRepeatEvent.startDate.clone();
+ repeatEvent.endDate.hour++;
+ repeatEvent.recurrenceInfo = new CalRecurrenceInfo(repeatEvent);
+ repeatEvent.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=5")
+ );
+
+ for (let event of [noRepeatEvent, repeatEvent]) {
+ let addedEvent = await calendar.addItem(event);
+
+ if (event == noRepeatEvent) {
+ Assert.equal(TodayPane.agenda.rowCount, 1);
+ } else {
+ Assert.equal(TodayPane.agenda.rowCount, 5);
+ }
+ Assert.equal(
+ TodayPane.agenda.rows[0].querySelector(".agenda-listitem-title").textContent,
+ event.title,
+ "event title is correct"
+ );
+
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog();
+ EventUtils.synthesizeMouseAtCenter(TodayPane.agenda.rows[0], { clickCount: 2 });
+
+ let dialogWindow = await dialogWindowPromise;
+ let docUri = dialogWindow.document.documentURI;
+ Assert.ok(
+ docUri === "chrome://calendar/content/calendar-summary-dialog.xhtml",
+ "event summary dialog shown"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ await calendar.deleteItem(addedEvent);
+ }
+});
+
+/**
+ * Tests that the "New Event" button begins creating an event on the date
+ * selected in the Today Pane.
+ */
+add_task(async function testNewEvent() {
+ async function checkEventDialogDate() {
+ let dialogWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventButton, {}, window);
+ await dialogWindowPromise.then(async function (dialogWindow) {
+ let iframe = dialogWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDocument = iframe.contentDocument;
+
+ let startDate = iframeDocument.getElementById("event-starttime");
+ Assert.equal(
+ startDate._datepicker._inputField.value,
+ formatDate(expectedDate),
+ "date should match the expected date"
+ );
+ Assert.equal(
+ startDate._timepicker._inputField.value,
+ formatTime(expectedDate),
+ "time should be the next hour after now"
+ );
+
+ await BrowserTestUtils.closeWindow(dialogWindow);
+ });
+ }
+
+ let newEventButton = document.getElementById("todaypane-new-event-button");
+
+ // Check today with the "day" view.
+
+ TodayPane.displayMiniSection("miniday");
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("today-button"), {}, window);
+
+ let expectedDate = cal.dtz.now();
+ expectedDate.hour++;
+ expectedDate.minute = 0;
+
+ await checkEventDialogDate();
+
+ // Check tomorrow with the "day" view.
+
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("next-day-button"), {}, window);
+ expectedDate.day++;
+
+ await checkEventDialogDate();
+
+ // Check today with the "month" view;
+
+ TodayPane.displayMiniSection("minimonth");
+ let minimonth = document.getElementById("today-minimonth");
+ minimonth.value = new Date();
+ expectedDate.day--;
+
+ await checkEventDialogDate();
+
+ // Check a date in the past with the "month" view;
+
+ minimonth.value = new Date(Date.UTC(2018, 8, 1));
+ expectedDate.resetTo(2018, 8, 1, expectedDate.hour, 0, 0, cal.dtz.UTC);
+
+ await checkEventDialogDate();
+}).__skipMe = new Date().getUTCHours() == 23;
diff --git a/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js
new file mode 100644
index 0000000000..bac91ed60d
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane_dragAndDrop.js
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for drag and drop on the today pane.
+ */
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+const {
+ add_message_to_folder,
+ be_in_folder,
+ create_folder,
+ create_message,
+ inboxFolder,
+ select_click_row,
+} = ChromeUtils.import("resource://testing-common/mozmill/FolderDisplayHelpers.jsm");
+const { SyntheticPartLeaf } = ChromeUtils.import(
+ "resource://testing-common/mailnews/MessageGenerator.jsm"
+);
+
+const calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+/**
+ * Ensures the today pane is visible for each test.
+ */
+async function ensureTodayPane() {
+ let todayPane = document.querySelector("#today-pane-panel");
+ if (!todayPane.isVisible()) {
+ todayPane.setVisible(true, true, true);
+ }
+
+ await TestUtils.waitForCondition(() => todayPane.isVisible(), "today pane not visible in time");
+}
+
+/**
+ * Tests dropping a message from the message pane on to the today pane brings
+ * up the new event dialog.
+ */
+add_task(async function testDropMozMessage() {
+ let folder = await create_folder("Mochitest");
+ let subject = "The Grand Event";
+ let body = "Parking is available.";
+ await be_in_folder(folder);
+ await add_message_to_folder([folder], create_message({ subject, body: { body } }));
+ select_click_row(0);
+
+ let about3PaneTab = document.getElementById("tabmail").currentTabInfo;
+ let msg = about3PaneTab.message;
+ let msgStr = about3PaneTab.folder.getUriForMsg(msg);
+ let msgUrl = MailServices.messageServiceFromURI(msgStr).getUrlForUri(msgStr);
+
+ // Setup a DataTransfer to mimic what ThreadPaneOnDragStart sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.mozSetDataAt("text/x-moz-message", msgStr, 0);
+ dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, 0);
+ dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise-url",
+ msgUrl.spec + "?fileName=" + encodeURIComponent("message.eml"),
+ 0
+ );
+ dataTransfer.mozSetDataAt(
+ "application/x-moz-file-promise",
+ new window.messageFlavorDataProvider(),
+ 0
+ );
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-title").value,
+ subject,
+ "the message subject was used as the event title"
+ );
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ body,
+ "the message body was used as the event description"
+ );
+
+ await BrowserTestUtils.closeWindow(eventWindow);
+ await be_in_folder(inboxFolder);
+ folder.deleteSelf(null);
+});
+
+/**
+ * Tests dropping an entry from the address book adds the address as an attendee
+ * to a new event when dropped on the today pane.
+ */
+add_task(async function testMozAddressDrop() {
+ let vcard = CalendarTestUtils.dedent`
+ BEGIN:VCARD
+ VERSION:4.0
+ EMAIL;PREF=1:person@example.com
+ FN:Some Person
+ N:Some;Person;;;
+ UID:d5f9113d-5ede-4a5c-ba8e-0f2345369993
+ END:VCARD
+ `;
+
+ let address = "Some Person <person@example.com>";
+
+ // Setup a DataTransfer to mimic what the address book sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("moz/abcard", "0");
+ dataTransfer.setData("text/x-moz-address", address);
+ dataTransfer.setData("text/plain", address);
+ dataTransfer.setData("text/vcard", decodeURIComponent(vcard));
+ dataTransfer.setData("application/x-moz-file-promise-dest-filename", "person.vcf");
+ dataTransfer.setData("application/x-moz-file-promise-url", "data:text/vcard," + vcard);
+ dataTransfer.setData("application/x-moz-file-promise", window.abFlavorDataProvider);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeWin = iframe.cotnentWindow;
+ let iframeDoc = iframe.contentDocument;
+
+ // Verify the address was added as an attendee.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDoc.querySelector("#event-grid-tab-attendees"),
+ {},
+ iframeWin
+ );
+
+ let box = iframeDoc.querySelector('[attendeeid="mailto:person@example.com"]');
+ Assert.ok(box, "address included as an attendee to the new event");
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping plain text that is actually ics data format is picked up by
+ * the today pane.
+ */
+add_task(async function testPlainTextICSDrop() {
+ let event = CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ SUMMARY:An Event
+ DESCRIPTION:Parking is not available.
+ DTSTART:20210325T110000Z
+ DTEND:20210325T120000Z
+ UID:916bd967-35ac-40f6-8cd5-487739c9d245
+ END:VEVENT
+ END:VCALENDAR
+ `;
+
+ // Setup a DataTransfer to mimic what the address book sends.
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("text/plain", event);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+ Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event");
+
+ let startTime = iframeDoc.querySelector("#event-starttime");
+ Assert.equal(
+ startTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z"))
+ );
+
+ let endTime = iframeDoc.querySelector("#event-endtime");
+ Assert.equal(
+ endTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z"))
+ );
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ "Parking is not available."
+ );
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping a file with an ics extension on the today pane is parsed as an
+ * ics file.
+ */
+add_task(async function testICSFileDrop() {
+ let file = await File.createFromFileName(getTestFilePath("data/event.ics"));
+ let dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+
+ // For some reason, dataTransfer.items.add() results in a mozItemCount of 2
+ // instead of one. Call onExternalDrop directly to get around that.
+ window.calendarCalendarButtonDNDObserver.onExternalDrop(dataTransfer);
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeDoc = iframe.contentDocument;
+
+ Assert.equal(iframeDoc.querySelector("#item-title").value, "An Event");
+
+ let startTime = iframeDoc.querySelector("#event-starttime");
+ Assert.equal(
+ startTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T110000Z"))
+ );
+
+ let endTime = iframeDoc.querySelector("#event-endtime");
+ Assert.equal(
+ endTime._datepicker._inputBoxValue,
+ cal.dtz.formatter.formatDateShort(cal.createDateTime("20210325T120000Z"))
+ );
+
+ Assert.equal(
+ iframeDoc.querySelector("#item-description").contentDocument.body.innerText,
+ "Parking is not available."
+ );
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
+
+/**
+ * Tests dropping any other file on the today pane ends up as an attachment
+ * to a new event.
+ */
+add_task(async function testOtherFileDrop() {
+ let file = await File.createFromNsIFile(
+ new FileUtils.File(getTestFilePath("data/attachment.png"))
+ );
+ let dataTransfer = new DataTransfer();
+ dataTransfer.setData("image/png", file);
+ dataTransfer.items.add(file);
+
+ let promise = CalendarTestUtils.waitForEventDialog("edit");
+ await ensureTodayPane();
+ document.querySelector("#agenda").dispatchEvent(new DragEvent("drop", { dataTransfer }));
+
+ let eventWindow = await promise;
+ let iframe = eventWindow.document.querySelector("#calendar-item-panel-iframe");
+ let iframeWin = iframe.contentWindow;
+ let iframeDoc = iframe.contentDocument;
+
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDoc.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWin
+ );
+
+ let listBox = iframeDoc.querySelector("#attachment-link");
+ let listItem = listBox.itemChildren[0];
+ Assert.equal(listItem.querySelector("label").value, "attachment.png");
+ await BrowserTestUtils.closeWindow(eventWindow);
+});
diff --git a/comm/calendar/test/browser/browser_todayPane_visibility.js b/comm/calendar/test/browser/browser_todayPane_visibility.js
new file mode 100644
index 0000000000..d2176218ed
--- /dev/null
+++ b/comm/calendar/test/browser/browser_todayPane_visibility.js
@@ -0,0 +1,167 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAddonsTab, openChatTab, openNewCalendarEventTab,
+ * openNewCalendarTaskTab, openPreferencesTab, openTasksTab,
+ * selectCalendarEventTab, selectCalendarTaskTab, selectFolderTab,
+ * toAddressBook */
+
+// Test that today pane is visible/collapsed correctly for various tab types.
+// In all cases today pane should not be visible in preferences or addons tab.
+// Also test that the today pane button is visible/hidden for various tab types.
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ const todayPane = document.getElementById("today-pane-panel");
+ const todayPaneButton = document.getElementById("calendar-status-todaypane-button");
+
+ let eventTabPanelId, taskTabPanelId;
+
+ async function clickTodayPaneButton() {
+ // The today pane button will be hidden for certain tabs (e.g. preferences), and then
+ // the user won't be able to click it, so we shouldn't be able to here either.
+ if (BrowserTestUtils.is_visible(todayPaneButton)) {
+ EventUtils.synthesizeMouseAtCenter(todayPaneButton, { clickCount: 1 });
+ }
+ await new Promise(resolve => setTimeout(resolve));
+ }
+
+ /**
+ * Tests whether the today pane is only open in certain tabs.
+ *
+ * @param {string[]} tabsWhereVisible - Array of tab mode names for tabs where
+ * the today pane should be visible.
+ */
+ async function checkTodayPaneVisibility(tabsWhereVisible) {
+ function check(tabModeName) {
+ let shouldBeVisible = tabsWhereVisible.includes(tabModeName);
+ is(
+ BrowserTestUtils.is_visible(todayPane),
+ shouldBeVisible,
+ `today pane is ${shouldBeVisible ? "visible" : "collapsed"} in ${tabModeName} tab`
+ );
+ }
+
+ await selectFolderTab();
+ check("folder");
+ await CalendarTestUtils.openCalendarTab(window);
+ check("calendar");
+ await openTasksTab();
+ check("tasks");
+ await openChatTab();
+ check("chat");
+ await selectCalendarEventTab(eventTabPanelId);
+ check("calendarEvent");
+ await selectCalendarTaskTab(taskTabPanelId);
+ check("calendarTask");
+ await toAddressBook();
+ check("addressBookTab");
+ await openPreferencesTab();
+ check("preferencesTab");
+ await openAddonsTab();
+ check("contentTab");
+ }
+
+ // Show today pane in folder (mail) tab, but not in other tabs.
+ await selectFolderTab();
+ if (!BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await CalendarTestUtils.openCalendarTab(window);
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await openTasksTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ await openChatTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ eventTabPanelId = await openNewCalendarEventTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+ taskTabPanelId = await openNewCalendarTaskTab();
+ if (BrowserTestUtils.is_visible(todayPane)) {
+ await clickTodayPaneButton();
+ }
+
+ await checkTodayPaneVisibility(["folder"]);
+
+ // Show today pane in calendar tab, but not in other tabs.
+ // Hide it in folder tab.
+ await selectFolderTab();
+ await clickTodayPaneButton();
+ // Show it in calendar tab.
+ await CalendarTestUtils.openCalendarTab(window);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendar"]);
+
+ // Show today pane in tasks tab, but not in other tabs.
+ // Hide it in calendar tab.
+ await CalendarTestUtils.openCalendarTab(window);
+ await clickTodayPaneButton();
+ // Show it in tasks tab.
+ await openTasksTab();
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["tasks"]);
+
+ // Show today pane in chat tab, but not in other tabs.
+ // Hide it in tasks tab.
+ await openTasksTab();
+ await clickTodayPaneButton();
+ // Show it in chat tab.
+ await openChatTab();
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["chat"]);
+
+ // Show today pane in calendar event tab, but not in other tabs.
+ // Hide it in chat tab.
+ await openChatTab();
+ await clickTodayPaneButton();
+ // Show it in calendar event tab.
+ await selectCalendarEventTab(eventTabPanelId);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendarEvent"]);
+
+ // Show today pane in calendar task tab, but not in other tabs.
+ // Hide it in calendar event tab.
+ await selectCalendarEventTab(eventTabPanelId);
+ await clickTodayPaneButton();
+ // Show it in calendar task tab.
+ await selectCalendarTaskTab(taskTabPanelId);
+ await clickTodayPaneButton();
+
+ await checkTodayPaneVisibility(["calendarTask"]);
+
+ // Check the visibility of the today pane button.
+ const button = document.getElementById("calendar-status-todaypane-button");
+ await selectFolderTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in folder tab");
+ await CalendarTestUtils.openCalendarTab(window);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in calendar tab");
+ await openTasksTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in tasks tab");
+ await openChatTab();
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in chat tab");
+ await selectCalendarEventTab(eventTabPanelId);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in event tab");
+ await selectCalendarTaskTab(taskTabPanelId);
+ ok(BrowserTestUtils.is_visible(button), "today pane button is visible in task tab");
+ await toAddressBook();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in address book tab");
+ await openPreferencesTab();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in preferences tab");
+ await openAddonsTab();
+ is(BrowserTestUtils.is_visible(button), false, "today pane button is hidden in addons tab");
+});
diff --git a/comm/calendar/test/browser/contextMenu/browser.ini b/comm/calendar/test/browser/contextMenu/browser.ini
new file mode 100644
index 0000000000..b6590e849c
--- /dev/null
+++ b/comm/calendar/test/browser/contextMenu/browser.ini
@@ -0,0 +1,14 @@
+[default]
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_edit.js]
diff --git a/comm/calendar/test/browser/contextMenu/browser_edit.js b/comm/calendar/test/browser/contextMenu/browser_edit.js
new file mode 100644
index 0000000000..672055709f
--- /dev/null
+++ b/comm/calendar/test/browser/contextMenu/browser_edit.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+/**
+ * Grabs a calendar-month-day-box-item from the view using an attribute CSS
+ * selector. Only works when the calendar is in month view.
+ */
+async function getDayBoxItem(attrSelector) {
+ let itemBox;
+ await TestUtils.waitForCondition(() => {
+ itemBox = document.querySelector(
+ `calendar-month-day-box[${attrSelector}] calendar-month-day-box-item`
+ );
+ return itemBox != null;
+ }, "calendar item did not appear in time");
+ return itemBox;
+}
+
+/**
+ * Switches to the view to the calendar.
+ */
+add_setup(function () {
+ return CalendarTestUtils.setCalendarView(window, "month");
+});
+
+/**
+ * Tests the "Edit" menu item is available and opens up the event dialog.
+ */
+add_task(async function testEditEditableItem() {
+ let calendar = CalendarTestUtils.createCalendar("Editable", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let title = "Editable Event";
+ let event = new CalEvent();
+ event.title = title;
+ event.startDate = cal.createDateTime("20200101T000001Z");
+
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshown");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="1"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(!editMenu.disabled, 'context menu "Edit" item is not disabled for editable event');
+
+ let editDialogPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ let doc = win.document;
+ Assert.ok(
+ doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml",
+ "editing event dialog opened"
+ );
+
+ let iframe = doc.querySelector("#calendar-item-panel-iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+
+ let iframeDoc = iframe.contentDocument;
+ Assert.ok(
+ (iframeDoc.querySelector("#item-title").value = title),
+ 'context menu "Edit" item opens the editing dialog'
+ );
+ doc.querySelector("dialog").acceptDialog();
+ return true;
+ });
+
+ menu.activateItem(editMenu);
+ await editDialogPromise;
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled for events we are not allowed to
+ * modify.
+ */
+add_task(async function testEditNonEditableItem() {
+ let calendar = CalendarTestUtils.createCalendar("Non-Editable", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let event = new CalEvent();
+ let acl = {
+ QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]),
+ userCanModify: false,
+ userCanRespond: true,
+ userCanViewAll: true,
+ userCanViewDateAndTime: true,
+ calendarEntry: {
+ hasAccessControl: true,
+ userIsOwner: false,
+ },
+ };
+ event.title = "Read Only Event";
+ event.startDate = cal.createDateTime("20200102T000001Z");
+ event.mACLEntry = acl;
+
+ await calendar.addItem(event);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="2"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for non-editable event');
+ menu.hidePopup();
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled when the event is an invitation.
+ */
+add_task(async function testInvitation() {
+ let calendar = CalendarTestUtils.createCalendar("Invitation", "memory");
+ calendar.setProperty("organizerId", "mailto:attendee@example.com");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20200103T152601Z
+ DTSTAMP:20200103T192729Z
+ UID:x131e
+ SUMMARY:Invitation
+ ORGANIZER;CN=Org:mailto:organizer@example.com
+ ATTENDEE;RSVP=TRUE;CN=attendee@example.com;PARTSTAT=NEEDS-ACTION;CUTY
+ PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:attendee@example.com
+ DTSTART:20200103T153000Z
+ DTEND:20200103T163000Z
+ DESCRIPTION:Just a Test
+ SEQUENCE:0
+ TRANSP:OPAQUE
+ END:VEVENT
+ `;
+
+ let invitation = new CalEvent(icalString);
+ await calendar.addItem(invitation);
+ window.goToDate(invitation.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="3"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled for invitations');
+ menu.hidePopup();
+});
+
+/**
+ * Tests that the "Edit" menu item is disabled when the calendar is read-only.
+ */
+add_task(async function testCalendarReadOnly() {
+ let calendar = CalendarTestUtils.createCalendar("ReadOnly", "memory");
+ registerCleanupFunction(() => CalendarTestUtils.removeCalendar(calendar));
+
+ let event = new CalEvent();
+ event.title = "ReadOnly Event";
+ event.startDate = cal.createDateTime("20200104T000001Z");
+
+ await calendar.addItem(event);
+ calendar.setProperty("readOnly", true);
+ window.goToDate(event.startDate);
+
+ let menu = document.querySelector("#calendar-item-context-menu");
+ let editMenu = document.querySelector("#calendar-item-context-menu-modify-menuitem");
+ let popupPromise = BrowserTestUtils.waitForEvent(menu, "popupshowing");
+
+ EventUtils.synthesizeMouseAtCenter(await getDayBoxItem('day="4"'), { type: "contextmenu" });
+ await popupPromise;
+ Assert.ok(editMenu.disabled, 'context menu "Edit" item is disabled when calendar is read-only');
+ menu.hidePopup();
+});
+
+registerCleanupFunction(() => {
+ return CalendarTestUtils.closeCalendarTab(window);
+});
diff --git a/comm/calendar/test/browser/data/attachment.png b/comm/calendar/test/browser/data/attachment.png
new file mode 100644
index 0000000000..30caecab7b
--- /dev/null
+++ b/comm/calendar/test/browser/data/attachment.png
Binary files differ
diff --git a/comm/calendar/test/browser/data/calendars.sjs b/comm/calendar/test/browser/data/calendars.sjs
new file mode 100644
index 0000000000..f1175f1903
--- /dev/null
+++ b/comm/calendar/test/browser/data/calendars.sjs
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.importGlobalProperties(["TextEncoder"]);
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <displayname/>
+ // <current-user-privilege-set/>
+ // <calendar-color/>
+ // </prop>
+ // </propfind>
+
+ let res = `<multistatus xmlns="DAV:"
+ xmlns:A="http://apple.com/ns/ical/"
+ xmlns:C="urn:ietf:params:xml:ns:caldav"
+ xmlns:CS="http://calendarserver.org/ns/">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <displayname>Things found by DNS</displayname>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ <A:calendar-color/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendar.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <C:calendar/>
+ <CS:shared/>
+ </resourcetype>
+ <displayname>You found me!</displayname>
+ <A:calendar-color>#008000</A:calendar-color>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/calendar2.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ <C:calendar/>
+ <CS:shared/>
+ </resourcetype>
+ <displayname>RΓΆda dagar</displayname>
+ <A:calendar-color>#ff0000</A:calendar-color>
+ <current-user-privilege-set>
+ <privilege>
+ <read/>
+ </privilege>
+ <privilege>
+ <C:read-free-busy/>
+ </privilege>
+ <privilege>
+ <read-current-user-privilege-set/>
+ </privilege>
+ <privilege>
+ <write/>
+ </privilege>
+ <privilege>
+ <write-content/>
+ </privilege>
+ <privilege>
+ <write-properties/>
+ </privilege>
+ <privilege>
+ <bind/>
+ </privilege>
+ <privilege>
+ <unbind/>
+ </privilege>
+ </current-user-privilege-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <current-user-privilege-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`;
+
+ let bytes = new TextEncoder().encode(res);
+ let str = "";
+ for (let i = 0; i < bytes.length; i += 65536) {
+ str += String.fromCharCode.apply(null, bytes.subarray(i, i + 65536));
+ }
+ response.write(str);
+}
diff --git a/comm/calendar/test/browser/data/dns.sjs b/comm/calendar/test/browser/data/dns.sjs
new file mode 100644
index 0000000000..85b8777233
--- /dev/null
+++ b/comm/calendar/test/browser/data/dns.sjs
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <owner/>
+ // <displayname/>
+ // <current-user-principal/>
+ // <current-user-privilege-set/>
+ // <calendar-color/>
+ // <calendar-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:"
+ xmlns:A="http://apple.com/ns/ical/"
+ xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/dns.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <collection/>
+ </resourcetype>
+ <current-user-principal>
+ <href>/browser/comm/calendar/test/browser/data/principal.sjs</href>
+ </current-user-principal>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ <propstat>
+ <prop>
+ <owner/>
+ <displayname/>
+ <current-user-privilege-set/>
+ <A:calendar-color/>
+ <C:calendar-home-set/>
+ </prop>
+ <status>HTTP/1.1 404 Not Found</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/calendar/test/browser/data/event.ics b/comm/calendar/test/browser/data/event.ics
new file mode 100644
index 0000000000..3ee7fd4495
--- /dev/null
+++ b/comm/calendar/test/browser/data/event.ics
@@ -0,0 +1,10 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:An Event
+DESCRIPTION:Parking is not available.
+DTSTART:20210325T110000Z
+DTEND:20210325T120000Z
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/import.ics b/comm/calendar/test/browser/data/import.ics
new file mode 100644
index 0000000000..b6e7a965d7
--- /dev/null
+++ b/comm/calendar/test/browser/data/import.ics
@@ -0,0 +1,24 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/browser/data/principal.sjs b/comm/calendar/test/browser/data/principal.sjs
new file mode 100644
index 0000000000..4cebd9660e
--- /dev/null
+++ b/comm/calendar/test/browser/data/principal.sjs
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response) {
+ if (!request.hasHeader("Authorization")) {
+ response.setStatusLine("1.1", 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", `Basic realm="test"`);
+ return;
+ }
+
+ response.setStatusLine("1.1", 207, "Multi-Status");
+ response.setHeader("Content-Type", "text/xml", false);
+
+ // Request:
+ // <propfind>
+ // <prop>
+ // <resourcetype/>
+ // <calendar-home-set/>
+ // </prop>
+ // </propfind>
+
+ response.write(`<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <response>
+ <href>/browser/comm/calendar/test/browser/data/principal.sjs</href>
+ <propstat>
+ <prop>
+ <resourcetype>
+ <principal/>
+ </resourcetype>
+ <C:calendar-home-set>
+ <href>/browser/comm/calendar/test/browser/data/calendars.sjs</href>
+ </C:calendar-home-set>
+ </prop>
+ <status>HTTP/1.1 200 OK</status>
+ </propstat>
+ </response>
+ </multistatus>`);
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser.ini b/comm/calendar/test/browser/eventDialog/browser.ini
new file mode 100644
index 0000000000..85f569c0cc
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser.ini
@@ -0,0 +1,27 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_alarmDialog.js]
+[browser_attachMenu.js]
+[browser_attendeesDialog.js]
+[browser_attendeesDialogAdd.js]
+[browser_attendeesDialogNoEdit.js]
+[browser_attendeesDialogRemove.js]
+[browser_attendeesDialogUpdate.js]
+[browser_eventDialog.js]
+[browser_eventDialogDescriptionEditor.js]
+[browser_eventDialogEditButton.js]
+[browser_eventDialogModificationPrompt.js]
+[browser_utf8.js]
diff --git a/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js
new file mode 100644
index 0000000000..0d6a07a3c4
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_alarmDialog.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView } = CalendarTestUtils;
+
+add_task(async function testAlarmDialog() {
+ let now = new Date();
+
+ const TITLE = "Event";
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ let allDayHeader = dayView.getAllDayHeader(window);
+ Assert.ok(allDayHeader);
+ EventUtils.synthesizeMouseAtCenter(allDayHeader, {}, window);
+
+ // Create a new all-day event tomorrow.
+
+ // Prepare to dismiss the alarm.
+ let alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let dismissButton = alarmWindow.document.getElementById("alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow);
+ },
+ }
+ );
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window);
+ await setData(dialogWindow, iframeWindow, {
+ allday: true,
+ reminder: "1day",
+ title: TITLE,
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmPromise;
+
+ // Change the reminder duration, this resets the alarm.
+ let eventBox = await dayView.waitForAllDayItemAt(window, 1);
+
+ // Prepare to snooze the alarm.
+ alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let snoozeAllButton = alarmWindow.document.getElementById("alarm-snooze-all-button");
+ let popup = alarmWindow.document.querySelector("#alarm-snooze-all-popup");
+ let menuitems = alarmWindow.document.querySelectorAll("#alarm-snooze-all-popup > menuitem");
+
+ let shownPromise = BrowserTestUtils.waitForEvent(snoozeAllButton, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(snoozeAllButton, {}, alarmWindow);
+ await shownPromise;
+ popup.activateItem(menuitems[5]);
+ },
+ }
+ );
+
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventBox));
+ await setData(dialogWindow, iframeWindow, { reminder: "2days", title: TITLE });
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmPromise;
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attachMenu.js b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js
new file mode 100644
index 0000000000..2a0b2afc4c
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attachMenu.js
@@ -0,0 +1,266 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the attach menu in the event dialog window.
+ */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm");
+const { MockFilePicker } = ChromeUtils.importESModule(
+ "resource://testing-common/MockFilePicker.sys.mjs"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+// Remove the save prompt observer that head.js added. It's causing trouble here.
+Services.ww.unregisterNotification(savePromptObserver);
+
+let calendar = CalendarTestUtils.createCalendar("Attachments");
+registerCleanupFunction(() => {
+ cal.manager.unregisterCalendar(calendar);
+ MockFilePicker.cleanup();
+});
+
+async function getEventBox(selector) {
+ let itemBox;
+ await TestUtils.waitForCondition(() => {
+ itemBox = document.querySelector(selector);
+ return itemBox != null;
+ }, "calendar item did not appear in time");
+ return itemBox;
+}
+
+async function openEventFromBox(eventBox) {
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+ let promise = CalendarTestUtils.waitForEventDialog();
+ EventUtils.synthesizeMouseAtCenter(eventBox, { clickCount: 2 });
+ return promise;
+}
+
+/**
+ * Tests using the "Website" menu item attaches a link to the event.
+ */
+add_task(async function testAttachWebPage() {
+ let startDate = cal.createDateTime("20200101T000001Z");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(startDate);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ await setData(dialogWindow, iframeWindow, {
+ title: "Web Link Event",
+ startDate,
+ });
+
+ // Attach the url.
+ let attachButton = dialogWindow.document.querySelector("#button-url");
+ Assert.ok(attachButton, "attach menu button found");
+
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ let url = "https://thunderbird.net/";
+ let urlPrompt = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://global/content/commonDialog.xhtml",
+ {
+ async callback(win) {
+ win.document.querySelector("#loginTextbox").value = url;
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dialogDocument.querySelector("#button-attach-url"),
+ {},
+ dialogWindow
+ );
+ await urlPrompt;
+
+ // Now check that the url shows in the attachments list.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ let listBox = iframeDocument.querySelector("#attachment-link");
+ await BrowserTestUtils.waitForCondition(
+ () => listBox.itemChildren.length == 1,
+ "attachment list did not show in time"
+ );
+
+ Assert.equal(listBox.itemChildren[0].tooltipText, url, "url included in attachments list");
+
+ // Save the new event.
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Open the event to verify the attachment is shown in the summary dialog.
+ let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item"));
+ let label = summaryWin.document.querySelector(`label[value="${url}"]`);
+ Assert.ok(label, "attachment label found on calendar summary dialog");
+ await BrowserTestUtils.closeWindow(summaryWin);
+
+ // Clean up.
+ let eventBox = await getEventBox("calendar-month-day-box-item");
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {});
+});
+
+/**
+ * Tests selecting a provider from the attach menu works.
+ */
+add_task(async function testAttachProvider() {
+ let fileUrl = "https://path/to/mock/file.pdf";
+ let iconURL = "chrome://messenger/content/extension.svg";
+ let provider = {
+ type: "Mochitest",
+ displayName: "Mochitest",
+ iconURL,
+ initAccount(accountKey) {
+ return {
+ accountKey,
+ type: "Mochitest",
+ get displayName() {
+ return Services.prefs.getCharPref(
+ `mail.cloud_files.accounts.${this.accountKey}.displayName`,
+ "Mochitest Account"
+ );
+ },
+ iconURL,
+ configured: true,
+ managementURL: "",
+ uploadFile(window, aFile) {
+ return new Promise(resolve =>
+ setTimeout(() =>
+ resolve({
+ id: 1,
+ path: aFile.path,
+ size: aFile.fileSize,
+ url: fileUrl,
+ // The uploadFile() function should return serviceIcon, serviceName
+ // and serviceUrl - either default or user defined values specified
+ // by the onFileUpload event. The item-edit dialog uses only the
+ // serviceIcon.
+ serviceIcon: "chrome://messenger/skin/icons/globe.svg",
+ })
+ )
+ );
+ },
+ };
+ },
+ };
+
+ cloudFileAccounts.registerProvider("Mochitest", provider);
+ cloudFileAccounts.createAccount("Mochitest");
+ registerCleanupFunction(() => {
+ cloudFileAccounts.unregisterProvider("Mochitest");
+ });
+
+ let file = new FileUtils.File(getTestFilePath("data/guests.txt"));
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.returnValue = MockFilePicker.returnOk;
+
+ let startDate = cal.createDateTime("20200201T000001Z");
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(startDate);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ await setData(dialogWindow, iframeWindow, {
+ title: "Provider Attachment Event",
+ startDate,
+ });
+
+ let attachButton = dialogDocument.querySelector("#button-url");
+ Assert.ok(attachButton, "attach menu button found");
+
+ let menu = dialogDocument.querySelector("#button-attach-menupopup");
+ let menuItem;
+
+ await BrowserTestUtils.waitForCondition(() => {
+ menuItem = menu.querySelector("menuitem[label='File using Mochitest Account']");
+ return menuItem;
+ });
+
+ Assert.ok(menuItem, "custom provider menuitem found");
+ Assert.equal(menuItem.image, iconURL, "provider image src is provider image");
+
+ // Click on the "Attach" menu.
+ let menuShowing = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(attachButton, {}, dialogWindow);
+ await menuShowing;
+
+ // Click on the menuitem to attach a file using our provider.
+ let menuHidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ EventUtils.synthesizeMouseAtCenter(menuItem, {}, dialogWindow);
+ await menuHidden;
+
+ // Check if the file dialog was "shown". MockFilePicker.open() is asynchronous
+ // but does not return a promise.
+ await BrowserTestUtils.waitForCondition(
+ () => MockFilePicker.shown,
+ "file picker was not shown in time"
+ );
+
+ // Click on the attachments tab of the event dialog.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.querySelector("#event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ // Wait until the file we attached appears.
+ let listBox = iframeDocument.querySelector("#attachment-link");
+ await BrowserTestUtils.waitForCondition(
+ () => listBox.itemChildren.length == 1,
+ "attachment list did not show in time"
+ );
+
+ let listItem = listBox.itemChildren[0];
+
+ // XXX: This property is set after an async operation. Unfortunately, that
+ // operation is not awaited on in its surrounding code so the assertion
+ // after this will occasionally fail if this is not done.
+ await BrowserTestUtils.waitForCondition(
+ () => listItem.attachCloudFileUpload,
+ "attachCloudFileUpload property not set on attachment listitem in time."
+ );
+
+ Assert.equal(listItem.attachCloudFileUpload.url, fileUrl, "upload attached to event");
+
+ let listItemImage = listItem.querySelector("img");
+ Assert.equal(
+ listItemImage.src,
+ "chrome://messenger/skin/icons/globe.svg",
+ "attachment image is provider image"
+ );
+
+ // Save the new event.
+ dialogDocument.querySelector("#button-saveandclose").click();
+
+ // Open it and verify the attachment is shown.
+ let summaryWin = await openEventFromBox(await getEventBox("calendar-month-day-box-item"));
+ let label = summaryWin.document.querySelector(`label[value="${fileUrl}"]`);
+ Assert.ok(label, "attachment label found on calendar summary dialog");
+ await BrowserTestUtils.closeWindow(summaryWin);
+
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+
+ // Clean up.
+ let eventBox = await getEventBox("calendar-month-day-box-item");
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {});
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js
new file mode 100644
index 0000000000..f6e73f3957
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialog.js
@@ -0,0 +1,462 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals createEventWithDialog, openAttendeesWindow, closeAttendeesWindow */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.name = "Mochitest";
+ calendar.setProperty("organizerId", "mailto:mochitest@example.com");
+
+ cal.freeBusyService.addProvider(freeBusyProvider);
+
+ let book = MailServices.ab.getDirectoryFromId(
+ MailServices.ab.newAddressBook("Mochitest", null, 101)
+ );
+ let contacts = {};
+ for (let name of ["Charlie", "Juliet", "Mike", "Oscar", "Romeo", "Victor"]) {
+ let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(Ci.nsIAbCard);
+ card.firstName = name;
+ card.lastName = "Mochitest";
+ card.displayName = `${name} Mochitest`;
+ card.primaryEmail = `${name.toLowerCase()}@example.com`;
+ contacts[name.toUpperCase()] = book.addCard(card);
+ }
+ let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(Ci.nsIAbDirectory);
+ list.isMailList = true;
+ list.dirName = "The Boys";
+ list = book.addMailList(list);
+ list.addCard(contacts.MIKE);
+ list.addCard(contacts.OSCAR);
+ list.addCard(contacts.ROMEO);
+ list.addCard(contacts.VICTOR);
+
+ let today = new Date();
+ let times = {
+ ONE: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 13, 0, 0)
+ ),
+ TWO_THIRTY: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 14, 30, 0)
+ ),
+ THREE_THIRTY: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 15, 30, 0)
+ ),
+ FOUR: new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1, 16, 0, 0)
+ ),
+ };
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ cal.freeBusyService.removeProvider(freeBusyProvider);
+ MailServices.ab.deleteAddressBook(book.URI);
+ });
+
+ let eventWindow = await openEventWindow(calendar);
+ let eventDocument = eventWindow.document;
+ let iframeDocument = eventDocument.getElementById("calendar-item-panel-iframe").contentDocument;
+
+ let eventStartTime = iframeDocument.getElementById("event-starttime");
+ eventStartTime.value = times.ONE;
+ let eventEndTime = iframeDocument.getElementById("event-endtime");
+ eventEndTime.value = times.THREE_THIRTY;
+
+ async function checkAttendeesInAttendeesDialog(attendeesDocument, expectedAttendees) {
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+ await TestUtils.waitForCondition(
+ () => attendeesList.childElementCount == expectedAttendees.length + 1,
+ "empty attendee input should have been added"
+ );
+
+ function getInputValueFromAttendeeRow(row) {
+ const input = row.querySelector("input");
+ return input.value;
+ }
+
+ Assert.deepEqual(
+ Array.from(attendeesList.children, getInputValueFromAttendeeRow),
+ [...expectedAttendees, ""],
+ "attendees list matches what was expected"
+ );
+ Assert.equal(
+ attendeesDocument.activeElement,
+ attendeesList.children[expectedAttendees.length].querySelector("input"),
+ "empty attendee input should have focus"
+ );
+ }
+
+ async function checkFreeBusy(row, count) {
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 1);
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, 0);
+ let responsePromise = BrowserTestUtils.waitForEvent(row, "freebusy-update-finished");
+ freeBusyProvider.sendNextResponse();
+ await responsePromise;
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".pending").length, 0);
+ Assert.equal(row._freeBusyDiv.querySelectorAll(".busy").length, count);
+ }
+
+ {
+ info("Opening for the first time");
+ let attendeesWindow = await openAttendeesWindow(eventWindow);
+ let attendeesDocument = attendeesWindow.document;
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+
+ Assert.equal(attendeesWindow.arguments[0].calendar, calendar);
+ Assert.equal(attendeesWindow.arguments[0].organizer, null);
+ Assert.equal(calendar.getProperty("organizerId"), "mailto:mochitest@example.com");
+ Assert.deepEqual(attendeesWindow.arguments[0].attendees, []);
+
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve));
+
+ let attendeesStartTime = attendeesDocument.getElementById("event-starttime");
+ let attendeesEndTime = attendeesDocument.getElementById("event-endtime");
+ Assert.equal(attendeesStartTime.value.toISOString(), times.ONE.toISOString());
+ Assert.equal(attendeesEndTime.value.toISOString(), times.THREE_THIRTY.toISOString());
+
+ attendeesStartTime.value = times.TWO_THIRTY;
+ attendeesEndTime.value = times.FOUR;
+
+ // Check free/busy of organizer.
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, ["mochitest@example.com"]);
+
+ let organizer = attendeesList.firstElementChild;
+ await checkFreeBusy(organizer, 5);
+
+ // Add attendee.
+
+ EventUtils.sendString("test@example.com", attendeesWindow);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ ]);
+ await checkFreeBusy(attendeesList.children[1], 0);
+
+ // Add another attendee, from the address book.
+
+ let input = attendeesDocument.activeElement;
+ EventUtils.sendString("julie", attendeesWindow);
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000));
+ Assert.equal(input.value, "juliet Mochitest <juliet@example.com>");
+ Assert.ok(input.popupElement.popupOpen);
+ Assert.equal(input.popupElement.richlistbox.childElementCount, 1);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ ]);
+ await checkFreeBusy(attendeesList.children[2], 1);
+
+ // Add a mailing list which should expand.
+
+ input = attendeesDocument.activeElement;
+ EventUtils.sendString("boys", attendeesWindow);
+ await new Promise(resolve => attendeesWindow.setTimeout(resolve, 1000));
+ Assert.equal(input.value, "boys >> The Boys <The Boys>");
+ Assert.ok(input.popupElement.popupOpen);
+ Assert.equal(input.popupElement.richlistbox.childElementCount, 1);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_DOWN", {}, attendeesWindow);
+ Assert.equal(input.popupElement._currentIndex, 1);
+ EventUtils.synthesizeKey("VK_TAB", {}, attendeesWindow);
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ "Mike Mochitest <mike@example.com>",
+ "Oscar Mochitest <oscar@example.com>",
+ "Romeo Mochitest <romeo@example.com>",
+ "Victor Mochitest <victor@example.com>",
+ ]);
+ await checkFreeBusy(attendeesList.children[3], 0);
+ await checkFreeBusy(attendeesList.children[4], 0);
+ await checkFreeBusy(attendeesList.children[5], 1);
+ await checkFreeBusy(attendeesList.children[6], 0);
+
+ await closeAttendeesWindow(attendeesWindow);
+ await new Promise(resolve => eventWindow.setTimeout(resolve));
+ }
+
+ Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ function checkAttendeesInEventDialog(organizer, expectedAttendees) {
+ Assert.equal(iframeDocument.getElementById("item-organizer-row").textContent, organizer);
+
+ let attendeeItems = iframeDocument.querySelectorAll(".attendee-list .attendee-label");
+ Assert.equal(attendeeItems.length, expectedAttendees.length);
+ for (let i = 0; i < expectedAttendees.length; i++) {
+ Assert.equal(attendeeItems[i].getAttribute("attendeeid"), expectedAttendees[i]);
+ }
+ }
+
+ checkAttendeesInEventDialog("mochitest@example.com", [
+ "mailto:mochitest@example.com",
+ "mailto:test@example.com",
+ "mailto:juliet@example.com",
+ "mailto:mike@example.com",
+ "mailto:oscar@example.com",
+ "mailto:romeo@example.com",
+ "mailto:victor@example.com",
+ ]);
+
+ {
+ info("Opening for a second time");
+ let attendeesWindow = await openAttendeesWindow(eventWindow);
+ let attendeesDocument = attendeesWindow.document;
+ let attendeesList = attendeesDocument.getElementById("attendee-list");
+
+ let attendeesStartTime = attendeesDocument.getElementById("event-starttime");
+ let attendeesEndTime = attendeesDocument.getElementById("event-endtime");
+ Assert.equal(attendeesStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(attendeesEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ await checkAttendeesInAttendeesDialog(attendeesDocument, [
+ "mochitest@example.com",
+ "test@example.com",
+ "Juliet Mochitest <juliet@example.com>",
+ "Mike Mochitest <mike@example.com>",
+ "Oscar Mochitest <oscar@example.com>",
+ "Romeo Mochitest <romeo@example.com>",
+ "Victor Mochitest <victor@example.com>",
+ ]);
+
+ await checkFreeBusy(attendeesList.children[0], 5);
+ await checkFreeBusy(attendeesList.children[1], 0);
+ await checkFreeBusy(attendeesList.children[2], 1);
+ await checkFreeBusy(attendeesList.children[3], 0);
+ await checkFreeBusy(attendeesList.children[4], 0);
+ await checkFreeBusy(attendeesList.children[5], 1);
+ await checkFreeBusy(attendeesList.children[6], 0);
+
+ await closeAttendeesWindow(attendeesWindow);
+ await new Promise(resolve => eventWindow.setTimeout(resolve));
+ }
+
+ Assert.equal(eventStartTime.value.toISOString(), times.TWO_THIRTY.toISOString());
+ Assert.equal(eventEndTime.value.toISOString(), times.FOUR.toISOString());
+
+ checkAttendeesInEventDialog("mochitest@example.com", [
+ "mailto:mochitest@example.com",
+ "mailto:test@example.com",
+ "mailto:juliet@example.com",
+ "mailto:mike@example.com",
+ "mailto:oscar@example.com",
+ "mailto:romeo@example.com",
+ "mailto:victor@example.com",
+ ]);
+
+ iframeDocument.getElementById("notify-attendees-checkbox").checked = false;
+ await closeEventWindow(eventWindow);
+});
+
+add_task(async () => {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.setProperty("organizerId", "mailto:mochitest@example.com");
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let defaults = {
+ displayTimezone: true,
+ attendees: [],
+ organizer: null,
+ calendar,
+ onOk: () => {},
+ };
+
+ async function testDays(startTime, endTime, expectedFirst, expectedLast) {
+ let attendeesWindow = await openAttendeesWindow({ ...defaults, startTime, endTime });
+ let attendeesDocument = attendeesWindow.document;
+
+ let days = attendeesDocument.querySelectorAll("calendar-day");
+ Assert.equal(days.length, 16);
+ Assert.equal(days[0].date.icalString, expectedFirst);
+ Assert.equal(days[15].date.icalString, expectedLast);
+
+ await closeAttendeesWindow(attendeesWindow);
+ }
+
+ // With the management of the reduced days or not, the format of the dates is different according to the cases.
+ // In case of a reduced day, the day format will include the start hour of the day (defined by calendar.view.daystarthour).
+ // In the case of a full day, we keep the behavior similar to before.
+
+ //Full day tests
+ await testDays(
+ cal.createDateTime("20100403T020000"),
+ cal.createDateTime("20100403T030000"),
+ "20100403",
+ "20100418"
+ );
+ for (let i = -2; i < 0; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: i }).icalString.substring(0, 8),
+ fromToday({ days: i + 15 }).icalString.substring(0, 8)
+ );
+ }
+ for (let i = 0; i < 3; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: 0 }).icalString.substring(0, 8),
+ fromToday({ days: 15 }).icalString.substring(0, 8)
+ );
+ }
+ for (let i = 3; i < 5; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 2 }),
+ fromToday({ days: i, hours: 3 }),
+ fromToday({ days: i - 2 }).icalString.substring(0, 8),
+ fromToday({ days: i + 13 }).icalString.substring(0, 8)
+ );
+ }
+ await testDays(
+ cal.createDateTime("20300403T020000"),
+ cal.createDateTime("20300403T030000"),
+ "20300401",
+ "20300416"
+ );
+
+ // Reduced day tests
+ let dayStartHour = Services.prefs.getIntPref("calendar.view.daystarthour", 8).toString();
+ if (dayStartHour.length == 1) {
+ dayStartHour = "0" + dayStartHour;
+ }
+
+ await testDays(
+ cal.createDateTime("20100403T120000"),
+ cal.createDateTime("20100403T130000"),
+ "20100403T" + dayStartHour + "0000Z",
+ "20100418T" + dayStartHour + "0000Z"
+ );
+ for (let i = -2; i < 0; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: i }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: i + 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ for (let i = 0; i < 3; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: 0 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: 15 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ for (let i = 3; i < 5; i++) {
+ await testDays(
+ fromToday({ days: i, hours: 12 }),
+ fromToday({ days: i, hours: 13 }),
+ fromToday({ days: i - 2 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z",
+ fromToday({ days: i + 13 }).icalString.substring(0, 8) + "T" + dayStartHour + "0000Z"
+ );
+ }
+ await testDays(
+ cal.createDateTime("20300403T120000"),
+ cal.createDateTime("20300403T130000"),
+ "20300401T" + dayStartHour + "0000Z",
+ "20300416T" + dayStartHour + "0000Z"
+ );
+});
+
+function openEventWindow(calendar) {
+ let eventWindowPromise = BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+
+ let doc = win.document;
+ if (doc.documentURI == "chrome://calendar/content/calendar-event-dialog.xhtml") {
+ let iframe = doc.getElementById("calendar-item-panel-iframe");
+ await BrowserTestUtils.waitForEvent(iframe.contentWindow, "load");
+ return true;
+ }
+ return false;
+ });
+ createEventWithDialog(calendar, null, null, "Event");
+ return eventWindowPromise;
+}
+
+async function closeEventWindow(eventWindow) {
+ let eventWindowPromise = BrowserTestUtils.domWindowClosed(eventWindow);
+ eventWindow.document.getElementById("button-saveandclose").click();
+ await eventWindowPromise;
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+function fromToday({ days = 0, hours = 0 }) {
+ if (!fromToday.today) {
+ fromToday.today = cal.dtz.now();
+ fromToday.today.hour = fromToday.today.minute = fromToday.today.second = 0;
+ }
+
+ let duration = cal.createDuration();
+ duration.days = days;
+ duration.hours = hours;
+
+ let value = fromToday.today.clone();
+ value.addDuration(duration);
+ return value;
+}
+
+var freeBusyProvider = {
+ pendingRequests: [],
+ sendNextResponse() {
+ let next = this.pendingRequests.shift();
+ if (next) {
+ next();
+ }
+ },
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ this.pendingRequests.push(() => {
+ info(`Sending free/busy response for ${aCalId}`);
+ if (aCalId in this.data) {
+ aListener.onResult(
+ null,
+ this.data[aCalId].map(([startDuration, duration]) => {
+ let start = fromToday(startDuration);
+
+ let end = start.clone();
+ end.addDuration(cal.createDuration(duration));
+
+ return new cal.provider.FreeBusyInterval(
+ aCalId,
+ Ci.calIFreeBusyInterval.BUSY,
+ start,
+ end
+ );
+ })
+ );
+ } else {
+ aListener.onResult(null, []);
+ }
+ });
+ },
+ data: {
+ "mailto:mochitest@example.com": [
+ [{ days: 1, hours: 4 }, "PT3H"],
+ [{ days: 1, hours: 8 }, "PT3H"],
+ [{ days: 1, hours: 12 }, "PT3H"],
+ [{ days: 1, hours: 16 }, "PT3H"],
+ [{ days: 2, hours: 4 }, "PT3H"],
+ ],
+ "mailto:juliet@example.com": [["P1DT9H", "PT8H"]],
+ "mailto:romeo@example.com": [["P1DT14H", "PT5H"]],
+ },
+};
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js
new file mode 100644
index 0000000000..c1f2778118
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogAdd.js
@@ -0,0 +1,248 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testAddAttendeeToEventWithNone() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which currently has no attendees or organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.organizer, null, "event should not have an organizer");
+ Assert.equal(event.getAttendees().length, 0, "event should not have any attendees");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Set text in the empty row to create a new attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "bar@example.com",
+ "there should an empty input",
+ value => value === ""
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer was set on the event.
+ const organizer = editedEvent.organizer;
+ Assert.ok(organizer, "there should be an organizer for the event after editing");
+ Assert.equal(
+ organizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should match calendar property"
+ );
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match calendar property");
+
+ const attendees = editedEvent.getAttendees();
+ Assert.equal(attendees.length, 2, "there should be two attendees of the event after editing");
+
+ // Verify that the organizer was added as an attendee.
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "the organizer should have been added as an attendee");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match organizer's");
+ Assert.equal(
+ fooFooson.participationStatus,
+ "ACCEPTED",
+ "organizer attendee should have automatically accepted"
+ );
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "organizer attendee should be required");
+
+ // Verify that the attendee we added to the list is represented on the event.
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, null, "new attendee name should not be set");
+ Assert.equal(
+ barBarrington.participationStatus,
+ "NEEDS-ACTION",
+ "new attendee should have default participation status"
+ );
+ Assert.equal(barBarrington.role, "REQ-PARTICIPANT", "new attendee should have default role");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testAddAttendeeToEventWithoutOrganizerAsAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which has an organizer and attendees, but no attendee
+ // matching the organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp
+ le.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const organizer = event.organizer;
+ Assert.ok(organizer, "the organizer should be set");
+ Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match");
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match");
+
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 2, "there should be two attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!fooFooson, "there should be no attendee matching the organizer");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Verify that we don't display an attendee for the organizer if there is no
+ // attendee on the event for them.
+ const attendeeList = attendeesWindow.document.getElementById("attendee-list");
+ const attendeeInput = Array.from(attendeeList.children)
+ .map(child => child.querySelector("input"))
+ .find(input => {
+ return input ? input.value.includes("foo@example.com") : false;
+ });
+ Assert.ok(!attendeeInput, "there should be no row in the dialog for the organizer");
+
+ // Set text in the empty row to create a new attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "Jim James <jim@example.com>",
+ "there should an empty input",
+ value => value === ""
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer hasn't changed.
+ const editedOrganizer = editedEvent.organizer;
+ Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing");
+ Assert.equal(
+ editedOrganizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should not have changed"
+ );
+ Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed");
+
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 3,
+ "there should be three attendees of the event after editing"
+ );
+
+ // Verify that no attendee matching the organizer was added.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!editedFooFooson, "there should still be no attendee matching the organizer");
+
+ // Verify that a new attendee was added.
+ const jimJames = editedAttendees.find(attendee => attendee.id == "mailto:jim@example.com");
+ Assert.ok(jimJames, "an attendee should have the address jim@example.com");
+ Assert.equal(jimJames.commonName, "Jim James", "new attendee name should be set");
+ Assert.equal(
+ jimJames.participationStatus,
+ "NEEDS-ACTION",
+ "new attendee should have default participation status"
+ );
+ Assert.equal(jimJames.role, "REQ-PARTICIPANT", "new attendee should have default role");
+
+ // Verify that the original first attendee's properties remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the original second attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js
new file mode 100644
index 0000000000..a103173790
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogNoEdit.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndFocusMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testBackingOutWithNoAttendees() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+ calendar.setProperty("organizerCN", "Foo Fooson");
+
+ // Create an event which currently has no attendees or organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.organizer, null, "event should not have an organizer");
+ Assert.equal(event.getAttendees().length, 0, "event should not have any attendees");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ findAndFocusMatchingRow(attendeesWindow, "there should be a row matching the organizer", value =>
+ value.includes(calendar.getProperty("organizerCN"))
+ );
+
+ // We changed our mind. Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ // The event is still counted as modified even with no changes. If this
+ // changes in the future, we'll just need to wait a reasonable time and fetch
+ // the event again.
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer was set on the event.
+ const organizer = editedEvent.organizer;
+ Assert.ok(!organizer, "there should still be no organizer for the event");
+
+ const attendees = editedEvent.getAttendees();
+ Assert.equal(attendees.length, 0, "there should still be no attendees of the event");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js
new file mode 100644
index 0000000000..7ad5a3cf68
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogRemove.js
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testRemoveOrganizerAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:jim@example.com");
+ calendar.setProperty("organizerCN", "Jim James");
+
+ // Create an event with several attendees, including one matching the current
+ // organizer.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f
+ oo@example.com
+ ATTENDEE;CN="Bar Barrington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@exam
+ ple.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const organizer = event.organizer;
+ Assert.ok(organizer, "the organizer should be set");
+ Assert.equal(organizer.id, "mailto:foo@example.com", "organizer ID should match");
+ Assert.equal(organizer.commonName, "Foo Fooson", "organizer name should match");
+
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 3, "there should be three attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "an attendee should match the organizer");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative");
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Empty the row matching the organizer's attendee.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "",
+ "there should an input for attendee matching the organizer",
+ value => value.includes("foo@example.com")
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the organizer hasn't changed.
+ const editedOrganizer = editedEvent.organizer;
+ Assert.ok(editedOrganizer, "the organizer should still be set on the event after editing");
+ Assert.equal(
+ editedOrganizer.id,
+ "mailto:foo@example.com",
+ "organizer ID should not have changed"
+ );
+ Assert.equal(editedOrganizer.commonName, "Foo Fooson", "organizer name should not have changed");
+
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 2,
+ "there should be two attendees of the event after editing"
+ );
+
+ // Verify that the attendee matching the organizer was removed.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(!editedFooFooson, "there should be no attendee matching the organizer after editing");
+
+ // Verify that the second attendee's properties remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the final attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js
new file mode 100644
index 0000000000..b4e30344d0
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_attendeesDialogUpdate.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openAttendeesWindow, closeAttendeesWindow, findAndEditMatchingRow */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testUpdateAttendee() {
+ const calendar = CalendarTestUtils.createCalendar();
+ calendar.setProperty("organizerId", "mailto:foo@example.com");
+
+ // Create an event with several attendees, all of which should have some
+ // non-default properties which aren't covered in the attendees dialog to
+ // ensure that we aren't throwing properties away when we close the dialog.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ ORGANIZER;CN="Foo Fooson":mailto:foo@example.com
+ ATTENDEE;CN="Foo Fooson";PARTSTAT=TENTATIVE;ROLE=REQ-PARTICIPANT:mailto:f
+ oo@example.com
+ ATTENDEE;CN="Bar Barington";PARTSTAT=DECLINED;ROLE=CHAIR:mailto:bar@examp
+ le.com
+ ATTENDEE;CN="Baz Luhrmann";PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT;RSV
+ P=TRUE:mailto:baz@example.com
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check. Note that order of attendees is not significant and thus not
+ // guaranteed.
+ const attendees = event.getAttendees();
+ Assert.equal(attendees.length, 3, "there should be three attendees of the event");
+
+ const fooFooson = attendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(fooFooson, "an attendee should have the address foo@example.com");
+ Assert.equal(fooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(fooFooson.participationStatus, "TENTATIVE", "attendee should be marked tentative");
+ Assert.equal(fooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ const barBarrington = attendees.find(attendee => attendee.id == "mailto:bar@example.com");
+ Assert.ok(barBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(barBarrington.commonName, "Bar Barington", "attendee name should match");
+ Assert.equal(barBarrington.participationStatus, "DECLINED", "attendee should have declined");
+ Assert.equal(barBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ const bazLuhrmann = attendees.find(attendee => attendee.id == "mailto:baz@example.com");
+ Assert.ok(bazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(bazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ bazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(bazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(bazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+ const attendeesWindow = await openAttendeesWindow(eventWindow);
+
+ // Edit the second attendee to correct their name.
+ findAndEditMatchingRow(
+ attendeesWindow,
+ "Bar Barrington <bar@example.com>",
+ "there should an input containing the provided email",
+ value => value.includes("bar@example.com")
+ );
+
+ // Save and close the event.
+ await closeAttendeesWindow(attendeesWindow);
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+ const editedAttendees = editedEvent.getAttendees();
+ Assert.equal(
+ editedAttendees.length,
+ 3,
+ "there should be three attendees of the event after editing"
+ );
+
+ // Verify that the first attendee's properties have not been overwritten or
+ // lost.
+ const editedFooFooson = editedAttendees.find(attendee => attendee.id == "mailto:foo@example.com");
+ Assert.ok(editedFooFooson, "an attendee should have the address foo@example.com");
+ Assert.equal(editedFooFooson.commonName, "Foo Fooson", "attendee name should match");
+ Assert.equal(
+ editedFooFooson.participationStatus,
+ "TENTATIVE",
+ "attendee should be marked tentative"
+ );
+ Assert.equal(editedFooFooson.role, "REQ-PARTICIPANT", "attendee should be required");
+
+ // Verify that the second attendee's name has been changed and all other
+ // fields remain untouched.
+ const editedBarBarrington = editedAttendees.find(
+ attendee => attendee.id == "mailto:bar@example.com"
+ );
+ Assert.ok(editedBarBarrington, "an attendee should have the address bar@example.com");
+ Assert.equal(editedBarBarrington.commonName, "Bar Barrington", "attendee name should match");
+ Assert.equal(
+ editedBarBarrington.participationStatus,
+ "DECLINED",
+ "attendee should have declined"
+ );
+ Assert.equal(editedBarBarrington.role, "CHAIR", "attendee should be the meeting chair");
+
+ // Verify that the final attendee's properties remain untouched.
+ const editedBazLuhrmann = editedAttendees.find(
+ attendee => attendee.id == "mailto:baz@example.com"
+ );
+ Assert.ok(editedBazLuhrmann, "an attendee should have the address baz@example.com");
+ Assert.equal(editedBazLuhrmann.commonName, "Baz Luhrmann", "attendee name should match");
+ Assert.equal(
+ editedBazLuhrmann.participationStatus,
+ "NEEDS-ACTION",
+ "attendee should not have responded yet"
+ );
+ Assert.equal(editedBazLuhrmann.role, "OPT-PARTICIPANT", "attendee should be optional");
+ Assert.equal(editedBazLuhrmann.rsvp, "TRUE", "attendee should be expected to RSVP");
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialog.js b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js
new file mode 100644
index 0000000000..44d75d7169
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialog.js
@@ -0,0 +1,399 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { TIMEOUT_MODAL_DIALOG, checkMonthAlarmIcon, handleDeleteOccurrencePrompt } =
+ ChromeUtils.import("resource://testing-common/calendar/CalendarUtils.jsm");
+var { cancelItemDialog, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+const EVENTTITLE = "Event";
+const EVENTLOCATION = "Location";
+const EVENTDESCRIPTION = "Event Description";
+const EVENTATTENDEE = "foo@example.com";
+const EVENTURL = "https://mozilla.org/";
+const EVENT_ORGANIZER_EMAIL = "pillow@example.com";
+var firstDay;
+
+var { dayView, monthView } = CalendarTestUtils;
+
+let calendar = CalendarTestUtils.createCalendar();
+// This is done so that calItemBase#isInvitation returns true.
+calendar.setProperty("organizerId", `mailto:${EVENT_ORGANIZER_EMAIL}`);
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testEventDialog() {
+ let now = new Date();
+
+ // Since from other tests we may be elsewhere, make sure we start today.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+
+ // Open month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ firstDay = window.currentView().startDay;
+ dump(`First day in view is: ${firstDay.year}-${firstDay.month + 1}-${firstDay.day}\n`);
+
+ // Setup start- & endTime.
+ // Next full hour except last hour of the day.
+ let hour = now.getUTCHours();
+ let startHour = hour == 23 ? hour : (hour + 1) % 24;
+
+ let nextHour = cal.dtz.now();
+ nextHour.resetTo(firstDay.year, firstDay.month, firstDay.day, startHour, 0, 0, cal.dtz.UTC);
+ let startTime = formatTime(nextHour);
+ nextHour.resetTo(
+ firstDay.year,
+ firstDay.month,
+ firstDay.day,
+ (startHour + 1) % 24,
+ 0,
+ 0,
+ cal.dtz.UTC
+ );
+ let endTime = formatTime(nextHour);
+
+ // Create new event on first day in view.
+ EventUtils.synthesizeMouseAtCenter(monthView.getDayBox(window, 1, 1), {}, window);
+
+ let { dialogWindow, iframeWindow, dialogDocument, iframeDocument } =
+ await CalendarTestUtils.editNewEvent(window);
+
+ // First check all standard-values are set correctly.
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._timepicker._inputField.value, startTime);
+
+ // Check selected calendar.
+ Assert.equal(iframeDocument.getElementById("item-calendar").value, "Test");
+
+ // Check standard title.
+ let defTitle = cal.l10n.getAnyString("calendar", "calendar", "newEvent");
+ Assert.equal(iframeDocument.getElementById("item-title").placeholder, defTitle);
+
+ // Prepare category.
+ let categories = cal.l10n.getAnyString("calendar", "categories", "categories2");
+ // Pick 4th value in a comma-separated list.
+ let category = categories.split(",")[4];
+ // Calculate date to repeat until.
+ let untildate = firstDay.clone();
+ untildate.addDuration(cal.createDuration("P20D"));
+
+ // Fill in the rest of the values.
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ categories: [category],
+ repeat: "daily",
+ repeatuntil: untildate,
+ reminder: "5minutes",
+ privacy: "private",
+ attachment: { add: EVENTURL },
+ attendees: { add: EVENTATTENDEE },
+ });
+
+ // Verify attendee added.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attendees"),
+ {},
+ dialogWindow
+ );
+
+ let attendeesTab = iframeDocument.getElementById("event-grid-tabpanel-attendees");
+ let attendeeNameElements = attendeesTab.querySelectorAll(".attendee-list .attendee-name");
+ Assert.equal(attendeeNameElements.length, 2, "there should be two attendees after save");
+ Assert.equal(attendeeNameElements[0].textContent, EVENT_ORGANIZER_EMAIL);
+ Assert.equal(attendeeNameElements[1].textContent, EVENTATTENDEE);
+ Assert.ok(!iframeDocument.getElementById("notify-attendees-checkbox").checked);
+
+ // Verify private label visible.
+ await TestUtils.waitForCondition(
+ () => !dialogDocument.getElementById("status-privacy-private-box").hasAttribute("collapsed")
+ );
+ dialogDocument.getElementById("event-privacy-menupopup").hidePopup();
+
+ // Add attachment and verify added.
+ EventUtils.synthesizeMouseAtCenter(
+ iframeDocument.getElementById("event-grid-tab-attachments"),
+ {},
+ iframeWindow
+ );
+
+ let attachmentsTab = iframeDocument.getElementById("event-grid-tabpanel-attachments");
+ Assert.equal(attachmentsTab.querySelectorAll("richlistitem").length, 1);
+
+ let alarmPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ callback(alarmWindow) {
+ let dismissAllButton = alarmWindow.document.getElementById("alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissAllButton, {}, alarmWindow);
+ },
+ }
+ );
+
+ // save
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Catch and dismiss alarm.
+ await alarmPromise;
+
+ // Verify event and alarm icon visible until endDate (3 full rows) and check tooltip.
+ for (let row = 1; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ await monthView.waitForItemAt(window, row, col, 1);
+ checkMonthAlarmIcon(window, row, col);
+ checkTooltip(row, col, startTime, endTime);
+ }
+ }
+ Assert.ok(!monthView.getItemAt(window, 4, 1, 1));
+
+ // Delete and verify deleted 6th col in row 1.
+ EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 6, 1), {}, window);
+ let elemToDelete = document.getElementById("month-view");
+ await handleDeleteOccurrencePrompt(window, elemToDelete, false);
+
+ await monthView.waitForNoItemAt(window, 1, 6, 1);
+
+ // Verify all others still exist.
+ for (let col = 1; col <= 5; col++) {
+ Assert.ok(monthView.getItemAt(window, 1, col, 1));
+ }
+ Assert.ok(monthView.getItemAt(window, 1, 7, 1));
+
+ for (let row = 2; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ Assert.ok(monthView.getItemAt(window, row, col, 1));
+ }
+ }
+
+ // Delete series by deleting last item in row 1 and confirming to delete all.
+ EventUtils.synthesizeMouseAtCenter(monthView.getItemAt(window, 1, 7, 1), {}, window);
+ elemToDelete = document.getElementById("month-view");
+ await handleDeleteOccurrencePrompt(window, elemToDelete, true);
+
+ // Verify all deleted.
+ await monthView.waitForNoItemAt(window, 1, 5, 1);
+ await monthView.waitForNoItemAt(window, 1, 6, 1);
+ await monthView.waitForNoItemAt(window, 1, 7, 1);
+
+ for (let row = 2; row <= 3; row++) {
+ for (let col = 1; col <= 7; col++) {
+ await monthView.waitForNoItemAt(window, row, col, 1);
+ }
+ }
+});
+
+add_task(async function testOpenExistingEventDialog() {
+ let now = new Date();
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(
+ window,
+ now.getUTCFullYear(),
+ now.getUTCMonth() + 1,
+ now.getUTCDate()
+ );
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create a new event.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open the event in the summary dialog, it will fail if otherwise.
+ let eventWin = await CalendarTestUtils.viewItem(window, eventBox);
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-title").textContent,
+ EVENTTITLE
+ );
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-location").textContent,
+ EVENTLOCATION
+ );
+ Assert.equal(
+ eventWin.document.querySelector("calendar-item-summary .item-description").contentDocument.body
+ .innerText,
+ EVENTDESCRIPTION
+ );
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWin);
+
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+add_task(async function testEventReminderDisplay() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2020, 1, 1);
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create an event without a reminder.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ let eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ let doc = eventWindow.document;
+ let row = doc.querySelector(".reminder-row");
+ Assert.ok(row.hidden, "reminder dropdown is not displayed");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ await CalendarTestUtils.goToDate(window, 2020, 2, 1);
+ createBox = dayView.getHourBoxAt(window, 8);
+
+ // Create an event with a reminder.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox));
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ reminder: "1week",
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+ eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ doc = eventWindow.document;
+ row = doc.querySelector(".reminder-row");
+
+ Assert.ok(
+ row.textContent.includes("7 days before"),
+ "the details are shown when a reminder is set"
+ );
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ // Create an invitation.
+ let icalString =
+ "BEGIN:VCALENDAR\r\n" +
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\n" +
+ "VERSION:2.0\r\n" +
+ "BEGIN:VEVENT\r\n" +
+ "CREATED:20200301T152601Z\r\n" +
+ "DTSTAMP:20200301T192729Z\r\n" +
+ "UID:x137e\r\n" +
+ "SUMMARY:Nap Time\r\n" +
+ "ORGANIZER;CN=Papa Bois:mailto:papabois@example.com\r\n" +
+ "ATTENDEE;RSVP=TRUE;CN=pillow@example.com;PARTSTAT=NEEDS-ACTION;CUTY\r\n" +
+ " PE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:pillow@example.com\r\n" +
+ "DTSTART:20200301T153000Z\r\n" +
+ "DTEND:20200301T163000Z\r\n" +
+ "DESCRIPTION:Slumber In Lumber\r\n" +
+ "SEQUENCE:0\r\n" +
+ "TRANSP:OPAQUE\r\n" +
+ "BEGIN:VALARM\r\n" +
+ "TRIGGER:-PT30M\r\n" +
+ "REPEAT:2\r\n" +
+ "DURATION:PT15M\r\n" +
+ "ACTION:DISPLAY\r\n" +
+ "END:VALARM\r\n" +
+ "END:VEVENT\r\n" +
+ "END:VCALENDAR\r\n";
+
+ let calendarEvent = await calendar.addItem(new CalEvent(icalString));
+ await CalendarTestUtils.goToDate(window, 2020, 3, 1);
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+
+ eventWindow = await CalendarTestUtils.viewItem(window, eventBox);
+ doc = eventWindow.document;
+ row = doc.querySelector(".reminder-row");
+
+ Assert.ok(!row.hidden, "reminder row is displayed");
+ Assert.ok(row.querySelector("menulist") != null, "reminder dropdown is available");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, eventWindow);
+
+ // Delete directly, as using the UI causes a prompt to appear.
+ calendar.deleteItem(calendarEvent);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+/**
+ * Test that using CTRL+Enter does not result in two events being created.
+ * This only happens in the dialog window. See bug 1668478.
+ */
+add_task(async function testCtrlEnterShortcut() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2020, 9, 1);
+
+ let createBox = dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: EVENTTITLE,
+ location: EVENTLOCATION,
+ description: EVENTDESCRIPTION,
+ });
+ EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, dialogWindow);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ // Give the event boxes enough time to appear before checking for duplicates.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ let events = document.querySelectorAll("calendar-month-day-box-item");
+ Assert.equal(events.length, 1, "event was created once");
+
+ if (Services.focus.activeWindow != window) {
+ await BrowserTestUtils.waitForEvent(window, "focus");
+ }
+
+ events[0].focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+});
+
+function checkTooltip(row, col, startTime, endTime) {
+ let item = monthView.getItemAt(window, row, col, 1);
+
+ let toolTipNode = document.getElementById("itemTooltip");
+ toolTipNode.ownerGlobal.onMouseOverItem({ currentTarget: item });
+
+ function getDescription(index) {
+ return toolTipNode.querySelector(
+ `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription`
+ ).textContent;
+ }
+
+ // Check title.
+ Assert.equal(getDescription(1), EVENTTITLE);
+
+ // Check date and time.
+ let dateTime = getDescription(3);
+
+ let currDate = firstDay.clone();
+ currDate.addDuration(cal.createDuration(`P${7 * (row - 1) + (col - 1)}D`));
+ let startDate = cal.dtz.formatter.formatDate(currDate);
+
+ Assert.ok(dateTime.includes(`${startDate} ${startTime} – `));
+
+ // This could be on the next day if it is 00:00.
+ Assert.ok(dateTime.endsWith(endTime));
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js
new file mode 100644
index 0000000000..d838330e73
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogDescriptionEditor.js
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+add_setup(async function () {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ CalendarTestUtils.goToDate(window, 2023, 2, 18);
+});
+
+add_task(async function testPastePreformattedWithLinebreak() {
+ const calendar = CalendarTestUtils.createCalendar();
+
+ // Create an event which currently has no description.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.descriptionHTML, null, "event should not have an HTML description");
+ Assert.equal(event.descriptionText, null, "event should not have a text description");
+
+ // Open our event for editing.
+ const { dialogWindow: eventWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt(
+ window,
+ 1
+ );
+
+ const editor = iframeDocument.getElementById("item-description");
+ editor.focus();
+
+ const expectedHTML =
+ "<pre><code>This event is one which includes\nan explicit linebreak inside a pre tag.</code></pre>";
+
+ // Create a paste which includes HTML data, which the editor will recognize as
+ // HTML and paste with formatting by default.
+ const stringData = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ stringData.data = expectedHTML;
+
+ const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+ transferable.init(null);
+ transferable.addDataFlavor("text/html");
+ transferable.setTransferData("text/html", stringData);
+ Services.clipboard.setData(transferable, null, Ci.nsIClipboard.kGlobalClipboard);
+
+ // Paste.
+ EventUtils.synthesizeKey("v", { accelKey: true }, eventWindow);
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the description has been set appropriately. There should be no
+ // change to the HTML, which is preformatted, and the text description should
+ // include a linebreak in the same place as the HTML.
+ Assert.equal(editedEvent.descriptionHTML, expectedHTML, "HTML description should match input");
+ Assert.equal(
+ editedEvent.descriptionText,
+ "This event is one which includes\nan explicit linebreak inside a pre tag.",
+ "text description should include linebreak"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testTypeLongTextWithLinebreaks() {
+ const calendar = CalendarTestUtils.createCalendar();
+
+ // Create an event which currently has no description.
+ const event = await calendar.addItem(
+ new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ SUMMARY:An event
+ DTSTART:20230218T100000Z
+ DTEND:20230218T110000Z
+ END:VEVENT
+ `)
+ );
+
+ // Remember event details so we can refetch it after editing.
+ const eventId = event.id;
+ const eventModified = event.lastModifiedTime;
+
+ // Sanity check.
+ Assert.equal(event.descriptionHTML, null, "event should not have an HTML description");
+ Assert.equal(event.descriptionText, null, "event should not have a text description");
+
+ // Open our event for editing.
+ const {
+ dialogWindow: eventWindow,
+ iframeDocument,
+ iframeWindow,
+ } = await CalendarTestUtils.dayView.editEventAt(window, 1);
+
+ const editor = iframeDocument.getElementById("item-description");
+ editor.focus();
+
+ // Insert text with several long lines and explicit linebreaks.
+ const firstLine =
+ "This event is pretty much just plain text, albeit it has some pretty long lines so that we can ensure that we don't accidentally wrap it during conversion.";
+ EventUtils.sendString(firstLine, iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+
+ const secondLine = "This line follows immediately after a linebreak.";
+ EventUtils.sendString(secondLine, iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+ EventUtils.sendKey("RETURN", iframeWindow);
+
+ const thirdLine =
+ "And one after a couple more linebreaks, for good measure. It might as well be a fairly long string as well, just so we're certain.";
+ EventUtils.sendString(thirdLine, iframeWindow);
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(eventWindow);
+
+ await TestUtils.waitForCondition(async () => {
+ const item = await calendar.getItem(eventId);
+ return item.lastModifiedTime != eventModified;
+ });
+
+ const editedEvent = await calendar.getItem(eventId);
+
+ // Verify that the description has been set appropriately. The HTML should
+ // match the input and use <br> as a linebreak, while the text should not be
+ // wrapped and should use \n as a linebreak.
+ Assert.equal(
+ editedEvent.descriptionHTML,
+ `${firstLine}<br>${secondLine}<br><br>${thirdLine}`,
+ "HTML description should match input with <br> for linebreaks"
+ );
+ Assert.equal(
+ editedEvent.descriptionText,
+ `${firstLine}\n${secondLine}\n\n${thirdLine}`,
+ "text description should match input with linebreaks"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js
new file mode 100644
index 0000000000..b7730444b2
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogEditButton.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the edit button displayed in the calendar summary dialog.
+ */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+const calendar = CalendarTestUtils.createCalendar("Edit Button Test", "storage");
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+function createNonRecurringEvent() {
+ let event = new CalEvent();
+ event.title = "Non-Recurring Event";
+ event.startDate = cal.createDateTime("20191201T000001Z");
+ return event;
+}
+
+function createRecurringEvent() {
+ let event = new CalEvent();
+ event.title = "Recurring Event";
+ event.startDate = cal.createDateTime("20200101T000001Z");
+ event.recurrenceInfo = new CalRecurrenceInfo(event);
+ event.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30"));
+ return event;
+}
+
+/**
+ * Test the correct edit button is shown for a non-recurring event.
+ */
+add_task(async function testNonRecurringEvent() {
+ let event = await calendar.addItem(createNonRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let eventWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1);
+ let editMenuButton = eventWindow.document.querySelector(
+ "#calendar-summary-dialog-edit-menu-button"
+ );
+
+ Assert.ok(
+ !BrowserTestUtils.is_visible(editMenuButton),
+ "edit dropdown is not visible for non-recurring event"
+ );
+
+ let editButton = eventWindow.document.querySelector("#calendar-summary-dialog-edit-button");
+
+ Assert.ok(
+ BrowserTestUtils.is_visible(editButton),
+ "edit button is visible for non-recurring event"
+ );
+ await CalendarTestUtils.items.cancelItemDialog(eventWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Test the edit button for a non-recurring event actual edits the event.
+ */
+add_task(async function testEditNonRecurringEvent() {
+ let event = await calendar.addItem(createNonRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let modificationPromise = new Promise(resolve => {
+ calendar.wrappedJSObject.addObserver({
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ onModifyItem(aNewItem, aOldItem) {
+ calendar.wrappedJSObject.removeObserver(this);
+ resolve();
+ },
+ });
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemAt(
+ window,
+ 1,
+ 1,
+ 1
+ );
+
+ let newTitle = "Edited Non-Recurring Event";
+ iframeDocument.querySelector("#item-title").value = newTitle;
+
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+ await modificationPromise;
+
+ let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 1, 1);
+ let actualTitle = viewWindow.document.querySelector(
+ "#calendar-item-summary .item-title"
+ ).textContent;
+
+ Assert.equal(actualTitle, newTitle, "edit non-recurring event successful");
+ await CalendarTestUtils.items.cancelItemDialog(viewWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu is displayed for a recurring event.
+ */
+add_task(async function testRecurringEvent() {
+ let event = await calendar.addItem(createRecurringEvent());
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let viewWindow = await CalendarTestUtils.monthView.viewItemAt(window, 1, 6, 1);
+
+ Assert.ok(
+ !BrowserTestUtils.is_visible(
+ viewWindow.document.querySelector("#calendar-summary-dialog-edit-button")
+ ),
+ "non-recurring edit button is not visible for recurring event"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_visible(
+ viewWindow.document.querySelector("#calendar-summary-dialog-edit-menu-button")
+ ),
+ "edit dropdown is visible for recurring event"
+ );
+
+ await CalendarTestUtils.items.cancelItemDialog(viewWindow);
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu allows a single occurrence of a repeating event
+ * to be edited.
+ */
+add_task(async function testEditThisOccurrence() {
+ let event = createRecurringEvent();
+ event = await calendar.addItem(event);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ let modificationPromise = new Promise(resolve => {
+ calendar.wrappedJSObject.addObserver({
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+ onModifyItem(aNewItem, aOldItem) {
+ calendar.wrappedJSObject.removeObserver(this);
+ resolve();
+ },
+ });
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrenceAt(
+ window,
+ 1,
+ 6,
+ 1
+ );
+
+ let originalTitle = event.title;
+ let newTitle = "Edited This Occurrence";
+
+ iframeDocument.querySelector("#item-title").value = newTitle;
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+
+ await modificationPromise;
+
+ let changedBox = await CalendarTestUtils.monthView.waitForItemAt(window, 1, 6, 1);
+ let eventBoxes = document.querySelectorAll("calendar-month-day-box-item");
+
+ for (let box of eventBoxes) {
+ if (box !== changedBox) {
+ Assert.equal(
+ box.item.title,
+ originalTitle,
+ '"Edit this occurrence" did not edit other occurrences'
+ );
+ } else {
+ Assert.equal(box.item.title, newTitle, '"Edit this occurrence only" edited this occurrence.');
+ }
+ }
+ await calendar.deleteItem(event);
+});
+
+/**
+ * Tests the dropdown menu allows all occurrences of a recurring event to be
+ * edited.
+ */
+add_task(async function testEditAllOccurrences() {
+ let event = await calendar.addItem(createRecurringEvent());
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(event.startDate);
+
+ // Setup an observer so we can wait for the event boxes to be updated.
+ let boxesRefreshed = false;
+ let observer = new MutationObserver(() => (boxesRefreshed = true));
+ observer.observe(document.querySelector("#month-view"), {
+ childList: true,
+ subtree: true,
+ });
+
+ let { dialogWindow, iframeDocument } = await CalendarTestUtils.monthView.editItemOccurrencesAt(
+ window,
+ 1,
+ 6,
+ 1
+ );
+
+ let newTitle = "Edited All Occurrences";
+
+ iframeDocument.querySelector("#item-title").value = newTitle;
+ await CalendarTestUtils.items.saveAndCloseItemDialog(dialogWindow);
+ await TestUtils.waitForCondition(() => boxesRefreshed, "event boxes did not refresh in time");
+
+ let eventBoxes = document.querySelectorAll("calendar-month-day-box-item");
+ for (let box of eventBoxes) {
+ Assert.equal(box.item.title, newTitle, '"Edit all occurrences" edited each occurrence');
+ }
+ await calendar.deleteItem(event);
+});
diff --git a/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js
new file mode 100644
index 0000000000..b0f3282b24
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_eventDialogModificationPrompt.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(2);
+
+var { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { data, newlines } = setupData();
+
+var { dayView } = CalendarTestUtils;
+
+let calendar = CalendarTestUtils.createCalendar();
+// This is done so that calItemBase#isInvitation returns true.
+calendar.setProperty("organizerId", "mailto:pillow@example.com");
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+// Test that closing an event dialog with no changes does not prompt for save.
+add_task(async function testEventDialogModificationPrompt() {
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ let createbox = dayView.getHourBoxAt(window, 8);
+
+ // Create new event.
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox);
+ let categories = cal.l10n.getAnyString("calendar", "categories", "categories2").split(",");
+ data[0].categories.push(categories[0]);
+ data[1].categories.push(categories[1], categories[2]);
+
+ // Enter first set of data.
+ await setData(dialogWindow, iframeWindow, data[0]);
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventbox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open, but change nothing.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ // Escape the event window, there should be no prompt to save event.
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ eventbox = await dayView.waitForEventBoxAt(window, 1);
+ // Open, change all values then revert the changes.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ // Change all values.
+ await setData(dialogWindow, iframeWindow, data[1]);
+
+ // Edit all values back to original.
+ await setData(dialogWindow, iframeWindow, data[0]);
+
+ // Escape the event window, there should be no prompt to save event.
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Delete event.
+ document.getElementById("day-view").focus();
+ if (window.currentView().getSelectedItems().length == 0) {
+ EventUtils.synthesizeMouseAtCenter(eventbox, {}, window);
+ }
+ Assert.equal(eventbox.isEditing, false, "event is not being edited");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+});
+
+add_task(async function testDescriptionWhitespace() {
+ for (let i = 0; i < newlines.length; i++) {
+ // test set i
+ let createbox = dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, createbox);
+ await setData(dialogWindow, iframeWindow, newlines[i]);
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventbox = await dayView.waitForEventBoxAt(window, 1);
+
+ // Open and close.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItem(window, eventbox));
+ await setData(dialogWindow, iframeWindow, newlines[i]);
+ cancelItemDialog(dialogWindow);
+ // Wait to see if the prompt appears.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ // Delete it.
+ document.getElementById("day-view").focus();
+ if (window.currentView().getSelectedItems().length == 0) {
+ EventUtils.synthesizeMouseAtCenter(eventbox, {}, window);
+ }
+ Assert.equal(eventbox.isEditing, false, "event is not being edited");
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ }
+});
+
+function setupData() {
+ let date1 = cal.createDateTime("20090101T080000Z");
+ let date2 = cal.createDateTime("20090102T090000Z");
+ let date3 = cal.createDateTime("20090103T100000Z");
+ return {
+ data: [
+ {
+ title: "title1",
+ location: "location1",
+ description: "description1",
+ categories: [],
+ allday: false,
+ startdate: date1,
+ starttime: date1,
+ enddate: date2,
+ endtime: date2,
+ repeat: "none",
+ reminder: "none",
+ priority: "normal",
+ privacy: "public",
+ status: "confirmed",
+ freebusy: "busy",
+ timezonedisplay: true,
+ attachment: { add: "https://mozilla.org" },
+ attendees: { add: "foo@bar.de,foo@bar.com" },
+ },
+ {
+ title: "title2",
+ location: "location2",
+ description: "description2",
+ categories: [],
+ allday: true,
+ startdate: date2,
+ starttime: date2,
+ enddate: date3,
+ endtime: date3,
+ repeat: "daily",
+ reminder: "5minutes",
+ priority: "high",
+ privacy: "private",
+ status: "tentative",
+ freebusy: "free",
+ timezonedisplay: false,
+ attachment: { remove: "mozilla.org" },
+ attendees: { remove: "foo@bar.de,foo@bar.com" },
+ },
+ ],
+ newlines: [
+ { title: "title", description: " test spaces " },
+ { title: "title", description: "\ntest newline\n" },
+ { title: "title", description: "\rtest \\r\r" },
+ { title: "title", description: "\r\ntest \\r\\n\r\n" },
+ { title: "title", description: "\ttest \\t\t" },
+ ],
+ };
+}
diff --git a/comm/calendar/test/browser/eventDialog/browser_utf8.js b/comm/calendar/test/browser/eventDialog/browser_utf8.js
new file mode 100644
index 0000000000..5e9ff82d19
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/browser_utf8.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cancelItemDialog, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var UTF8STRING = " πŸ’£ πŸ’₯ ☣ ";
+
+add_task(async function testUTF8() {
+ let calendar = CalendarTestUtils.createCalendar();
+ Services.prefs.setStringPref("calendar.categories.names", UTF8STRING);
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.clearUserPref("calendar.categories.names");
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ // Create new event.
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ // Fill in name, location, description.
+ await setData(dialogWindow, iframeWindow, {
+ title: UTF8STRING,
+ location: UTF8STRING,
+ description: UTF8STRING,
+ categories: [UTF8STRING],
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // open
+ let { dialogWindow: dlgWindow, iframeDocument } = await CalendarTestUtils.dayView.editEventAt(
+ window,
+ 1
+ );
+ // Check values.
+ Assert.equal(iframeDocument.getElementById("item-title").value, UTF8STRING);
+ Assert.equal(iframeDocument.getElementById("item-location").value, UTF8STRING);
+ // The trailing spaces confuse innerText, so we'll do this longhand
+ let editorEl = iframeDocument.getElementById("item-description");
+ let editor = editorEl.getEditor(editorEl.contentWindow);
+ let description = editor.outputToString("text/plain", 0);
+ // The HTML editor makes the first character a NBSP instead of a space.
+ Assert.equal(description.replaceAll("\xA0", " "), UTF8STRING);
+ Assert.ok(
+ iframeDocument
+ .getElementById("item-categories")
+ .querySelector(`menuitem[label="${UTF8STRING}"][checked]`)
+ );
+
+ // Escape the event window.
+ cancelItemDialog(dlgWindow);
+});
diff --git a/comm/calendar/test/browser/eventDialog/data/guests.txt b/comm/calendar/test/browser/eventDialog/data/guests.txt
new file mode 100644
index 0000000000..e2959cf71e
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/data/guests.txt
@@ -0,0 +1,2 @@
+Nobody
+No one
diff --git a/comm/calendar/test/browser/eventDialog/head.js b/comm/calendar/test/browser/eventDialog/head.js
new file mode 100644
index 0000000000..0646cd709c
--- /dev/null
+++ b/comm/calendar/test/browser/eventDialog/head.js
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+// If the "do you want to save the event?" prompt appears, the test failed.
+// Listen for all windows opening, and if one is the save prompt, fail.
+var savePromptObserver = {
+ async observe(win, topic) {
+ if (topic == "domwindowopened") {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ // Make sure this is a prompt window.
+ if (win.location.href == "chrome://global/content/commonDialog.xhtml") {
+ let doc = win.document;
+ // Adding attachments also shows a prompt, but we can tell which one
+ // this is by checking whether the textbox is visible.
+ if (doc.querySelector("#loginContainer").hasAttribute("hidden")) {
+ Assert.report(true, undefined, undefined, "Unexpected save prompt appeared");
+ doc.querySelector("dialog").getButton("cancel").click();
+ }
+ }
+ }
+ },
+};
+Services.ww.registerNotification(savePromptObserver);
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ Services.ww.unregisterNotification(savePromptObserver);
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
+
+function openAttendeesWindow(eventWindowOrArgs) {
+ let attendeesWindowPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ {
+ async callback(win) {
+ await new Promise(resolve => win.setTimeout(resolve));
+ },
+ }
+ );
+
+ if (Window.isInstance(eventWindowOrArgs)) {
+ EventUtils.synthesizeMouseAtCenter(
+ eventWindowOrArgs.document.getElementById("button-attendees"),
+ {},
+ eventWindowOrArgs
+ );
+ } else {
+ openDialog(
+ "chrome://calendar/content/calendar-event-dialog-attendees.xhtml",
+ "_blank",
+ "chrome,titlebar,resizable",
+ eventWindowOrArgs
+ );
+ }
+ return attendeesWindowPromise;
+}
+
+async function closeAttendeesWindow(attendeesWindow, buttonAction = "accept") {
+ let closedPromise = BrowserTestUtils.domWindowClosed(attendeesWindow);
+ let dialog = attendeesWindow.document.querySelector("dialog");
+ dialog.getButton(buttonAction).click();
+ await closedPromise;
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+function findAndFocusMatchingRow(attendeesWindow, message, matchFunction) {
+ // Get the attendee row for which the input matches.
+ const attendeeList = attendeesWindow.document.getElementById("attendee-list");
+ const attendeeInput = Array.from(attendeeList.children)
+ .map(child => child.querySelector("input"))
+ .find(input => {
+ return input ? matchFunction(input.value) : false;
+ });
+ Assert.ok(attendeeInput, message);
+
+ attendeeInput.focus();
+
+ return attendeeInput;
+}
+
+function findAndEditMatchingRow(attendeesWindow, newValue, message, matchFunction) {
+ // Get the attendee row we wish to edit.
+ const attendeeInput = findAndFocusMatchingRow(attendeesWindow, message, matchFunction);
+
+ // Set the new value of the row. We set the input value directly due to issues
+ // experienced trying to use simulated keystrokes.
+ attendeeInput.value = newValue;
+ EventUtils.synthesizeKey("VK_RETURN", {}, attendeesWindow);
+}
diff --git a/comm/calendar/test/browser/head.js b/comm/calendar/test/browser/head.js
new file mode 100644
index 0000000000..f76cc85754
--- /dev/null
+++ b/comm/calendar/test/browser/head.js
@@ -0,0 +1,374 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* import-globals-from ../../base/content/calendar-views-utils.js */
+
+/* globals openOptionsDialog, openAddonsMgr */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+async function openTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.selectedTab = tasksMode.tabs[0];
+ } else {
+ let tasksTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 });
+ }
+
+ is(tasksMode.tabs.length, 1, "tasks tab is open");
+ is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.closeTab(tasksMode.tabs[0]);
+ }
+
+ is(tasksMode.tabs.length, 0, "tasks tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Currently there's always a folder tab open, hence "select" not "open".
+ */
+async function selectFolderTab() {
+ const tabmail = document.getElementById("tabmail");
+ const folderMode = tabmail.tabModes.mail3PaneTab;
+
+ tabmail.selectedTab = folderMode.tabs[0];
+
+ is(folderMode.tabs.length > 0, true, "at least one folder tab is open");
+ is(tabmail.selectedTab, folderMode.tabs[0], "a folder tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function openChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.selectedTab = chatMode.tabs[0];
+ } else {
+ window.showChatTab();
+ }
+
+ is(chatMode.tabs.length, 1, "chat tab is open");
+ is(tabmail.selectedTab, chatMode.tabs[0], "chat tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeChatTab() {
+ let tabmail = document.getElementById("tabmail");
+ let chatMode = tabmail.tabModes.chat;
+
+ if (chatMode.tabs.length == 1) {
+ tabmail.closeTab(chatMode.tabs[0]);
+ }
+
+ is(chatMode.tabs.length, 0, "chat tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Opens a new calendar event or task tab.
+ *
+ * @param {string} tabMode - Mode of the new tab, either `calendarEvent` or `calendarTask`.
+ * @returns {string} - The id of the new tab's panel element.
+ */
+async function _openNewCalendarItemTab(tabMode) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let previousTabCount = itemTabs.length;
+
+ Services.prefs.setBoolPref("calendar.item.editInTab", true);
+ let buttonId = "sidePanelNewEvent";
+ if (tabMode == "calendarTask") {
+ await openTasksTab();
+ buttonId = "sidePanelNewTask";
+ } else {
+ await CalendarTestUtils.openCalendarTab(window);
+ }
+
+ let newItemButton = document.getElementById(buttonId);
+ EventUtils.synthesizeMouseAtCenter(newItemButton, { clickCount: 1 });
+
+ let newTab = itemTabs[itemTabs.length - 1];
+
+ is(itemTabs.length, previousTabCount + 1, `new ${tabMode} tab is open`);
+ is(tabmail.selectedTab, newTab, `new ${tabMode} tab is selected`);
+
+ await BrowserTestUtils.browserLoaded(newTab.iframe);
+ await new Promise(resolve => setTimeout(resolve));
+ return newTab.panel.id;
+}
+
+let openNewCalendarEventTab = _openNewCalendarItemTab.bind(null, "calendarEvent");
+let openNewCalendarTaskTab = _openNewCalendarItemTab.bind(null, "calendarTask");
+
+/**
+ * Selects an existing (open) calendar event or task tab.
+ *
+ * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`.
+ * @param {string} panelId - The id of the tab's panel element.
+ */
+async function _selectCalendarItemTab(tabMode, panelId) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let tabToSelect = itemTabs.find(tab => tab.panel.id == panelId);
+
+ ok(tabToSelect, `${tabMode} tab is open`);
+
+ tabmail.selectedTab = tabToSelect;
+
+ is(tabmail.selectedTab, tabToSelect, `${tabMode} tab is selected`);
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+let selectCalendarEventTab = _selectCalendarItemTab.bind(null, "calendarEvent");
+let selectCalendarTaskTab = _selectCalendarItemTab.bind(null, "calendarTask");
+
+/**
+ * Closes a calendar event or task tab.
+ *
+ * @param {string} tabMode - The tab mode, either `calendarEvent` or `calendarTask`.
+ * @param {string} panelId - The id of the panel of the tab to close.
+ */
+async function _closeCalendarItemTab(tabMode, panelId) {
+ let tabmail = document.getElementById("tabmail");
+ let itemTabs = tabmail.tabModes[tabMode].tabs;
+ let previousTabCount = itemTabs.length;
+ let itemTab = itemTabs.find(tab => tab.panel.id == panelId);
+
+ if (itemTab) {
+ // Tab does not immediately close, so wait for it.
+ let tabClosedPromise = new Promise(resolve => {
+ itemTab.tabNode.addEventListener("TabClose", resolve, { once: true });
+ });
+ tabmail.closeTab(itemTab);
+ await tabClosedPromise;
+ }
+
+ is(itemTabs.length, previousTabCount - 1, `${tabMode} tab was closed`);
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+let closeCalendarEventTab = _closeCalendarItemTab.bind(null, "calendarEvent");
+let closeCalendarTaskTab = _closeCalendarItemTab.bind(null, "calendarTask");
+
+async function openPreferencesTab() {
+ const tabmail = document.getElementById("tabmail");
+ const prefsMode = tabmail.tabModes.preferencesTab;
+
+ if (prefsMode.tabs.length == 1) {
+ tabmail.selectedTab = prefsMode.tabs[0];
+ } else {
+ openOptionsDialog();
+ }
+
+ is(prefsMode.tabs.length, 1, "preferences tab is open");
+ is(tabmail.selectedTab, prefsMode.tabs[0], "preferences tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeAddressBookTab() {
+ let tabmail = document.getElementById("tabmail");
+ let abMode = tabmail.tabModes.addressBookTab;
+
+ if (abMode.tabs.length == 1) {
+ tabmail.closeTab(abMode.tabs[0]);
+ }
+
+ is(abMode.tabs.length, 0, "address book tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closePreferencesTab() {
+ let tabmail = document.getElementById("tabmail");
+ let prefsMode = tabmail.tabModes.preferencesTab;
+
+ if (prefsMode.tabs.length == 1) {
+ tabmail.closeTab(prefsMode.tabs[0]);
+ }
+
+ is(prefsMode.tabs.length, 0, "preferences tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function openAddonsTab() {
+ const tabmail = document.getElementById("tabmail");
+ const contentMode = tabmail.tabModes.contentTab;
+
+ if (contentMode.tabs.length == 1) {
+ tabmail.selectedTab = contentMode.tabs[0];
+ } else {
+ openAddonsMgr("addons://list/extension");
+ }
+
+ is(contentMode.tabs.length, 1, "addons tab is open");
+ is(tabmail.selectedTab, contentMode.tabs[0], "addons tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+async function closeAddonsTab() {
+ let tabmail = document.getElementById("tabmail");
+ let contentMode = tabmail.tabModes.contentTab;
+
+ if (contentMode.tabs.length == 1) {
+ tabmail.closeTab(contentMode.tabs[0]);
+ }
+
+ is(contentMode.tabs.length, 0, "addons tab is not open");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+/**
+ * Create a calendar using the "Create New Calendar" dialog.
+ *
+ * @param {string} name - Name for the new calendar.
+ * @param {object} [data] - Data to enter into the dialog.
+ * @param {boolean} [data.showReminders] - False to disable reminders.
+ * @param {string} [data.email] - An email address.
+ * @param {object} [data.network] - Data for network calendars.
+ * @param {string} [data.network.location] - A URI (leave undefined for local ICS file).
+ * @param {boolean} [data.network.offline] - False to disable the cache.
+ */
+async function createCalendarUsingDialog(name, data = {}) {
+ /**
+ * Callback function to interact with the dialog.
+ *
+ * @param {nsIDOMWindow} win - The dialog window.
+ */
+ async function useDialog(win) {
+ let doc = win.document;
+ let dialogElement = doc.querySelector("dialog");
+ let acceptButton = dialogElement.getButton("accept");
+
+ if (data.network) {
+ // Choose network calendar type.
+ doc.querySelector("#calendar-type [value='network']").click();
+ acceptButton.click();
+
+ // Enter a location.
+ if (data.network.location == undefined) {
+ let calendarFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ calendarFile.append(name + ".ics");
+ let fileURI = Services.io.newFileURI(calendarFile);
+ data.network.location = fileURI.prePath + fileURI.pathQueryRef;
+ }
+ EventUtils.synthesizeMouseAtCenter(doc.querySelector("#network-location-input"), {}, win);
+ EventUtils.sendString(data.network.location, win);
+
+ // Choose offline support.
+ if (data.network.offline == undefined) {
+ data.network.offline = true;
+ }
+ let offlineCheckbox = doc.querySelector("#network-cache-checkbox");
+ if (!offlineCheckbox.checked) {
+ EventUtils.synthesizeMouseAtCenter(offlineCheckbox, {}, win);
+ }
+ acceptButton.click();
+
+ // Set up an observer to wait for calendar(s) to be found, before
+ // clicking the accept button to subscribe to the calendar(s).
+ let observer = new MutationObserver(mutationList => {
+ mutationList.forEach(async mutation => {
+ if (mutation.type === "childList") {
+ acceptButton.click();
+ }
+ });
+ });
+ observer.observe(doc.querySelector("#network-calendar-list"), { childList: true });
+ } else {
+ // Choose local calendar type.
+ doc.querySelector("#calendar-type [value='local']").click();
+ acceptButton.click();
+
+ // Set calendar name.
+ // Setting the value does not activate the accept button on all platforms,
+ // so we need to type something in case the field is empty.
+ let nameInput = doc.querySelector("#local-calendar-name-input");
+ if (nameInput.value == "") {
+ EventUtils.synthesizeMouseAtCenter(nameInput, {}, win);
+ EventUtils.sendString(name, win);
+ }
+
+ // Set reminder option.
+ if (data.showReminders == undefined) {
+ data.showReminders = true;
+ }
+ let localFireAlarmsCheckbox = doc.querySelector("#local-fire-alarms-checkbox");
+ if (localFireAlarmsCheckbox.checked != data.showReminders) {
+ EventUtils.synthesizeMouseAtCenter(localFireAlarmsCheckbox, {}, win);
+ }
+
+ // Set email account.
+ if (data.email == undefined) {
+ data.email = "none";
+ }
+ let emailIdentityMenulist = doc.querySelector("#email-identity-menulist");
+ EventUtils.synthesizeMouseAtCenter(emailIdentityMenulist, {}, win);
+ emailIdentityMenulist.querySelector("menuitem[value='none']").click();
+
+ // Create the calendar.
+ acceptButton.click();
+ }
+ }
+
+ let dialogWindowPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-creation.xhtml",
+ { callback: useDialog }
+ );
+ // Open the "create new calendar" dialog.
+ CalendarTestUtils.openCalendarTab(window);
+ // This double-click must be inside the calendar list but below the list items.
+ EventUtils.synthesizeMouseAtCenter(document.querySelector("#calendar-list"), { clickCount: 2 });
+ return dialogWindowPromise;
+}
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+ await closeTasksTab();
+ await closeChatTab();
+ await closeAddressBookTab();
+ await closePreferencesTab();
+ await closeAddonsTab();
+
+ // Close any event or task tabs that are open.
+ let tabmail = document.getElementById("tabmail");
+ let eventTabPanelIds = tabmail.tabModes.calendarEvent.tabs.map(tab => tab.panel.id);
+ let taskTabPanelIds = tabmail.tabModes.calendarTask.tabs.map(tab => tab.panel.id);
+ for (let id of eventTabPanelIds) {
+ await closeCalendarEventTab(id);
+ }
+ for (let id of taskTabPanelIds) {
+ await closeCalendarTaskTab(id);
+ }
+ Services.prefs.setBoolPref("calendar.item.editInTab", false);
+
+ Assert.equal(tabmail.tabInfo.length, 1, "all tabs closed");
+});
diff --git a/comm/calendar/test/browser/invitations/browser.ini b/comm/calendar/test/browser/invitations/browser.ini
new file mode 100644
index 0000000000..7c7aa6af46
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser.ini
@@ -0,0 +1,31 @@
+[default]
+head = ../head.js head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_attachedPublishEvent.js]
+[browser_icsAttachment.js]
+skip-if = os == 'win'
+[browser_identityPrompt.js]
+[browser_imipBar.js]
+[browser_imipBarCancel.js]
+[browser_imipBarEmail.js]
+[browser_imipBarExceptionCancel.js]
+[browser_imipBarExceptionOnly.js]
+[browser_imipBarExceptions.js]
+[browser_imipBarRepeat.js]
+[browser_imipBarRepeatCancel.js]
+[browser_imipBarRepeatUpdates.js]
+[browser_imipBarUpdates.js]
+[browser_invitationDisplayNew.js]
+[browser_unsupportedFreq.js]
diff --git a/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js
new file mode 100644
index 0000000000..af121a8032
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_attachedPublishEvent.js
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test that attached events - NOT invites - works properly.
+ * These are attached VCALENDARs that have METHOD:PUBLISH.
+ */
+"use strict";
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var gCalendar;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let receiverAcct = MailServices.accounts.createAccount();
+ receiverAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ let receiverIdentity = MailServices.accounts.createIdentity();
+ receiverIdentity.email = "john.doe@example.com";
+ receiverAcct.addIdentity(receiverIdentity);
+ gCalendar = CalendarTestUtils.createCalendar("EventTestCal");
+
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(gCalendar);
+ MailServices.accounts.removeAccount(receiverAcct, true);
+ });
+});
+
+/**
+ * Test that opening a message containing an event with iTIP method "PUBLISH"
+ * shows the correct UI.
+ * The party crashing dialog should not show.
+ */
+add_task(async function test_event_from_eml() {
+ let file = new FileUtils.File(getTestFilePath("data/message-non-invite.eml"));
+
+ let win = await openMessageFromFile(file);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let imipBar = aboutMessage.document.getElementById("imip-bar");
+
+ await TestUtils.waitForCondition(() => !imipBar.collapsed);
+ info("Ok, iMIP bar is showing");
+
+ let imipAddButton = aboutMessage.document.getElementById("imipAddButton");
+ Assert.ok(!imipAddButton.hidden, "Add button should show");
+
+ EventUtils.synthesizeMouseAtCenter(imipAddButton, {}, aboutMessage);
+
+ // Make sure the event got added, without showing the party crashing dialog.
+ await TestUtils.waitForCondition(async () => {
+ let event = await gCalendar.getItem("1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com");
+ return event;
+ });
+
+ await TestUtils.waitForCondition(() => imipAddButton.hidden, "Add button should hide");
+
+ let imipDetailsButton = aboutMessage.document.getElementById("imipDetailsButton");
+ Assert.ok(!imipDetailsButton.hidden, "Details button should show");
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_icsAttachment.js b/comm/calendar/test/browser/invitations/browser_icsAttachment.js
new file mode 100644
index 0000000000..11bde9144d
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_icsAttachment.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test TB can be set as default calendar app.
+ */
+
+/**
+ * Set TB as default calendar app.
+ */
+add_setup(function () {
+ let shellSvc = Cc["@mozilla.org/mail/shell-service;1"].getService(Ci.nsIShellService);
+ shellSvc.setDefaultClient(false, shellSvc.CALENDAR);
+ ok(shellSvc.isDefaultClient(false, shellSvc.CALENDAR), "setDefaultClient works");
+});
+
+/**
+ * Test when opening an ics attachment, TB should be shown as an option.
+ */
+add_task(async function test_ics_attachment() {
+ let file = new FileUtils.File(getTestFilePath("data/message-containing-event.eml"));
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+ let promise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ {
+ async callback(dialogWindow) {
+ ok(true, "unknownContentType dialog opened");
+ let dialogElement = dialogWindow.document.querySelector("dialog");
+ let acceptButton = dialogElement.getButton("accept");
+ return new Promise(resolve => {
+ let observer = new MutationObserver(mutationList => {
+ mutationList.forEach(async mutation => {
+ if (mutation.attributeName == "disabled" && !acceptButton.disabled) {
+ is(acceptButton.disabled, false, "Accept button enabled");
+ if (AppConstants.platform != "macosx") {
+ let bundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let name = bundle.GetStringFromName("brandShortName");
+ // macOS requires extra step in Finder to set TB as default calendar app.
+ ok(
+ dialogWindow.document.getElementById("openHandler").label.includes(name),
+ `${name} is the default calendar app`
+ );
+ }
+
+ // Should really click acceptButton and test
+ // calender-ics-file-dialog is opened. But on local, a new TB
+ // instance is started and this test will fail.
+ dialogElement.getButton("cancel").click();
+ resolve();
+ }
+ });
+ });
+ observer.observe(acceptButton, { attributes: true });
+ });
+ },
+ }
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ aboutMessage.document.getElementById("attachmentName"),
+ {},
+ aboutMessage
+ );
+ await promise;
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_identityPrompt.js b/comm/calendar/test/browser/invitations/browser_identityPrompt.js
new file mode 100644
index 0000000000..e2d6fe3115
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_identityPrompt.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the calender-itip-identity dialog.
+ */
+
+"use strict";
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/mailnews/PromiseTestUtils.jsm"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let receiverAcct;
+let receiverIdentity;
+let gInbox;
+let calendar;
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ MailServices.accounts.removeIncomingServer(receiverAcct.incomingServer, true);
+ MailServices.accounts.removeAccount(receiverAcct);
+});
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ if (MailServices.accounts.accounts.length == 0) {
+ MailServices.accounts.createLocalMailAccount();
+ }
+
+ let rootFolder = MailServices.accounts.localFoldersServer.rootFolder;
+ if (!rootFolder.containsChildNamed("Inbox")) {
+ rootFolder.createSubfolder("Inbox", null);
+ }
+ gInbox = rootFolder.getChildNamed("Inbox");
+
+ receiverAcct = MailServices.accounts.createAccount();
+ receiverAcct.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ receiverIdentity = MailServices.accounts.createIdentity();
+ receiverIdentity.email = "receiver@example.com";
+ receiverAcct.addIdentity(receiverIdentity);
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+
+ let copyListener = new PromiseTestUtils.PromiseCopyListener();
+ MailServices.copy.copyFileMessage(
+ new FileUtils.File(getTestFilePath("data/meet-meeting-invite.eml")),
+ gInbox,
+ null,
+ false,
+ 0,
+ "",
+ copyListener,
+ null
+ );
+ await copyListener.promise;
+});
+
+/**
+ * Tests that the identity prompt shows when accepting an invitation to an
+ * event with an identity no calendar is configured to use.
+ */
+add_task(async function testInvitationIdentityPrompt() {
+ let tabmail = document.getElementById("tabmail");
+ let about3Pane = tabmail.currentAbout3Pane;
+ about3Pane.displayFolder(gInbox.URI);
+ about3Pane.threadTree.selectedIndex = 0;
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-itip-identity-dialog.xhtml",
+ {
+ async callback(win) {
+ // Select the identity we want to use.
+ let menulist = win.document.getElementById("identity-menu");
+ for (let i = 0; i < menulist.itemCount; i++) {
+ let target = menulist.getItemAtIndex(i);
+ if (target.value == receiverIdentity.fullAddress) {
+ menulist.selectedIndex = i;
+ }
+ }
+
+ win.document.querySelector("dialog").getButton("accept").click();
+ },
+ }
+ );
+
+ // Override this function to intercept the attempt to send the email out.
+ let sendItemsArgs = [];
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => ({
+ scheme: "mailto",
+ type: "email",
+ sendItems(receipientArray, item, sender) {
+ sendItemsArgs = [receipientArray, item, sender];
+ return true;
+ },
+ });
+
+ let aboutMessage = tabmail.currentAboutMessage;
+ let acceptButton = aboutMessage.document.getElementById("imipAcceptButton");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(acceptButton),
+ "waiting for accept button to become visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(acceptButton, {}, aboutMessage);
+ await dialogPromise;
+
+ let event;
+ await TestUtils.waitForCondition(async () => {
+ event = await calendar.getItem("65m17hsdolmotv3kvmrtg40ont@google.com");
+ return event && sendItemsArgs.length;
+ });
+
+ // Restore this function.
+ cal.itip.getImipTransport = getImipTransport;
+
+ let id = `mailto:${receiverIdentity.email}`;
+ Assert.ok(event, "event was added to the calendar successfully");
+ Assert.ok(event.getAttendeeById(id), "selected identity was added to the attendee list");
+ Assert.equal(
+ event.getProperty("X-MOZ-INVITED-ATTENDEE"),
+ id,
+ "X-MOZ-INVITED-ATTENDEE is set to the selected identity"
+ );
+
+ let [recipientArray, , sender] = sendItemsArgs;
+ Assert.equal(recipientArray.length, 1, "one recipient for the reply");
+ Assert.equal(recipientArray[0].id, "mailto:example@gmail.com", "recipient is event organizer");
+ Assert.equal(sender.id, id, "sender is the identity selected");
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBar.js b/comm/calendar/test/browser/invitations/browser_imipBar.js
new file mode 100644
index 0000000000..c9a21a6d2b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBar.js
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving event invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting an invitation and sending a response.
+ */
+add_task(async function testAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipAcceptButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation and sending a response.
+ */
+add_task(async function testTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipTentativeButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation and sending a response.
+ */
+add_task(async function testDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickAction(win, "imipDeclineButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation without sending a response.
+ */
+add_task(async function testAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation without sending a response.
+ */
+add_task(async function testTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation without sending a response.
+ */
+add_task(async function testDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js
new file mode 100644
index 0000000000..3cde7d4656
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarCancel.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already accepted event.
+ */
+add_task(async function testCancelAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to tentatively accepted event.
+ */
+add_task(async function testCancelTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already declined event.
+ */
+add_task(async function testCancelDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ transport,
+ calendar,
+ event,
+ });
+});
+
+/**
+ * Tests the handling of a cancellation when the event was not processed
+ * previously.
+ */
+add_task(async function testUnprocessedCancel() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/cancel-single-event.eml"));
+ let win = await openImipMessage(invite);
+
+ // There should be no buttons present because there is no action to take.
+ // Note: the imip-bar message "This message contains an event that has already been processed" is
+ // misleading.
+ for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) {
+ Assert.ok(button.hidden, `${button.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarEmail.js b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js
new file mode 100644
index 0000000000..a3816b65dd
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarEmail.js
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test that the IMIP bar behaves properly for eml files with invites.
+ */
+
+/* eslint-disable @microsoft/sdl/no-insecure-url */
+
+function getFileFromChromeURL(leafName) {
+ let ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry);
+
+ let url = Services.io.newURI(getRootDirectory(gTestPath) + leafName);
+ info(url.spec);
+ let fileURL = ChromeRegistry.convertChromeURL(url).QueryInterface(Ci.nsIFileURL);
+ return fileURL.file;
+}
+
+/**
+ * Test that when opening a message containing a Teams meeting invite
+ * works as it should.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/teams-meeting-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ // The contentDocument has both the imipHTMLDetails HTML part generated by us,
+ // and the regular HTML part generated by the sender (the server).
+ let links = [
+ ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"),
+ ];
+
+ Assert.equal(links.length, 3, "The 3 links should show");
+
+ // Check the links and their text
+ Assert.equal(
+ links[0].href,
+ "https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d",
+ "link0 href"
+ );
+ Assert.equal(
+ links[0].textContent,
+ "<https://teams.microsoft.com/l/meetup-join/19%3ameeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thread.v2/0?context=%7b%22Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>",
+ "link0 textContent"
+ );
+
+ Assert.equal(links[1].href, "https://aka.ms/JoinTeamsMeeting", "link1 href");
+ Assert.equal(links[1].textContent, "<https://aka.ms/JoinTeamsMeeting>", "link1 textContent");
+
+ Assert.equal(
+ links[2].href,
+ "https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI",
+ "link2 href"
+ );
+ Assert.equal(
+ links[2].textContent,
+ "<https://teams.microsoft.com/meetingOptions/?organizerId=14464d09-ceb8-458c-a61c-717f1e5c66c5&tenantId=2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=19_meeting_MGU5NmI2ZGYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=0&language=fi-FI>",
+ "link2 textContent"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing a Meet meeting invite
+ * works as it should.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/meet-meeting-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ // The contentDocument has both the imipHTMLDetails HTML part generated by us,
+ // and the regular HTML part generated by the sender (the server).
+ let links = [
+ ...msgWindow.content.document.getElementById("imipHTMLDetails").querySelectorAll("a"),
+ ];
+
+ Assert.equal(links.length, 4, "The 4 links should show");
+
+ // Check the links and their text
+ Assert.equal(links[0].href, "mailto:foo@example.com", "link0 href");
+ Assert.equal(links[0].textContent, "<foo@example.com>", "link0 textContent");
+
+ Assert.equal(links[1].href, "http://example.com/?foo=bar", "link1 href");
+ Assert.equal(links[1].textContent, "http://example.com?foo=bar", "link1 textContent");
+
+ Assert.equal(links[2].href, "https://meet.google.com/pyb-ndcu-hhc", "link1 href");
+ Assert.equal(links[2].textContent, "https://meet.google.com/pyb-ndcu-hhc", "link1 textContent");
+
+ Assert.equal(
+ links[3].href,
+ "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1",
+ "link2 href"
+ );
+ Assert.equal(
+ links[3].textContent,
+ "https://calendar.google.com/calendar/event?action=VIEW&eid=NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&tok=MjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&ctz=Europe%2FHelsinki&hl=sv&es=1",
+ "link2 textContent"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing an outlook invite with "empty"
+ * content works as it should.
+ */
+add_task(async function test_outlook_event_from_eml() {
+ let file = getFileFromChromeURL("data/outlook-test-invite.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ let details = msgWindow.content.document.getElementById("imipHTMLDetails");
+
+ Assert.equal(
+ details.getAttribute("open"),
+ "open",
+ "Details should be expanded when the message doesn't include good details"
+ );
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_outlook_event_from_eml test ran to completion");
+});
+
+/**
+ * Test that when opening a message containing an event, the IMIP bar shows.
+ */
+add_task(async function test_event_from_eml() {
+ let file = getFileFromChromeURL("data/message-containing-event.eml");
+
+ let msgWindow = await openMessageFromFile(file);
+ let aboutMessage = msgWindow.document.getElementById("messageBrowser").contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !aboutMessage.document.getElementById("imip-bar").collapsed
+ );
+ info("Ok, iMIP bar is showing");
+
+ await BrowserTestUtils.closeWindow(msgWindow);
+
+ Assert.ok(true, "test_event_from_eml test ran to completion");
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js
new file mode 100644
index 0000000000..7800e742ca
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionCancel.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations to recurring event exceptions.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(3);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests cancelling an exception works.
+ */
+add_task(async function testCancelException() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ await doCancelExceptionTest({
+ calendar,
+ transport,
+ identity,
+ partStat,
+ recurrenceId: "20220317T110000Z",
+ isRecurring: true,
+ });
+ }
+});
+
+/**
+ * Tests cancelling an event with only an exception processed works.
+ */
+add_task(async function testCancelExceptionOnly() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ let win = await openImipMessage(
+ new FileUtils.File(getTestFilePath("data/exception-major.eml"))
+ );
+ await clickAction(win, actionIds.single.button[partStat]);
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ });
+ }
+});
+
+/**
+ * Tests processing a cancellation for a recurring event works when only an
+ * exception was processed previously.
+ */
+add_task(async function testCancelSeriesWithExceptionOnly() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ let win = await openImipMessage(
+ new FileUtils.File(getTestFilePath("data/exception-major.eml"))
+ );
+ await clickMenuAction(
+ win,
+ actionIds.single.button[partStat],
+ actionIds.single.noReply[partStat]
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+
+ let cancel = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml"));
+ let cancelWin = await openImipMessage(cancel);
+ let aboutMessage = cancelWin.document.getElementById("messageBrowser").contentWindow;
+
+ let deleteButton = aboutMessage.document.getElementById("imipDeleteButton");
+ Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage);
+ await BrowserTestUtils.closeWindow(cancelWin);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1);
+ Assert.ok(!(await calendar.getItem(event.id)), "event was deleted");
+
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js
new file mode 100644
index 0000000000..88ad0b3c41
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptionOnly.js
@@ -0,0 +1,262 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving an invitation exception but the original event was not
+ * processed first.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a minor exception and sending a response.
+ */
+add_task(async function testMinorAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipAcceptButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a minor exception and sending a response.
+ */
+add_task(async function testMinorTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipTentativeButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a minor exception and sending a response.
+ */
+add_task(async function testMinorDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickAction(win, "imipDeclineButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a minor exception without sending a response.
+ */
+add_task(async function testMinorAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a minor exception without sending a response.
+ */
+add_task(async function testMinorTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a minor exception without sending a response.
+ */
+add_task(async function testMinorDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a major exception and sending a response.
+ */
+add_task(async function testMajorAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipAcceptButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a major exception and sending a response.
+ */
+add_task(async function testMajorTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipTentativeButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a major exception and sending a response.
+ */
+add_task(async function testMajorDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickAction(win, "imipDeclineButton");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting a major exception without sending a response.
+ */
+add_task(async function testMajorAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipAcceptButton", "imipAcceptButton_AcceptDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting a major exception without sending a response.
+ */
+add_task(async function testMajorTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipTentativeButton", "imipTentativeButton_TentativeDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining a major exception without sending a response.
+ */
+add_task(async function testMajorDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ await clickMenuAction(win, "imipDeclineButton", "imipDeclineButton_DeclineDontSend");
+ await doExceptionOnlyTest({
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noReply: true,
+ isMajor: true,
+ });
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js
new file mode 100644
index 0000000000..2cdf18ed59
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarExceptions.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for handling exceptions to recurring event invitations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update exception to an already accepted recurring event.
+ */
+add_task(async function testMinorUpdateExceptionToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update exception to an already tentatively accepted recurring
+ * event.
+ */
+add_task(async function testMinorUpdateExceptionToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "TENTATIVE",
+ });
+});
+
+/**
+ * Tests a minor update exception to an already declined recurring declined
+ * event.
+ */
+add_task(async function testMinorUpdateExceptionToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorExceptionTest({
+ transport,
+ calendar,
+ partStat: "DECLINED",
+ });
+});
+
+/**
+ * Tests a major update exception to an already accepted event.
+ */
+add_task(async function testMajorExceptionToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already tentatively accepted event.
+ */
+add_task(async function testMajorExceptionToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already declined event.
+ */
+add_task(async function testMajorExceptionToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already accepted event without sending
+ * a reply.
+ */
+add_task(async function testMajorExecptionToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an already tentatively accepted event
+ * without sending a reply.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to a declined event without sending a reply.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorExceptionTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update exception to an event where the participation status
+ * is still "NEEDS-ACTION". Here we want to ensure action is only taken on the
+ * target exception date and not the other dates.
+ */
+add_task(async function testMajorUpdateToNeedsAction() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+
+ // Extract the event from the .eml file and manually add it to the calendar.
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let srcText = await IOUtils.readUTF8(invite.path);
+ let ics = srcText.match(
+ /--00000000000080f3da05db4aef59[\S\s]+--00000000000080f3da05db4aef59/g
+ )[0];
+ ics = ics.split("--00000000000080f3da05db4aef59").join("");
+ ics = ics.replaceAll(/Content-(Type|Transfer-Encoding)?: .*/g, "");
+
+ let event = new CalEvent(ics);
+
+ // This will not be set because we manually added the event.
+ event.setProperty("x-moz-received-dtstamp", "20220316T191602Z");
+
+ await calendar.addItem(event);
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1).item;
+ await doMajorExceptionTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js
new file mode 100644
index 0000000000..c14ff2c0a5
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeat.js
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving recurring event invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting an invitation to a recurring event and sending a response.
+ */
+add_task(async function testAcceptRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation to a recurring event and sending a
+ * response.
+ */
+add_task(async function testTentativeRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation to a recurring event and sending a response.
+ */
+add_task(async function testDeclineRecurringWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "DECLINED",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation to a recurring event without sending a response.
+ */
+add_task(async function testAcceptRecurringWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation to a recurring event without sending
+ * a response.
+ */
+add_task(async function testTentativeRecurringWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation to a recurring event without sending a response.
+ */
+add_task(async function testDeclineRecurrencesWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ isRecurring: true,
+ partStat: "DECLINED",
+ noReply: true,
+ },
+ event
+ );
+
+ await calendar.deleteItem(event.parentItem);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js
new file mode 100644
index 0000000000..1ab50cc739
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatCancel.js
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for processing cancellations to recurring invitations via the imip-bar.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already accepted recurring event.
+ */
+add_task(async function testCancelAcceptedRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already tentatively accepted event.
+ */
+add_task(async function testCancelTentativeRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to an already declined recurring event.
+ */
+add_task(async function testCancelDeclinedRecurring() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ });
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already accepted
+ * recurring event.
+ */
+add_task(async function testCancelAcceptedOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already tentatively
+ * accepted event.
+ */
+add_task(async function testCancelTentativeOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests accepting a cancellation to a single occurrence of an already declined
+ * recurring event.
+ */
+add_task(async function testCancelDeclinedOccurrence() {
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/repeat-event.eml")));
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await BrowserTestUtils.closeWindow(win);
+ await doCancelTest({
+ calendar,
+ event,
+ transport,
+ identity,
+ isRecurring: true,
+ recurrenceId: "20220317T110000Z",
+ });
+ await calendar.deleteItem(event.parentItem);
+});
+
+/**
+ * Tests the handling of a cancellation when the event was not processed
+ * previously.
+ */
+add_task(async function testUnprocessedCancel() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/cancel-repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ for (let button of [...win.document.querySelectorAll("#imip-view-toolbar > toolbarbutton")]) {
+ Assert.ok(button.hidden, `${button.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js
new file mode 100644
index 0000000000..7f0d16f627
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarRepeatUpdates.js
@@ -0,0 +1,247 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving minor and major updates to recurring event invitations
+ * via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update to an already accepted event.
+ */
+add_task(async function testMinorUpdateToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update to an already tentatively accepted event.
+ */
+add_task(async function testMinorUpdateToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat: "TENTATIVE",
+ });
+});
+
+/**
+ * Tests a minor update to an already declined event.
+ */
+add_task(async function testMinorUpdateToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, isRecurring: true, invite, partStat: "DECLINED" });
+});
+
+/**
+ * Tests a major update to an already accepted event.
+ */
+add_task(async function testMajorUpdateToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event.
+ */
+add_task(async function testMajorUpdateToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineRecurrencesButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ isRecurring: true,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already accepted event without replying to the
+ * update.
+ */
+add_task(async function testMajorUpdateToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event without replying
+ * to the update.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipTentativeRecurrencesButton",
+ "imipTentativeRecurrencesButton_TentativeDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickMenuAction(
+ win,
+ "imipDeclineRecurrencesButton",
+ "imipDeclineRecurrencesButton_DeclineDontSend"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ isRecurring: true,
+ partStat,
+ noReply: true,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js
new file mode 100644
index 0000000000..d0f5018e89
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_imipBarUpdates.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for receiving minor and major updates to invitations via the imip-bar.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ requestLongerTimeout(5);
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Tests a minor update to an already accepted event.
+ */
+add_task(async function testMinorUpdateToAccepted() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({
+ transport,
+ calendar,
+ partStat: "ACCEPTED",
+ });
+});
+
+/**
+ * Tests a minor update to an already tentatively accepted event.
+ */
+add_task(async function testMinorUpdateToTentative() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, invite, partStat: "TENTATIVE" });
+});
+
+/**
+ * Tests a minor update to an already declined event.
+ */
+add_task(async function testMinorUpdateToDeclined() {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMinorUpdateTest({ transport, calendar, invite, partStat: "DECLINED" });
+});
+
+/**
+ * Tests a major update to an already accepted event.
+ */
+add_task(async function testMajorUpdateToAcceptedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event.
+ */
+add_task(async function testMajorUpdateToTentativeWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ identity,
+ calendar,
+ partStat,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already accepted event without replying to the
+ * update.
+ */
+add_task(async function testMajorUpdateToAcceptedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipAcceptButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already tentatively accepted event without replying
+ * to the update.
+ */
+add_task(async function testMajorUpdateToTentativeWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipTentativeButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
+
+/**
+ * Tests a major update to an already declined event.
+ */
+add_task(async function testMajorUpdateToDeclinedWithoutResponse() {
+ for (let partStat of ["ACCEPTED", "TENTATIVE", "DECLINED"]) {
+ transport.reset();
+ let invite = new FileUtils.File(getTestFilePath("data/single-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, "imipDeclineButton");
+
+ await BrowserTestUtils.closeWindow(win);
+ await doMajorUpdateTest({
+ transport,
+ calendar,
+ partStat,
+ noReply: true,
+ });
+ }
+});
diff --git a/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js
new file mode 100644
index 0000000000..a7b3f833de
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_invitationDisplayNew.js
@@ -0,0 +1,257 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the invitation panel display with new events.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let identity;
+let calendar;
+let transport;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+ identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ transport = new EmailTransport(account, identity);
+
+ let getImipTransport = cal.itip.getImipTransport;
+ cal.itip.getImipTransport = () => transport;
+
+ let deleteMgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ ).wrappedJSObject;
+ let markDeleted = deleteMgr.markDeleted;
+ deleteMgr.markDeleted = () => {};
+
+ Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", true);
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ cal.itip.getImipTransport = getImipTransport;
+ deleteMgr.markDeleted = markDeleted;
+ CalendarTestUtils.removeCalendar(calendar);
+ Services.prefs.setBoolPref("calendar.itip.newInvitationDisplay", false);
+ });
+});
+
+/**
+ * Tests the invitation panel shows the correct data when loaded with a new
+ * invitation.
+ */
+add_task(async function testShowPanelData() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ if (panel.ownerDocument.hasPendingL10nMutations) {
+ await BrowserTestUtils.waitForEvent(panel.ownerDocument, "L10nMutationsFinished");
+ }
+
+ let notification = panel.shadowRoot.querySelector("notification-message");
+ compareShownPanelValues(notification.shadowRoot, {
+ ".notification-message": "You have been invited to this event.",
+ ".notification-button-container > button": "More",
+ });
+
+ compareShownPanelValues(panel.shadowRoot, {
+ "#title": "Single Event",
+ "#location": "Somewhere",
+ "#partStatTotal": "3 participants",
+ '[data-l10n-id="calendar-invitation-panel-partstat-accepted"]': "1 yes",
+ '[data-l10n-id="calendar-invitation-panel-partstat-needs-action"]': "2 pending",
+ "#attendees li:nth-of-type(1)": "Sender <sender@example.com>",
+ "#attendees li:nth-of-type(2)": "Receiver <receiver@example.com>",
+ "#attendees li:nth-of-type(3)": "Other <other@example.com>",
+ "#description": "An event invitation.",
+ });
+
+ Assert.ok(!panel.shadowRoot.querySelector("#actionButtons").hidden, "action buttons shown");
+ for (let indicator of [
+ ...panel.shadowRoot.querySelectorAll("calendar-invitation-change-indicator"),
+ ]) {
+ Assert.ok(indicator.hidden, `${indicator.id} is hidden`);
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation and sending a response.
+ */
+add_task(async function testAcceptWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "acceptButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation and sending a response.
+ */
+add_task(async function testTentativeWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "tentativeButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ },
+ event
+ );
+
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation and sending a response.
+ */
+add_task(async function testDeclineWithResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "declineButton");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests accepting an invitation without sending a response.
+ */
+add_task(async function testAcceptWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "acceptButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "ACCEPTED",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests tentatively accepting an invitation without sending a response.
+ */
+add_task(async function testTentativeWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "tentativeButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "TENTATIVE",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests declining an invitation without sending a response.
+ */
+add_task(async function testDeclineWithoutResponse() {
+ transport.reset();
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/single-event.eml")));
+ let panel = win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel");
+
+ await clickPanelAction(panel, "declineButton", false);
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ await doImipBarActionTest(
+ {
+ calendar,
+ transport,
+ identity,
+ partStat: "DECLINED",
+ noSend: true,
+ },
+ event
+ );
+ await calendar.deleteItem(event);
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js
new file mode 100644
index 0000000000..2d05ed66dc
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/browser_unsupportedFreq.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for ensuring the application does not hang after processing an
+ * unsupported FREQ value.
+ */
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+let calendar;
+
+/**
+ * Initialize account, identity and calendar.
+ */
+add_setup(async function () {
+ let account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ "receiver",
+ "example.com",
+ "imap"
+ );
+
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = "receiver@example.com";
+ account.addIdentity(identity);
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ window.goToDate(cal.createDateTime("20220316T191602Z"));
+
+ calendar = CalendarTestUtils.createCalendar("Test");
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeAccount(account, true);
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+});
+
+/**
+ * Runs the test using the provided FREQ value.
+ *
+ * @param {string} freq Either "SECONDLY" or "MINUTELY"
+ */
+async function doFreqTest(freq) {
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let srcText = await IOUtils.readUTF8(invite.path);
+ let tmpFile = FileTestUtils.getTempFile(`${freq}.eml`);
+
+ srcText = srcText.replace(/RRULE:.*/g, `RRULE:FREQ=${freq}`);
+ srcText = srcText.replace(/UID:.*/g, `UID:${freq}`);
+ await IOUtils.writeUTF8(tmpFile.path, srcText);
+
+ let win = await openImipMessage(tmpFile);
+ await clickMenuAction(
+ win,
+ "imipAcceptRecurrencesButton",
+ "imipAcceptRecurrencesButton_AcceptDontSend"
+ );
+
+ // Give the view time to refresh and create any occurrences.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ await BrowserTestUtils.closeWindow(win);
+
+ let dayBoxItems = document.querySelectorAll("calendar-month-day-box-item");
+ Assert.equal(dayBoxItems.length, 1, "only one occurrence displayed");
+
+ let [dayBox] = dayBoxItems;
+ let { item } = dayBox;
+ Assert.equal(item.title, "Repeat Event");
+ Assert.equal(item.startDate.icalString, "20220316T110000Z");
+
+ let summaryDialog = await CalendarTestUtils.viewItem(window, dayBox);
+ Assert.equal(
+ summaryDialog.document.querySelector(".repeat-details").textContent,
+ "Repeat details unknown",
+ "repeat details not shown"
+ );
+
+ await BrowserTestUtils.closeWindow(summaryDialog);
+ await calendar.deleteItem(item.parentItem);
+ await TestUtils.waitForCondition(
+ () => document.querySelectorAll("calendar-month-day-box-item").length == 0
+ );
+}
+
+/**
+ * Tests accepting an invitation using the FREQ=SECONDLY value does not render
+ * the application unusable.
+ */
+add_task(async function testSecondly() {
+ return doFreqTest("SECONDLY");
+});
+
+/**
+ * Tests accepting an invitation using the FREQ=MINUTELY value does not render
+ * the application unusable.
+ */
+add_task(async function testMinutely() {
+ return doFreqTest("MINUTELY");
+});
diff --git a/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml
new file mode 100644
index 0000000000..03f298525b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/cancel-repeat-event.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=CANCEL
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:CANCEL
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:1
+STATUS:CANCELLED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/cancel-single-event.eml b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml
new file mode 100644
index 0000000000..afb4edb99d
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/cancel-single-event.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Cancellation: Single Event @ Wed, Mar 16 2022 11:00 AST
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method=CANCEL
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:CANCEL
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:1
+DTSTAMP:20220317T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CANCELLED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/exception-major.eml b/comm/calendar/test/browser/invitations/data/exception-major.eml
new file mode 100644
index 0000000000..07f48e64bd
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/exception-major.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T050000Z
+DTEND:20220317T053000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/exception-minor.eml b/comm/calendar/test/browser/invitations/data/exception-minor.eml
new file mode 100644
index 0000000000..7cc38d29d3
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/exception-minor.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Exception Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220317T110000Z
+DTEND:20220317T113000Z
+RECURRENCE-ID:20220317T110000Z
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Exception location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Exception title
+DESCRIPTION:Exception description
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Exception description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml
new file mode 100644
index 0000000000..8587cd803a
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/meet-meeting-invite.eml
@@ -0,0 +1,384 @@
+Sender: Google Kalender <calendar-notification@google.com>
+Message-ID: <0000000000008c6d7005be1c767c@google.com>
+Date: Mon, 22 Mar 2021 09:12:20 +0000
+Subject: Meet invite (HTML)
+From: example@gmail.com
+To: homer@example.com
+Content-Type: multipart/mixed; boundary="0000000000008c6d6205be1c767e"
+Return-Path: example@gmail.com
+MIME-Version: 1.0
+
+--0000000000008c6d6205be1c767e
+Content-Type: multipart/alternative; boundary="0000000000008c6d6005be1c767c"
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/plain; charset="UTF-8"; format=flowed; delsp=yes
+Content-Transfer-Encoding: base64
+
+RHUgaGFyIGJsaXZpdCBpbmJqdWRlbiB0aWxsIGbDtmxqYW5kZSBow6RuZGVsc2UuCgpUaXRlbDog
+TWVlZWVldCBtZSBIVE1MClRoaXMgaXMgYSB0ZXN0LiBCb2xkLiBJdGFsaWMuJm5ic3A7V2lsbCBk
+aXNjdXNzIGFkZHJlc3MgZm9yIGVtYWlsICAKJmx0O2Zvb0BleGFtcGxlLmNvbSZndDsgYW5kIGh0
+dHA6Ly9leGFtcGxlLmNvbT9mb289YmFyLgpOw6RyOiBtw6VuIGRlbiAyMiBtYXJzIDIwMjEgMTE6
+MzBhbSDigJMgMTI6MzBwbSDDlnN0ZXVyb3BlaXNrIHRpZCAtIEhlbHNpbmdmb3JzCgpBbnNsdXRu
+aW5nc2luZm86IEFuc2x1dCB0aWxsIEdvb2dsZSBNZWV0Cmh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5Yi1uZGN1LWhoYz9ocz0yMjQKCkthbGVuZGVyOiBob21lckBleGFtcGxlLmNvbQpWZW06CiAg
+ICAgKiBleGFtcGxlQGdtYWlsLmNvbeKAkyBvcmdhbmlzYXTDtnIKICAgICAqIGhvbWVyQGV4YW1w
+bGUuY29tCgpJbmZvcm1hdGlvbiBvbSBow6RuZGVsc2VuOiAgCmh0dHBzOi8vY2FsZW5kYXIuZ29v
+Z2xlLmNvbS9jYWxlbmRhci9ldmVudD9hY3Rpb249VklFVyZlaWQ9TmpWdE1UZG9jMlJ2YkcxdmRI
+WXphM1p0Y25Sbk5EQnZiblFnYldGbmJuVnpMbTFsYkdsdVFHaDFkQzVtYVEmdG9rPU1qRWpZbVZ5
+ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0ptWTJVelltUm1N
+REF5TldVME1Ea3pOREF4WmpSaFpnJmN0ej1FdXJvcGUlMkZIZWxzaW5raSZobD1zdiZlcz0wCgpJ
+bmJqdWRhbiBmcsOlbiBHb29nbGUgS2FsZW5kZXI6IGh0dHBzOi8vY2FsZW5kYXIuZ29vZ2xlLmNv
+bS9jYWxlbmRhci8KCkRldHRhIGUtcG9zdG1lZGRlbGFuZGUgaGFyIHNraWNrYXRzIHRpbGwga29u
+dG90IGhvbWVyQGV4YW1wbGUuY29tICAKZWZ0ZXJzb20gZHUgw6RyIGRlbHRhZ2FyZSB2aWQgZGVu
+bmEgaMOkbmRlbHNlLgoKT20gZHUgaW50ZSB2aWxsIGbDpSB1cHBkYXRlcmluZ2FyIG9tIGRlbm5h
+IGjDpG5kZWxzZSBpIGZyYW10aWRlbiBrYW4gZHUgdGFja2EgIApuZWogdGlsbCBkZW5uYSBow6Ru
+ZGVsc2UuIER1IGthbiDDpHZlbiByZWdpc3RyZXJhIGRpZyBmw7ZyIGF0dCBmw6UgZXR0ICAKR29v
+Z2xlLWtvbnRvIHDDpSBodHRwczovL2NhbGVuZGFyLmdvb2dsZS5jb20vY2FsZW5kYXIvIG9jaCBr
+b250cm9sbGVyYSAgCmF2aXNlcmluZ3NpbnN0w6RsbG5pbmdhcm5hIGbDtnIgaGVsYSBrYWxlbmRl
+cm4uCgpPbSBkdSB2aWRhcmViZWZvcmRyYXIgZGVuIGjDpHIgaW5ianVkYW4ga2FuIGRldCBnw7Zy
+YSBkZXQgbcO2amxpZ3QgZsO2ciBhbGxhICAKbW90dGFnYXJlIGF0dCBza2lja2EgZXR0IHN2YXIg
+dGlsbCBvcmdhbmlzYXTDtnJlbiBvY2ggbMOkZ2dhcyB0aWxsIHDDpSAgCmfDpHN0bGlzdGFuLCBi
+anVkYSBpbiBhbmRyYSBvYXZzZXR0IGRlcmFzIGVnZW4gaW5ianVkbmluZ3NzdGF0dXMgZWxsZXIg
+IAptb2RpZmllcmEgZGl0dCBPU0EuIEzDpHMgbWVyIHDDpSAgCmh0dHBzOi8vc3VwcG9ydC5nb29n
+bGUuY29tL2NhbGVuZGFyL2Fuc3dlci8zNzEzNSNmb3J3YXJkaW5nCg==
+--0000000000008c6d6005be1c767c
+Content-Type: text/html; charset="UTF-8"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
+</head>
+<body>
+<span itemscope=3D"" itemtype=3D"http://schema.org/InformAction"><span styl=
+e=3D"display:none" itemprop=3D"about" itemscope=3D"" itemtype=3D"http://sch=
+ema.org/Person">
+<meta itemprop=3D"description" content=3D"Inbjudan fr=C3=A5n example@gm=
+ail.com">
+</span><span itemprop=3D"object" itemscope=3D"" itemtype=3D"http://schema.o=
+rg/Event">
+<div style=3D"">
+<table cellspacing=3D"0" cellpadding=3D"8" border=3D"0" summary=3D"" style=
+=3D"width:100%;font-family:Arial,Sans-serif;border:1px Solid #ccc;border-wi=
+dth:1px 2px 2px 1px;background-color:#fff;">
+<tbody>
+<tr>
+<td>
+<meta itemprop=3D"eventStatus" content=3D"http://schema.org/EventScheduled"=
+>
+<h4 style=3D"padding:6px 0;margin:0 0 4px 0;font-family:Arial,Sans-serif;fo=
+nt-size:13px;line-height:1.4;border:1px Solid #fff;background:#fff;color:#0=
+90;font-weight:normal">
+<strong>Du har blivit inbjuden till f=C3=B6ljande h=C3=A4ndelse.</strong></=
+h4>
+<div style=3D"padding:2px"><span itemprop=3D"publisher" itemscope=3D"" item=
+type=3D"http://schema.org/Organization">
+<meta itemprop=3D"name" content=3D"Google Calendar">
+</span>
+<meta itemprop=3D"eventId/googleCalendar" content=3D"65m17hsdolmotv3kvmrtg4=
+0ont">
+<h3 style=3D"padding:0 0 6px 0;margin:0;font-family:Arial,Sans-serif;font-s=
+ize:16px;font-weight:bold;color:#222">
+<span itemprop=3D"name">Meeeeet me HTML</span></h3>
+<table style=3D"display:inline-table" cellpadding=3D"0" cellspacing=3D"0" b=
+order=3D"0" summary=3D"Uppgifter om h=C3=A4ndelse">
+<tbody>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">N=C3=A4r</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px"><time itemprop=3D"startDate" datetime=3D"20=
+210322T093000Z"></time><time itemprop=3D"endDate" datetime=3D"20210322T1030=
+00Z"></time>m=C3=A5n den 22 mars 2021 11:30am =E2=80=93 12:30pm
+<span style=3D"color:#888">=C3=96steuropeisk tid - Helsingfors</span></div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 4px 0;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Anslutningsinfo</i></div>
+</td>
+<td style=3D"padding-bottom:4px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">Anslut till Google Meet</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px">
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">
+<div style=3D"text-indent:-1px"><span itemprop=3D"potentialaction" itemscop=
+e=3D"" itemtype=3D"http://schema.org/JoinAction"><span itemprop=3D"name" co=
+ntent=3D"meet.google.com/pyb-ndcu-hhc"><span itemprop=3D"target" itemscope=
+=3D"" itemtype=3D"http://schema.org/EntryPoint"><span itemprop=3D"url" cont=
+ent=3D"https://meet.google.com/pyb-ndcu-hhc?hs=3D224"><span itemprop=3D"htt=
+pMethod" content=3D"GET"><a href=3D"https://meet.google.com/pyb-ndcu-hhc?hs=
+=3D224" style=3D"color:#20c;white-space:nowrap" target=3D"_blank">meet.goog=
+le.com/pyb-ndcu-hhc</a></span></span></span></span></span>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Kalender</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<div style=3D"text-indent:-1px">homer@example.com</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding:0 1em 10px 0;font-family:Arial,Sans-serif;font-size:13=
+px;color:#888;white-space:nowrap;width:90px" valign=3D"top">
+<div><i style=3D"font-style:normal">Vem</i></div>
+</td>
+<td style=3D"padding-bottom:10px;font-family:Arial,Sans-serif;font-size:13p=
+x;color:#222" valign=3D"top">
+<table cellspacing=3D"0" cellpadding=3D"0">
+<tbody>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">example@gmail.com</span>
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span itemprop=3D"organizer" itemscope=3D"" itemtype=3D"http://schem=
+a.org/Person">
+<meta itemprop=3D"name" content=3D"example@gmail.com">
+<meta itemprop=3D"email" content=3D"example@gmail.com">
+</span><span style=3D"font-size:11px;color:#888">=E2=80=93 organisat=C3=B6r=
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+<tr>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222;width:10px">
+<div style=3D"text-indent:-1px"><span style=3D"font-family:Courier New,mono=
+space">=E2=80=A2</span></div>
+</td>
+<td style=3D"padding-right:10px;font-family:Arial,Sans-serif;font-size:13px=
+;color:#222">
+<div style=3D"text-indent:-1px">
+<div>
+<div style=3D"margin:0 0 0.3em 0"><span itemprop=3D"attendee" itemscope=3D"=
+" itemtype=3D"http://schema.org/Person"><span itemprop=3D"name" class=3D"no=
+translate">homer@example.com</span>
+<meta itemprop=3D"email" content=3D"homer@example.com">
+</span></div>
+</div>
+</div>
+</td>
+</tr>
+</tbody>
+</table>
+</td>
+</tr>
+</tbody>
+</table>
+<div style=3D"float:right;font-weight:bold;font-size:13px"><a href=3D"https=
+://calendar.google.com/calendar/event?action=3DVIEW&amp;eid=3DNjVtMTdoc2Rvb=
+G1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&amp;tok=3DMjEjYmVydGF0aGV=
+ib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp=
+;ctz=3DEurope%2FHelsinki&amp;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-=
+space:nowrap" itemprop=3D"url">mer
+ information =C2=BB</a><br>
+</div>
+<div style=3D"padding-bottom:15px;font-family:Arial,Sans-serif;font-size:13=
+px;color:#222;white-space:pre-wrap!important;white-space:-moz-pre-wrap!impo=
+rtant;white-space:-pre-wrap!important;white-space:-o-pre-wrap!important;whi=
+te-space:pre;word-wrap:break-word">
+<span>This is a test. <b>Bold</b>. <i>Italic</i>. <br>
+<br>
+Will discuss address for email &lt;<a href=3D"mailto:foo@example.com" targe=
+t=3D"_blank">foo@example.com</a>&gt; and
+<a href=3D"https://www.google.com/url?q=3Dhttp%3A%2F%2Fexample.com%3Ffoo%3D=
+bar&amp;sa=3DD&amp;ust=3D1616836340813000&amp;usg=3DAOvVaw04gjO0O3Bf1tJs9vs=
+BMj3x" target=3D"_blank">
+http://example.com?foo=3Dbar</a>.</span>
+<meta itemprop=3D"description" content=3D"This is a test. Bold. Italic.&nbs=
+p;Will discuss address for email &lt;foo@example.com&gt; and http://example=
+.com?foo=3Dbar.">
+</div>
+</div>
+<p style=3D"color:#222;font-size:13px;margin:0"><span style=3D"color:#888">=
+Ska du delta (homer@example.com)?
+</span><wbr><strong><span itemprop=3D"potentialaction" itemscope=3D"" itemt=
+ype=3D"http://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/Y=
+es">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D1&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Ja</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/M=
+aybe">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D3&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Kanske</a></span></span><span style=3D"margin:0 0.4em;font-weight:normal=
+">
+ - </span><span itemprop=3D"potentialaction" itemscope=3D"" itemtype=3D"htt=
+p://schema.org/RsvpAction">
+<meta itemprop=3D"attendance" content=3D"http://schema.org/RsvpAttendance/N=
+o">
+<span itemprop=3D"handler" itemscope=3D"" itemtype=3D"http://schema.org/Htt=
+pActionHandler"><link itemprop=3D"method" href=3D"http://schema.org/HttpReq=
+uestMethod/GET"><a href=3D"https://calendar.google.com/calendar/event?actio=
+n=3DRESPOND&amp;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQ=
+Gh1dC5maQ&amp;rst=3D2&amp;tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmN=
+jYWE1MjVlZWJmY2UzYmRmMDAyNWU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp=
+;hl=3Dsv&amp;es=3D0" style=3D"color:#20c;white-space:nowrap" itemprop=3D"ur=
+l">Nej</a></span></span></strong>
+<wbr><a href=3D"https://calendar.google.com/calendar/event?action=3DVIEW&am=
+p;eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGluQGh1dC5maQ&amp;=
+tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYmRmMDAyN=
+WU0MDkzNDAxZjRhZg&amp;ctz=3DEurope%2FHelsinki&amp;hl=3Dsv&amp;es=3D0" style=
+=3D"color:#20c;white-space:nowrap" itemprop=3D"url">fler
+ alternativ =C2=BB</a></p>
+</td>
+</tr>
+<tr>
+<td style=3D"background-color:#f6f6f6;color:#888;border-top:1px Solid #ccc;=
+font-family:Arial,Sans-serif;font-size:11px">
+<p>Inbjudan fr=C3=A5n <a href=3D"https://calendar.google.com/calendar/" tar=
+get=3D"_blank" style=3D"">
+Google Kalender</a></p>
+<p>Detta e-postmeddelande har skickats till kontot homer@example.com efte=
+rsom du =C3=A4r deltagare vid denna h=C3=A4ndelse.</p>
+<p>Om du inte vill f=C3=A5 uppdateringar om denna h=C3=A4ndelse i framtiden=
+ kan du tacka nej till denna h=C3=A4ndelse. Du kan =C3=A4ven registrera dig=
+ f=C3=B6r att f=C3=A5 ett Google-konto p=C3=A5 https://calendar.google.com/=
+calendar/ och kontrollera aviseringsinst=C3=A4llningarna f=C3=B6r hela kale=
+ndern.</p>
+<p>Om du vidarebefordrar den h=C3=A4r inbjudan kan det g=C3=B6ra det m=C3=
+=B6jligt f=C3=B6r alla mottagare att skicka ett svar till organisat=C3=B6re=
+n och l=C3=A4ggas till p=C3=A5 g=C3=A4stlistan, bjuda in andra oavsett dera=
+s egen inbjudningsstatus eller modifiera ditt OSA.
+<a href=3D"https://support.google.com/calendar/answer/37135#forwarding">L=
+=C3=A4s mer</a>.</p>
+</td>
+</tr>
+</tbody>
+</table>
+</div>
+</span></span>
+</body>
+</html>
+
+--0000000000008c6d6005be1c767c
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: quoted-printable
+
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20210322T093000Z
+DTEND:20210322T103000Z
+DTSTAMP:20210322T091220Z
+ORGANIZER;CN=3Dexample@gmail.com:mailto:example@gmail.com
+UID:65m17hsdolmotv3kvmrtg40ont@google.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;RSV=
+P=3DTRUE
+ ;CN=3Dexample@gmail.com;X-NUM-GUESTS=3D0:mailto:example@gmail.com
+ATTENDEE;CUTYPE=3DINDIVIDUAL;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION=
+;RSVP=3D
+ TRUE;CN=3Dhomer@example.com;X-NUM-GUESTS=3D0:mailto:homer@example.com
+X-MICROSOFT-CDO-OWNERAPPTID:-410050292
+CREATED:20210322T091220Z
+DESCRIPTION:This is a test. <b>Bold</b>. <i>Italic</i>.&nbsp\;<br><br>Will=
+=20
+ discuss address for email &lt\;foo@example.com&gt\; and http://example.com=
+?
+ foo=3Dbar.\n\n-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:=
+~:~
+ :~:~:~:~:~:~:~:~::~:~::-\n=C3=84ndra inte det h=C3=A4r avsnittet i beskriv=
+ningen.\n\n
+ Den h=C3=A4r h=C3=A4ndelsen har ett videosamtal.\nG=C3=A5 med: https://mee=
+t.google.com/pyb
+ -ndcu-hhc\n\nVisa din h=C3=A4ndelse p=C3=A5 https://calendar.google.com/ca=
+lendar/even
+ t?action=3DVIEW&eid=3DNjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnQgbWFnbnVzLm1lbGlu=
+QGh1d
+ C5maQ&tok=3DMjEjYmVydGF0aGVib3RAZ21haWwuY29tZTg2NGFjYmNjYWE1MjVlZWJmY2UzYm=
+RmM
+ DAyNWU0MDkzNDAxZjRhZg&ctz=3DEurope%2FHelsinki&hl=3Dsv&es=3D1.\n-::~:~::~:~=
+:~:~:~:
+ ~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
+LAST-MODIFIED:20210322T091220Z
+LOCATION:
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Meeeeet me HTML
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--0000000000008c6d6005be1c767c--
+
+--0000000000008c6d6205be1c767e
+Content-Type: application/ics; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSClBST0RJRDotLy9Hb29nbGUgSW5jLy9Hb29nbGUgQ2FsZW5kYXIgNzAu
+OTA1NC8vRU4KVkVSU0lPTjoyLjAKQ0FMU0NBTEU6R1JFR09SSUFOCk1FVEhPRDpSRVFVRVNUCkJF
+R0lOOlZFVkVOVApEVFNUQVJUOjIwMjEwMzIyVDA5MzAwMFoKRFRFTkQ6MjAyMTAzMjJUMTAzMDAw
+WgpEVFNUQU1QOjIwMjEwMzIyVDA5MTIyMFoKT1JHQU5JWkVSO0NOPWV4YW1wbGVAZ21haWwuY29t
+Om1haWx0bzpleGFtcGxlQGdtYWlsLmNvbQpVSUQ6NjVtMTdoc2RvbG1vdHYza3ZtcnRnNDBvbnRA
+Z29vZ2xlLmNvbQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJRFVBTDtST0xFPVJFUS1QQVJUSUNJUEFO
+VDtQQVJUU1RBVD1BQ0NFUFRFRDtSU1ZQPVRSVUUKIDtDTj1leGFtcGxlQGdtYWlsLmNvbTtYLU5V
+TS1HVUVTVFM9MDptYWlsdG86ZXhhbXBsZUBnbWFpbC5jb20KQVRURU5ERUU7Q1VUWVBFPUlORElW
+SURVQUw7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMtQUNUSU9OO1JTVlA9CiBU
+UlVFO0NOPWhvbWVyQGV4YW1wbGUuY29tO1gtTlVNLUdVRVNUUz0wOm1haWx0bzpob21lckBleGFt
+cGxlLmNvbQpYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTQxMDA1MDI5MgpDUkVBVEVEOjIw
+MjEwMzIyVDA5MTIyMFoKREVTQ1JJUFRJT046VGhpcyBpcyBhIHRlc3QuIDxiPkJvbGQ8L2I+LiA8
+aT5JdGFsaWM8L2k+LiZuYnNwXDs8YnI+PGJyPldpbGwgCiBkaXNjdXNzIGFkZHJlc3MgZm9yIGVt
+YWlsICZsdFw7Zm9vQGV4YW1wbGUuY29tJmd0XDsgYW5kIGh0dHA6Ly9leGFtcGxlLmNvbT8KIGZv
+bz1iYXIuXG5cbi06On46fjo6fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+
+On46fjp+On46fjp+On46fgogOn46fjp+On46fjp+On46fjo6fjp+OjotXG7DhG5kcmEgaW50ZSBk
+ZXQgaMOkciBhdnNuaXR0ZXQgaSBiZXNrcml2bmluZ2VuLlxuXG4KIERlbiBow6RyIGjDpG5kZWxz
+ZW4gaGFyIGV0dCB2aWRlb3NhbXRhbC5cbkfDpSBtZWQ6IGh0dHBzOi8vbWVldC5nb29nbGUuY29t
+L3B5YgogLW5kY3UtaGhjXG5cblZpc2EgZGluIGjDpG5kZWxzZSBww6UgaHR0cHM6Ly9jYWxlbmRh
+ci5nb29nbGUuY29tL2NhbGVuZGFyL2V2ZW4KIHQ/YWN0aW9uPVZJRVcmZWlkPU5qVnRNVGRvYzJS
+dmJHMXZkSFl6YTNadGNuUm5OREJ2Ym5RZ2JXRm5iblZ6TG0xbGJHbHVRR2gxZAogQzVtYVEmdG9r
+PU1qRWpZbVZ5ZEdGMGFHVmliM1JBWjIxaGFXd3VZMjl0WlRnMk5HRmpZbU5qWVdFMU1qVmxaV0pt
+WTJVelltUm1NCiBEQXlOV1UwTURrek5EQXhaalJoWmcmY3R6PUV1cm9wZSUyRkhlbHNpbmtpJmhs
+PXN2JmVzPTEuXG4tOjp+On46On46fjp+On46fjoKIH46fjp+On46fjp+On46fjp+On46fjp+On46
+fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46fjp+On46On46fjo6LQpMQVNULU1PRElGSUVE
+OjIwMjEwMzIyVDA5MTIyMFoKTE9DQVRJT046ClNFUVVFTkNFOjAKU1RBVFVTOkNPTkZJUk1FRApT
+VU1NQVJZOk1lZWVlZXQgbWUgSFRNTApUUkFOU1A6T1BBUVVFCkVORDpWRVZFTlQKRU5EOlZDQUxF
+TkRBUgo=
+
+--0000000000008c6d6205be1c767e--
diff --git a/comm/calendar/test/browser/invitations/data/message-containing-event.eml b/comm/calendar/test/browser/invitations/data/message-containing-event.eml
new file mode 100644
index 0000000000..d27c2976db
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/message-containing-event.eml
@@ -0,0 +1,44 @@
+From: ExampleStore <noreply@example.com>
+Date: Wed, 24 Aug 2016 16:40:06 -0400
+Subject: ExampleStore - booking 01.09.2016 @ 09.25 - 09.50
+Content-Type: multipart/mixed;
+ boundary="_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35"
+To: <foo@example.com>
+Message-ID: <df0f52ae-d3dc-4b89-bc3e-67fc4f6e8552@example.com>
+MIME-Version: 1.0
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: multipart/alternative;
+ boundary="_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35"
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: text/plain; charset="UTF-8"
+
+Remember your booking @ 09.25
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: text/html; charset="UTF-8"
+
+<html>
+<body>
+<p>You have a booking for 9.25</p>
+</body>
+</html>
+
+--_=ALT_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35--
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35
+Content-Type: TeXt/CaLeNdAr; method=PUBLISH; charset=UTF-8
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment; filename="booking.ics"
+
+QkVHSU46VkNBTEVOREFSDQpWRVJTSU9OOjIuMA0KTUVUSE9EOlBVQkxJU0gNClBST0RJRDpleGFt
+cGxlLmNvbQ0KQkVHSU46VkVWRU5UDQpEVFNUQVJUOjIwMTYwOTAxVDA2MjUwMFoNCkRURU5EOjIw
+MTYwOTAxVDA2NTAwMFoNCkRUU1RBTVA6MjAxNjA4MjRUMjA0MDAwWg0KVUlEOjIwMTYwODI0VDIw
+NDAwMFotNTY4ODYwMjgwQGV4YW1wbGUuY29tDQpTVU1NQVJZOkhhaXJjdXQNCk9SR0FOSVpFUjpt
+YWlsdG86c29tZW9uZUBleGFtcGxlLmNvbQ0KREVTQ1JJUFRJT046SGFpcmN1dCtzdHlsaW5nDQpM
+T0NBVElPTjpTb21ld2hlcmUNClRSQU5TUDpPUEFRVUUNClNFUVVFTkNFOjANCkNMQVNTOlBVQkxJ
+Qw0KQkVHSU46VkFMQVJNDQpUUklHR0VSOi1QVDYwTQ0KQUNUSU9OOkFVRElPDQpERVNDUklQVElP
+TjpSZW1pbmRlcg0KRU5EOlZBTEFSTQ0KRU5EOlZFVkVOVA0KRU5EOlZDQUxFTkRBUg==
+
+--_=aspNetEmail=_51bed191ceac49f7a22392ea84b6ef35--
diff --git a/comm/calendar/test/browser/invitations/data/message-non-invite.eml b/comm/calendar/test/browser/invitations/data/message-non-invite.eml
new file mode 100644
index 0000000000..cf391f445a
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/message-non-invite.eml
@@ -0,0 +1,115 @@
+Date: Sun, 28 Nov 2021 21:39:31 +0000
+From: Jane <noreply@example.com>
+To: <john.doe@example.com>
+Message-ID: <1074020157.32201638135571450.JavaMail.root@hki-example-prod-app-004>
+Subject: We're having a party - you're NOT invited
+Content-Type: multipart/mixed;
+ boundary="----=_Part_6440_2094089067.1638135571440"
+MIME-Version: 1.0
+
+------=_Part_6440_2094089067.1638135571440
+Content-Type: multipart/related;
+ boundary="----=_Part_6441_499243807.1638135571440"
+
+------=_Part_6441_499243807.1638135571440
+Content-Type: text/plain; charset="UTF-8"
+
+Hey, we're having a party! You're not invited ;)
+
+------=_Part_6441_499243807.1638135571440--
+
+------=_Part_6440_2094089067.1638135571440
+Content-Type: text/calendar; charset="utf-8"; name="event.ics"
+Content-Transfer-Encoding: quoted-printable
+Content-Disposition: attachment; filename="event.ics"
+
+BEGIN:VCALENDAR
+PRODID:-//EXAMPLE:COM//iCal4j 1.0.5.2//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+BEGIN:VTIMEZONE
+TZID:Europe/Helsinki
+TZURL:http://tzurl.org/zoneinfo/Europe/Helsinki
+X-LIC-LOCATION:Europe/Helsinki
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0300
+TZNAME:EEST
+DTSTART:19830327T030000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D3;BYDAY=3D-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0300
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19961027T040000
+RRULE:FREQ=3DYEARLY;BYMONTH=3D10;BYDAY=3D-1SU
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+013952
+TZOFFSETTO:+013952
+TZNAME:HMT
+DTSTART:18780531T000000
+RDATE:18780531T000000
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+013952
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19210501T000000
+RDATE:19210501T000000
+END:STANDARD
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0300
+TZNAME:EEST
+DTSTART:19420403T000000
+RDATE:19420403T000000
+RDATE:19810329T020000
+RDATE:19820328T020000
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0300
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19421003T000000
+RDATE:19421003T000000
+RDATE:19810927T030000
+RDATE:19820926T030000
+RDATE:19830925T040000
+RDATE:19840930T040000
+RDATE:19850929T040000
+RDATE:19860928T040000
+RDATE:19870927T040000
+RDATE:19880925T040000
+RDATE:19890924T040000
+RDATE:19900930T040000
+RDATE:19910929T040000
+RDATE:19920927T040000
+RDATE:19930926T040000
+RDATE:19940925T040000
+RDATE:19950924T040000
+END:STANDARD
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0200
+TZNAME:EET
+DTSTART:19830101T000000
+RDATE:19830101T000000
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:20211128T213931Z
+DTSTART;TZID=3DEurope/Helsinki:20211129T105500
+DTEND;TZID=3DEurope/Helsinki:20211129T110000
+SUMMARY:Party at John's house\, Helsinki
+ORGANIZER;CN=3DJANE:mailto:noreply@example.com
+UID:1e5fd4e6-bc52-439c-ac76-40da54f57c77@secure.example.com
+SEQUENCE:3
+STATUS:CONFIRMED
+LAST-MODIFIED:20211128T213931Z
+END:VEVENT
+END:VCALENDAR
+
+------=_Part_6440_2094089067.1638135571440--
diff --git a/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml
new file mode 100644
index 0000000000..de07a9b873
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/outlook-test-invite.eml
@@ -0,0 +1,102 @@
+From: Marge <marge@example.org>
+To: Homer <homer@example.org>
+Subject: Testaus
+Thread-Topic: Testaus
+Thread-Index: AdfdgAAehFDfsPyXTommZqRYgeMqiQAABo4Q
+Date: Fri, 19 Nov 2021 20:00:31 +0000
+Message-ID: <HE1P190MB0540579ABA18FCE320901B31E39C9@HE1P190MB0540.EURP190.PROD.OUTLOOK.COM>
+Accept-Language: en-US
+Content-Language: en-US
+Content-Type: multipart/alternative;
+ boundary="_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_"
+MIME-Version: 1.0
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
+osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
+xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
+//www.w3.org/TR/REC-html40"><head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
+<style><!--
+/* Font Definitions */
+@font-face
+ {font-family:"Cambria Math";
+ panose-1:2 4 5 3 5 4 6 3 2 4;}
+@font-face
+ {font-family:Calibri;
+ panose-1:2 15 5 2 2 2 4 3 2 4;}
+/* Style Definitions */
+p.MsoNormal, li.MsoNormal, div.MsoNormal
+ {margin:0cm;
+ font-size:11.0pt;
+ font-family:"Calibri",sans-serif;
+ mso-fareast-language:EN-US;}
+span.EmailStyle18
+ {mso-style-type:personal-compose;
+ font-family:"Calibri",sans-serif;
+ color:windowtext;}
+.MsoChpDefault
+ {mso-style-type:export-only;
+ font-size:10.0pt;}
+@page WordSection1
+ {size:612.0pt 792.0pt;
+ margin:70.85pt 2.0cm 70.85pt 2.0cm;}
+div.WordSection1
+ {page:WordSection1;}
+--></style><!--[if gte mso 9]><xml>
+<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
+</xml><![endif]--><!--[if gte mso 9]><xml>
+<o:shapelayout v:ext=3D"edit">
+<o:idmap v:ext=3D"edit" data=3D"1" />
+</o:shapelayout></xml><![endif]-->
+</head>
+<body lang=3D"FI" link=3D"#0563C1" vlink=3D"#954F72" style=3D"word-wrap:bre=
+ak-word">
+<div class=3D"WordSection1">
+<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
+</div>
+</body>
+</html>
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5vcmcKQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1Ib20KIGVyOm1haWx0bzpob21lckBleGFtcGxlLm9yZwpERVND
+UklQVElPTjtMQU5HVUFHRT1lbi1VUzpcbgpVSUQ6MDMwMDAwMDA4MjAwRTAwMDc0QzVCNzEwMUE4
+MkUwMDgwMDAwMDAwMDYwQjYwOEQwOTBEREQ3MDEwMDAwMDAwMDAwMDAwMDAKIDAxMDAwMDAwMDRC
+RTBDRkZBNTRCQ0Y2NEU5NTZFMzQxNDMzNjJDM0MwClNVTU1BUlk7TEFOR1VBR0U9ZW4tVVM6VGVz
+dGF1cwpEVFNUQVJUO1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkwMDAwCkRURU5E
+O1RaSUQ9RkxFIFN0YW5kYXJkIFRpbWU6MjAyMTExMjdUMDkzMDAwCkNMQVNTOlBVQkxJQwpQUklP
+UklUWTo1CkRUU1RBTVA6MjAyMTExMTlUMjAwMDI5WgpUUkFOU1A6T1BBUVVFClNUQVRVUzpDT05G
+SVJNRUQKU0VRVUVOQ0U6MApMT0NBVElPTjtMQU5HVUFHRT1lbi1VUzoKWC1NSUNST1NPRlQtQ0RP
+LUFQUFQtU0VRVUVOQ0U6MApYLU1JQ1JPU09GVC1DRE8tT1dORVJBUFBUSUQ6LTcyMDEyODAyNwpY
+LU1JQ1JPU09GVC1DRE8tQlVTWVNUQVRVUzpURU5UQVRJVkUKWC1NSUNST1NPRlQtQ0RPLUlOVEVO
+REVEU1RBVFVTOkJVU1kKWC1NSUNST1NPRlQtQ0RPLUFMTERBWUVWRU5UOkZBTFNFClgtTUlDUk9T
+T0ZULUNETy1JTVBPUlRBTkNFOjEKWC1NSUNST1NPRlQtQ0RPLUlOU1RUWVBFOjAKWC1NSUNST1NP
+RlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1JQ1JPU09GVC1ESVNBTExPVy1DT1VOVEVS
+OkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpCRUdJTjpWQUxBUk0KREVTQ1JJUFRJT046
+UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1QVDE1TQpBQ1RJT046RElTUExBWQpFTkQ6
+VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1P190MB0540579ABA18FCE320901B31E39C9HE1P190MB0540EURP_--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-event.eml b/comm/calendar/test/browser/invitations/data/repeat-event.eml
new file mode 100644
index 0000000000..9247e6575b
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-event.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Event @ Daily from 2pm to 3pm 3 times (AST) (receiver@example.com)
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-major.eml b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml
new file mode 100644
index 0000000000..61fe9f5022
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-update-major.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Repeat Update Major
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T050000Z
+DTEND:20220316T053000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220316T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Somewhere
+SEQUENCE:2
+STATUS:CONFIRMED
+SUMMARY:Repeat Event
+DESCRIPTION:An event invitation.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml
new file mode 100644
index 0000000000..a6ad357553
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/repeat-update-minor.eml
@@ -0,0 +1,49 @@
+MIME-Version: 1.0
+Date: Mon, 28 Mar 2022 17:49:35 +0000
+Subject: Invitation: Repeat Update Minor
+From: sender@example.com
+To: receiver@example.com
+Content-Type: multipart/mixed; boundary="00000000000080f3db05db4aef5b"
+
+--00000000000080f3db05db4aef5b
+Content-Type: multipart/alternative; boundary="00000000000080f3da05db4aef59"
+
+--00000000000080f3da05db4aef59
+Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+RRULE:FREQ=DAILY;WKST=SU;COUNT=3;INTERVAL=1
+DTSTAMP:20220318T191602Z
+UID:02e79b96
+ORGANIZER;CN=Sender;
+ EMAIL=sender@example.com:mailto:sender@example.com
+ATTENDEE;CN=Sender;
+ EMAIL=sender@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=receiver@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=INDIVIDUAL;
+ PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:other@example.com
+CREATED:20220328T174934Z
+LAST-MODIFIED:20220328T174934Z
+LOCATION:Updated location
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Updated Event
+DESCRIPTION:Updated description.
+TRANSP:OPAQUE
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Updated description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--00000000000080f3da05db4aef59--
+--00000000000080f3db05db4aef5b--
diff --git a/comm/calendar/test/browser/invitations/data/single-event.eml b/comm/calendar/test/browser/invitations/data/single-event.eml
new file mode 100644
index 0000000000..14418c2c79
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/single-event.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Invitation: Single Event @ Wed, Mar 16 2022 11:00 AST
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:0
+DTSTAMP:20220316T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml
new file mode 100644
index 0000000000..777486ec87
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/teams-meeting-invite.eml
@@ -0,0 +1,167 @@
+From: Marge <marge@example.com>
+To: bart@example.com, homer@example.com
+Subject: Teams meeting
+Thread-Topic: Teams meeting
+Thread-Index: AdbIy2RnFnEYrGmq80aB3RiaEcOS6w==
+Date: Wed, 2 Dec 2020 16:52:34 +0000
+Message-ID: <HE1PR0802MB228346BE1576FEAB8A7F32328EF30@HE1PR0802MB2283.eurprd08.prod.outlook.com>
+Accept-Language: fi-FI, en-US
+Content-Language: en-US
+X-MS-Has-Attach:
+X-MS-TNEF-Correlator:
+Content-Type: multipart/alternative;
+ boundary="_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_"
+X-Spam-Flag: No
+Return-Path: marge@example.com
+MIME-Version: 1.0
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/plain; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+___________________________________________________________________________=
+_____
+Microsoft Teams -kokous
+Liity tietokoneella tai mobiilisovelluksella
+Liity kokoukseen napsauttamalla t=E4t=E4<https://teams.microsoft.com/l/meet=
+up-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%40thr=
+ead.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c%2=
+2%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d>
+Lis=E4tietoja<https://aka.ms/JoinTeamsMeeting> | Kokousasetukset<https://te=
+ams.microsoft.com/meetingOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717=
+f1e5c66c5&tenantId=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&threadId=3D19_mee=
+ting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&messageId=
+=3D0&language=3Dfi-FI>
+___________________________________________________________________________=
+_____
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/html; charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+<html>
+<head>
+<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Diso-8859-=
+1">
+</head>
+<body>
+<div><br>
+<br>
+<br>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+<div class=3D"me-email-text" style=3D"color:#252424;font-family:'Segoe UI',=
+'Helvetica Neue',Helvetica,Arial,sans-serif;">
+<div style=3D"margin-top: 24px; margin-bottom: 20px;"><span style=3D"font-s=
+ize: 24px; color:#252424">Microsoft Teams -kokous</span>
+</div>
+<div style=3D"margin-bottom: 20px;">
+<div style=3D"margin-top: 0px; margin-bottom: 0px; font-weight: bold"><span=
+ style=3D"font-size: 14px; color:#252424">Liity tietokoneella tai mobiiliso=
+velluksella</span>
+</div>
+<a class=3D"me-email-headline" style=3D"font-size: 14px;font-family:'Segoe =
+UI Semibold','Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;text-de=
+coration: underline;color: #6264a7;" href=3D"https://teams.microsoft.com/l/=
+meetup-join/19%3ameeting_MHU5NmI5ZGAtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy%4=
+0thread.v2/0?context=3D%7b%33Tid%22%3a%222fd0c1c5-28e1-40c4-9f0d-a0363ca80a=
+3c%22%2c%22Oid%22%3a%2214464d09-ceb8-458c-a61c-717f1e5c66c5%22%7d" target=
+=3D"_blank" rel=3D"noreferrer noopener">Liity
+ kokoukseen napsauttamalla t=E4t=E4</a> </div>
+<div style=3D"margin-bottom: 24px;margin-top: 20px;"><a class=3D"me-email-l=
+ink" style=3D"font-size: 14px;text-decoration: underline;color: #6264a7;fon=
+t-family:'Segoe UI','Helvetica Neue',Helvetica,Arial,sans-serif;" target=3D=
+"_blank" href=3D"https://aka.ms/JoinTeamsMeeting" rel=3D"noreferrer noopene=
+r">Lis=E4tietoja</a>
+ | <a class=3D"me-email-link" style=3D"font-size: 14px;text-decoration: und=
+erline;color: #6264a7;font-family:'Segoe UI','Helvetica Neue',Helvetica,Ari=
+al,sans-serif;" target=3D"_blank" href=3D"https://teams.microsoft.com/meeti=
+ngOptions/?organizerId=3D14464d09-ceb8-458c-a61c-717f1e5c66c5&amp;tenantId=
+=3D2fd0c1c5-28e1-40c4-9f0d-a0363ca80a3c&amp;threadId=3D19_meeting_MGU5NmI2Z=
+GYtOWZmOC00Y2ZmLWJlOTItNjUxNjA5YjUyYTYy@thread.v2&amp;messageId=3D0&amp;lan=
+guage=3Dfi-FI" rel=3D"noreferrer noopener">
+Kokousasetukset</a> </div>
+</div>
+<div style=3D"font-size: 14px; margin-bottom: 4px;font-family:'Segoe UI','H=
+elvetica Neue',Helvetica,Arial,sans-serif;">
+</div>
+<div style=3D"font-size: 12px;"></div>
+<div style=3D"width:100%;height: 20px;"><span style=3D"white-space:nowrap;c=
+olor:#5F5F5F;opacity:.36;">________________________________________________=
+________________________________</span>
+</div>
+</div>
+</body>
+</html>
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_
+Content-Type: text/calendar; charset="utf-8"; method=REQUEST
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSCk1FVEhPRDpSRVFVRVNUClBST0RJRDpNaWNyb3NvZnQgRXhjaGFuZ2Ug
+U2VydmVyIDIwMTAKVkVSU0lPTjoyLjAKQkVHSU46VlRJTUVaT05FClRaSUQ6RkxFIFN0YW5kYXJk
+IFRpbWUKQkVHSU46U1RBTkRBUkQKRFRTVEFSVDoxNjAxMDEwMVQwNDAwMDAKVFpPRkZTRVRGUk9N
+OiswMzAwClRaT0ZGU0VUVE86KzAyMDAKUlJVTEU6RlJFUT1ZRUFSTFk7SU5URVJWQUw9MTtCWURB
+WT0tMVNVO0JZTU9OVEg9MTAKRU5EOlNUQU5EQVJECkJFR0lOOkRBWUxJR0hUCkRUU1RBUlQ6MTYw
+MTAxMDFUMDMwMDAwClRaT0ZGU0VURlJPTTorMDIwMApUWk9GRlNFVFRPOiswMzAwClJSVUxFOkZS
+RVE9WUVBUkxZO0lOVEVSVkFMPTE7QllEQVk9LTFTVTtCWU1PTlRIPTMKRU5EOkRBWUxJR0hUCkVO
+RDpWVElNRVpPTkUKQkVHSU46VkVWRU5UCk9SR0FOSVpFUjtDTj1NYXJnZTptYWlsdG86bWFyZ2VA
+ZXhhbXBsZS5jb20KQVRURU5ERUU7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9TkVFRFMt
+QUNUSU9OO1JTVlA9VFJVRTtDTj1iYXJ0QGUKIGV4YW1wbGUuY29tOm1haWx0bzpiYXJ0QGV4YW1w
+bGUuY29tCkFUVEVOREVFO1JPTEU9UkVRLVBBUlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElP
+TjtSU1ZQPVRSVUU7Q049aG9tZXJAZXgKIGFtcGxlLmNvbTptYWlsdG86aG9tZXJAZXhhbXBsZS5j
+b20KCkRFU0NSSVBUSU9OO0xBTkdVQUdFPWVuLVVTOlxuXG5cbl9fX19fX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19f
+X19fX19cbk1pY3Jvc29mdCBUZWFtcyAta29rb3VzXG5MaWl0eSB0aWUKIHRva29uZWVsbGEgdGFp
+IG1vYmlpbGlzb3ZlbGx1a3NlbGxhXG5MaWl0eSBrb2tvdWtzZWVuIG5hcHNhdXR0YW1hbGxhIHTD
+pHQKIMOkPGh0dHBzOi8vdGVhbXMubWljcm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLzE5JTNhbWVl
+dGluZ19NR1U1Tm1JMlpHWXRPV1ptCiBPQzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0
+MHRocmVhZC52Mi8wP2NvbnRleHQ9JTdiJTIyVGlkJTIyJTNhJTIyMgogZmQwYzFjNS0yOGUxLTQw
+YzQtOWYwZC1hMDM2M2NhODBhM2MlMjIlMmMlMjJPaWQlMjIlM2ElMjIxNDQ2NGQwOS1jZWI4LTQ1
+OGMKIC1hNjFjLTcxN2YxZTVjNjZjNSUyMiU3ZD5cbkxpc8OkdGlldG9qYTxodHRwczovL2FrYS5t
+cy9Kb2luVGVhbXNNZWV0aW5nPiB8CiAgS29rb3VzYXNldHVrc2V0PGh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9tZWV0aW5nT3B0aW9ucy8/b3JnYW5pemVySWQ9MQogNDQ2NGQwOS1jZWI4LTQ1
+OGMtYTYxYy03MTdmMWU1YzY2YzUmdGVuYW50SWQ9MmZkMGMxYzUtMjhlMS00MGM0LTlmMGQtYTAz
+NjMKIGNhODBhM2MmdGhyZWFkSWQ9MTlfbWVldGluZ19NR1U1Tm1JMlpHWXRPV1ptT0MwMFkyWm1M
+V0psT1RJdE5qVXhOakE1WWpVeVlUCiBZeUB0aHJlYWQudjImbWVzc2FnZUlkPTAmbGFuZ3VhZ2U9
+ZmktRkk+XG5fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXwogX19fX19fX19fX19fX19f
+X19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fXG4KVUlEOjA1NjAwMDAwODIwMEUwMDA3
+NEM1QjcxMDFBODJFMDA4MDAwMDAwMDAxQUY4NEM2NENCQzhENjAxMDAwMDAwMDAwMDAwMDAwCiAw
+MTAwMDAwMDA0MDNCNUFDMTBBMEVCNDQ0QTk0N0QyQjQ5OUE0Qjk4QwpTVU1NQVJZO0xBTkdVQUdF
+PWVuLVVTOlRlYW1zIG1lZXRpbmcKRFRTVEFSVDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAx
+MjAyVDE5MDAwMApEVEVORDtUWklEPUZMRSBTdGFuZGFyZCBUaW1lOjIwMjAxMjAyVDIxMDAwMApD
+TEFTUzpQVUJMSUMKUFJJT1JJVFk6NQpEVFNUQU1QOjIwMjAxMjAyVDE2NTEzNFoKVFJBTlNQOk9Q
+QVFVRQpTVEFUVVM6Q09ORklSTUVEClNFUVVFTkNFOjAKTE9DQVRJT047TEFOR1VBR0U9ZW4tVVM6
+ClgtTUlDUk9TT0ZULUNETy1BUFBULVNFUVVFTkNFOjAKWC1NSUNST1NPRlQtQ0RPLU9XTkVSQVBQ
+VElEOjIxMTg5MTUzNTQKWC1NSUNST1NPRlQtQ0RPLUJVU1lTVEFUVVM6VEVOVEFUSVZFClgtTUlD
+Uk9TT0ZULUNETy1JTlRFTkRFRFNUQVRVUzpCVVNZClgtTUlDUk9TT0ZULUNETy1BTExEQVlFVkVO
+VDpGQUxTRQpYLU1JQ1JPU09GVC1DRE8tSU1QT1JUQU5DRToxClgtTUlDUk9TT0ZULUNETy1JTlNU
+VFlQRTowClgtTUlDUk9TT0ZULVNLWVBFVEVBTVNNRUVUSU5HVVJMOmh0dHBzOi8vdGVhbXMubWlj
+cm9zb2Z0LmNvbS9sL21lZXR1cC1qb2luLwogMTklM2FtZWV0aW5nX01HVTVObUkyWkdZdE9XWm1P
+QzAwWTJabUxXSmxPVEl0TmpVeE5qQTVZalV5WVRZeSU0MHRocmVhZC52Mi8KIDA/Y29udGV4dD0l
+N2IlMjJUaWQlMjIlM2ElMjIyZmQwYzFjNS0xOGUxLTQwYzQtOWYwZC1hMDM2M2NhODBhM2MlMjIl
+MmMlMjJPCiBpZCUyMiUzYSUyMjE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxZTVjNjZjNSUy
+MiU3ZApYLU1JQ1JPU09GVC1TQ0hFRFVMSU5HU0VSVklDRVVQREFURVVSTDpodHRwczovL3NjaGVk
+dWxlci50ZWFtcy5taWNyb3NvZnQuY28KIG0vdGVhbXMvMmZkMGMxYzUtMjhlMS00MGM0LTlmMGQt
+YWIzNjNjYTgwYTNjLzE0NDY0ZDA5LWNlYjgtNDU4Yy1hNjFjLTcxN2YxCiBlNWM2NmM1LzE5X21l
+ZXRpbmdfTUdVNU5tSTJaR1l0T1dabU9DMDBZMlptTFdKbE9USXROalV4TmpBNVlqVXlZVFl5QHRo
+cmVhZAogLnYyLzAKWC1NSUNST1NPRlQtU0tZUEVURUFNU1BST1BFUlRJRVM6eyJjaWQiOiIxOTpt
+ZWV0aW5nX01HVTVObUkyWkdZdE9XWm1PQzAwWTJaCiBtTFdKbE9USXROalV4TmpBNVlqVXlZVFl5
+QHRocmVhZC52MiJcLCJyaWQiOjBcLCJtaWQiOjBcLCJ1aWQiOm51bGxcLCJwcml2YQogdGUiOnRy
+dWVcLCJ0eXBlIjowfQpYLU1JQ1JPU09GVC1PTkxJTkVNRUVUSU5HQ09ORkxJTks6Y29uZjpzaXA6
+bWFyZ2VAZXhhbXBsZS5jb21cO2dydXVcO29wCiBhcXVlPWFwcDpjb25mOmZvY3VzOmlkOnRlYW1z
+OjI6MCExOTptZWV0aW5nX01HVTVNbUkyWkdZdE9XWm1PQzAwWTJabUxXSmxPVAogSXROalV4TmpB
+NVlqVXlZVFl5LXRocmVhZC52MiExNDQ2NGQwOWNlYjg0NThjYTYxYzcxN2YxZTVjNjZjNSEyZmQw
+YzFjNTI4ZTEKIDQwYzQ5ZjBkYTAzNjNjYTgwYTNjClgtTUlDUk9TT0ZULU9OTElORU1FRVRJTkdJ
+TkZPUk1BVElPTjp7Ik9ubGluZU1lZXRpbmdDaGFubmVsSWQiOm51bGxcLCJPbmxpbgogZU1lZXRp
+bmdQcm92aWRlciI6M30KWC1NSUNST1NPRlQtRE9OT1RGT1JXQVJETUVFVElORzpGQUxTRQpYLU1J
+Q1JPU09GVC1ESVNBTExPVy1DT1VOVEVSOkZBTFNFClgtTUlDUk9TT0ZULUxPQ0FUSU9OUzpbXQpC
+RUdJTjpWQUxBUk0KREVTQ1JJUFRJT046UkVNSU5ERVIKVFJJR0dFUjtSRUxBVEVEPVNUQVJUOi1Q
+VDE1TQpBQ1RJT046RElTUExBWQpFTkQ6VkFMQVJNCkVORDpWRVZFTlQKRU5EOlZDQUxFTkRBUgo=
+
+--_000_HE1PR0802MB228346BE1576FEAB8A7F32328EF30HE1PR0802MB2283_--
diff --git a/comm/calendar/test/browser/invitations/data/update-major.eml b/comm/calendar/test/browser/invitations/data/update-major.eml
new file mode 100644
index 0000000000..04754798b2
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/update-major.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Update Major
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:2
+DTSTAMP:20220316T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T050000Z
+DTEND:20220316T053000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Single Event
+DESCRIPTION:An event invitation.
+LOCATION:Somewhere
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:This is an event reminder
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/data/update-minor.eml b/comm/calendar/test/browser/invitations/data/update-minor.eml
new file mode 100644
index 0000000000..afeb8e9ba0
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/data/update-minor.eml
@@ -0,0 +1,78 @@
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/mixed; boundary="_----------=_1647458162153312762582"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+To: receiver@example.com
+Subject: Update Minor
+From: Sender <sender@example.com>
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762582
+MIME-Version: 1.0
+Content-Transfer-Encoding: binary
+Content-Type: multipart/alternative; boundary="_----------=_1647458162153312762583"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+This is a multi-part message in MIME format.
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Length: 227
+Content-Transfer-Encoding: binary
+Content-Type: text/plain; charset="utf-8"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+Single Event
+
+When:
+ Wed, Mar 16 2022
+ 11:00 - 12:00 AST
+Where:
+ Somewhere
+
+--_----------=_1647458162153312762583
+MIME-Version: 1.0
+Content-Disposition: inline
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/calendar; charset="utf-8"; method="REQUEST"
+Date: Wed, 16 Mar 2022 15:16:02 -0400
+
+BEGIN:VCALENDAR
+VERSION:2.0
+METHOD:REQUEST
+CALSCALE:GREGORIAN
+BEGIN:VEVENT
+UID:02e79b96
+SEQUENCE:0
+DTSTAMP:20220318T191602Z
+CREATED:20220316T191532Z
+DTSTART:20220316T110000Z
+DTEND:20220316T113000Z
+DURATION:PT1H
+PRIORITY:0
+SUMMARY:Updated Event
+DESCRIPTION:Updated description.
+LOCATION:Updated location
+STATUS:CONFIRMED
+TRANSP:OPAQUE
+CLASS:PUBLIC
+ORGANIZER;CN=3DSender;
+ EMAIL=3Dsender@example.com:mailto:sender@example.com
+ATTENDEE;CN=3DSender;
+ EMAIL=3Dsender@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DACCEPTED;RSVP=3DFALSE:mailto:sender@example.com
+ATTENDEE;CN=Receiver;EMAIL=3Dreceiver@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:receiver@example.com
+ATTENDEE;CN=Other;EMAIL=other@example.com;CUTYPE=3DINDIVIDUAL;
+ PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:other@example.com
+BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-P1D
+DESCRIPTION:Updated description.
+END:VALARM
+END:VEVENT
+END:VCALENDAR
+
+--_----------=_1647458162153312762583--
diff --git a/comm/calendar/test/browser/invitations/head.js b/comm/calendar/test/browser/invitations/head.js
new file mode 100644
index 0000000000..24835c3021
--- /dev/null
+++ b/comm/calendar/test/browser/invitations/head.js
@@ -0,0 +1,942 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Common functions for the imip-bar tests.
+ *
+ * Note that these tests are heavily tied to the .eml files found in the data
+ * folder.
+ */
+
+"use strict";
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalItipDefaultEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+registerCleanupFunction(async () => {
+ // Some tests that open new windows don't return focus to the main window
+ // in a way that satisfies mochitest, and the test times out.
+ Services.focus.focusedWindow = window;
+ document.body.focus();
+});
+
+class EmailTransport extends CalItipDefaultEmailTransport {
+ sentItems = [];
+
+ sentMsgs = [];
+
+ getMsgSend() {
+ let { sentMsgs } = this;
+ return {
+ sendMessageFile(
+ userIdentity,
+ accountKey,
+ composeFields,
+ messageFile,
+ deleteSendFileOnCompletion,
+ digest,
+ deliverMode,
+ msgToReplace,
+ listener,
+ statusFeedback,
+ smtpPassword
+ ) {
+ sentMsgs.push({
+ userIdentity,
+ accountKey,
+ composeFields,
+ messageFile,
+ deleteSendFileOnCompletion,
+ digest,
+ deliverMode,
+ msgToReplace,
+ listener,
+ statusFeedback,
+ smtpPassword,
+ });
+ },
+ };
+ }
+
+ sendItems(recipients, itipItem, fromAttendee) {
+ this.sentItems.push({ recipients, itipItem, fromAttendee });
+ return super.sendItems(recipients, itipItem, fromAttendee);
+ }
+
+ reset() {
+ this.sentItems = [];
+ this.sentMsgs = [];
+ }
+}
+
+async function openMessageFromFile(file) {
+ let fileURL = Services.io
+ .newFileURI(file)
+ .mutate()
+ .setQuery("type=application/x-message-display")
+ .finalize();
+
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ window.openDialog(
+ "chrome://messenger/content/messageWindow.xhtml",
+ "_blank",
+ "all,chrome,dialog=no,status,toolbar",
+ fileURL
+ );
+ let win = await winPromise;
+ await BrowserTestUtils.waitForEvent(win, "MsgLoaded");
+ await TestUtils.waitForCondition(() => Services.focus.activeWindow == win);
+ return win;
+}
+
+/**
+ * Opens an iMIP message file and waits for the imip-bar to appear.
+ *
+ * @param {nsIFile} file
+ * @returns {Window}
+ */
+async function openImipMessage(file) {
+ let win = await openMessageFromFile(file);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let imipBar = aboutMessage.document.getElementById("imip-bar");
+ await TestUtils.waitForCondition(() => !imipBar.collapsed, "imip-bar shown");
+
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ // CalInvitationDisplay.show() does some async activities before the panel is added.
+ await TestUtils.waitForCondition(
+ () =>
+ win.document
+ .getElementById("messageBrowser")
+ .contentDocument.querySelector("calendar-invitation-panel"),
+ "calendar-invitation-panel shown"
+ );
+ }
+ return win;
+}
+
+/**
+ * Clicks on one of the imip-bar action buttons.
+ *
+ * @param {Window} win
+ * @param {string} id
+ */
+async function clickAction(win, id) {
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let action = aboutMessage.document.getElementById(id);
+ await TestUtils.waitForCondition(() => !action.hidden, `button "#${id}" shown`);
+
+ EventUtils.synthesizeMouseAtCenter(action, {}, aboutMessage);
+ await TestUtils.waitForCondition(() => action.hidden, `button "#${id}" hidden`);
+}
+
+/**
+ * Clicks on one of the imip-bar actions from a dropdown menu.
+ *
+ * @param {Window} win The window the imip message is opened in.
+ * @param {string} buttonId The id of the <toolbarbutton> containing the menu.
+ * @param {string} actionId The id of the menu item to click.
+ */
+async function clickMenuAction(win, buttonId, actionId) {
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let actionButton = aboutMessage.document.getElementById(buttonId);
+ await TestUtils.waitForCondition(() => !actionButton.hidden, `"${buttonId}" shown`);
+
+ let actionMenu = actionButton.querySelector("menupopup");
+ let menuShown = BrowserTestUtils.waitForEvent(actionMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(actionButton.querySelector("dropmarker"), {}, aboutMessage);
+ await menuShown;
+ actionMenu.activateItem(aboutMessage.document.getElementById(actionId));
+ await TestUtils.waitForCondition(() => actionButton.hidden, `action menu "#${buttonId}" hidden`);
+}
+
+const unpromotedProps = ["location", "description", "sequence", "x-moz-received-dtstamp"];
+
+/**
+ * An object where the keys are paths/selectors and the values are the values
+ * we expect to encounter.
+ *
+ * @typedef {object} Comparable
+ */
+
+/**
+ * Compares the paths specified in the expected object against the provided
+ * actual object.
+ *
+ * @param {object} actual This is expected to be a calIEvent or calIAttendee but
+ * can also be an array of both etc.
+ * @param {Comparable} expected
+ */
+function compareProperties(actual, expected, prefix = "") {
+ Assert.equal(typeof actual, "object", `${prefix || "provided value"} is an object`);
+ for (let [key, value] of Object.entries(expected)) {
+ if (key.includes(".")) {
+ let keys = key.split(".");
+ let head = keys[0];
+ let tail = keys.slice(1).join(".");
+ compareProperties(actual[head], { [tail]: value }, [prefix, head].filter(k => k).join("."));
+ continue;
+ }
+
+ let path = [prefix, key].filter(k => k).join(".");
+ let actualValue = unpromotedProps.includes(key) ? actual.getProperty(key) : actual[key];
+ Assert.equal(actualValue, value, `property "${path}" is "${value}"`);
+ }
+}
+
+/**
+ * Compares the text contents of the selectors specified on the inviatation panel
+ * to the expected value for each.
+ *
+ * @param {ShadowRoot} root The invitation panel's ShadowRoot instance.
+ * @param {Comparable} expected
+ */
+function compareShownPanelValues(root, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ value = Array.isArray(value) ? value.join("") : value;
+ Assert.equal(
+ root.querySelector(key).textContent.trim(),
+ value,
+ `property "${key}" is "${value}"`
+ );
+ }
+}
+
+/**
+ * Clicks on one of the invitation panel action buttons.
+ *
+ * @param {Window} panel
+ * @param {string} id
+ * @param {boolean} sendResponse
+ */
+async function clickPanelAction(panel, id, sendResponse = true) {
+ let promise = BrowserTestUtils.promiseAlertDialogOpen(sendResponse ? "accept" : "cancel");
+ let button = panel.shadowRoot.getElementById(id);
+ EventUtils.synthesizeMouseAtCenter(button, {}, panel.ownerGlobal);
+ await promise;
+ await BrowserTestUtils.waitForEvent(panel.ownerGlobal, "onItipItemActionFinished");
+}
+
+/**
+ * Tests that an attempt to reply to the organizer of the event with the correct
+ * details occurred.
+ *
+ * @param {EmailTransport} transport
+ * @param {nsIdentity} identity
+ * @param {string} partStat
+ */
+async function doReplyTest(transport, identity, partStat) {
+ info("Verifying the attempt to send a response uses the correct data");
+ Assert.equal(transport.sentItems.length, 1, "itip subsystem attempted to send a response");
+ compareProperties(transport.sentItems[0], {
+ "recipients.0.id": "mailto:sender@example.com",
+ "itipItem.responseMethod": "REPLY",
+ "fromAttendee.id": "mailto:receiver@example.com",
+ "fromAttendee.participationStatus": partStat,
+ });
+
+ // The itipItem is used to generate the iTIP data in the message body.
+ info("Verifying the reply calItipItem attendee list");
+ let replyItem = transport.sentItems[0].itipItem.getItemList()[0];
+ let replyAttendees = replyItem.getAttendees();
+ Assert.equal(replyAttendees.length, 1, "reply has one attendee");
+ compareProperties(replyAttendees[0], {
+ id: "mailto:receiver@example.com",
+ participationStatus: partStat,
+ });
+
+ info("Verifying the call to the message subsystem");
+ Assert.equal(transport.sentMsgs.length, 1, "transport sent 1 message");
+ compareProperties(transport.sentMsgs[0], {
+ userIdentity: identity,
+ "composeFields.from": "receiver@example.com",
+ "composeFields.to": "Sender <sender@example.com>",
+ });
+ Assert.ok(transport.sentMsgs[0].messageFile.exists(), "message file was created");
+}
+
+/**
+ * @typedef {object} ImipBarActionTestConf
+ *
+ * @property {calICalendar} calendar The calendar used for the test.
+ * @property {calIItipTranport} transport The transport used for the test.
+ * @property {nsIIdentity} identity The identity expected to be used to
+ * send the reply.
+ * @property {boolean} isRecurring Indicates whether to treat the event as a
+ * recurring event or not.
+ * @property {string} partStat The participationStatus of the receiving user to
+ * expect.
+ * @property {boolean} noReply If true, do not expect an attempt to send a reply.
+ * @property {boolean} noSend If true, expect the reply attempt to stop after the
+ * user is prompted.
+ * @property {boolean} isMajor For update tests indicates if the changes expected
+ * are major or minor.
+ */
+
+/**
+ * Test the properties of an event created from the imip-bar and optionally, the
+ * attempt to send a reply.
+ *
+ * @param {ImipBarActionTestConf} conf
+ * @param {calIEvent|calIEvent[]} item
+ */
+async function doImipBarActionTest(conf, event) {
+ let { calendar, transport, identity, partStat, isRecurring, noReply, noSend } = conf;
+ let events = [event];
+ let startDates = ["20220316T110000Z"];
+ let endDates = ["20220316T113000Z"];
+
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"];
+ endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"];
+ events = event.parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ info("Verifying relevant properties of each event occurrence");
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: isRecurring ? "Repeat Event" : "Single Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "An event invitation.",
+ location: "Somewhere",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220316T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Alarms should be ignored.
+ Assert.equal(
+ occurrence.getAlarms().length,
+ 0,
+ `${isRecurring ? "occurrence" : "event"} has no reminders`
+ );
+
+ info("Verifying attendee list and participation status");
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.participationStatus": partStat,
+ "1.id": "mailto:receiver@example.com",
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ }
+ if (noReply || noSend) {
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ return;
+ }
+ await doReplyTest(transport, identity, partStat);
+}
+
+/**
+ * Tests the recognition and application of a minor update to an existing event.
+ * An update is considered minor if the SEQUENCE property has not changed but
+ * the DTSTAMP has.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMinorUpdateTest(conf) {
+ let { transport, calendar, partStat, isRecurring } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let prevEventIcs = event.icalString;
+
+ transport.reset();
+
+ let updatePath = isRecurring ? "data/repeat-update-minor.eml" : "data/update-minor.eml";
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath)));
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let updateButton = aboutMessage.document.getElementById("imipUpdateButton");
+ Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage);
+
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ return event.icalString != prevEventIcs;
+ }, "event updated");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ let events = [event];
+ let startDates = ["20220316T110000Z"];
+ let endDates = ["20220316T113000Z"];
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T110000Z", "20220318T110000Z"];
+ endDates = [...endDates, "20220317T113000Z", "20220318T113000Z"];
+ events = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ info("Verifying relevant properties of each event occurrence");
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: "Updated Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "Updated description.",
+ location: "Updated location",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Note: It seems we do not keep the order of the attendees list for updates.
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.participationStatus": partStat,
+ "2.id": "mailto:receiver@example.com",
+ });
+ }
+
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ await calendar.deleteItem(event);
+}
+
+const actionIds = {
+ single: {
+ button: {
+ ACCEPTED: "imipAcceptButton",
+ TENTATIVE: "imipTentativeButton",
+ DECLINED: "imipDeclineButton",
+ },
+ noReply: {
+ ACCEPTED: "imipAcceptButton_AcceptDontSend",
+ TENTATIVE: "imipTentativeButton_TentativeDontSend",
+ DECLINED: "imipDeclineButton_DeclineDontSend",
+ },
+ },
+ recurring: {
+ button: {
+ ACCEPTED: "imipAcceptRecurrencesButton",
+ TENTATIVE: "imipTentativeRecurrencesButton",
+ DECLINED: "imipDeclineRecurrencesButton",
+ },
+ noReply: {
+ ACCEPTED: "imipAcceptRecurrencesButton_AcceptDontSend",
+ TENTATIVE: "imipTentativeRecurrencesButton_TentativeDontSend",
+ DECLINED: "imipDeclineRecurrencesButton_DeclineDontSend",
+ },
+ },
+};
+
+/**
+ * Tests the recognition and application of a major update to an existing event.
+ * An update is considered major if the SEQUENCE property has changed. For major
+ * updates, the imip-bar prompts the user to re-confirm their attendance.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMajorUpdateTest(conf) {
+ let { transport, identity, calendar, partStat, isRecurring, noReply } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let prevEventIcs = event.icalString;
+
+ transport.reset();
+
+ let updatePath = isRecurring ? "data/repeat-update-major.eml" : "data/update-major.eml";
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath(updatePath)));
+ let actions = isRecurring ? actionIds.recurring : actionIds.single;
+ if (noReply) {
+ let { button, noReply } = actions;
+ await clickMenuAction(win, button[partStat], noReply[partStat]);
+ } else {
+ await clickAction(win, actions.button[partStat]);
+ }
+
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ return event.icalString != prevEventIcs;
+ }, "event updated");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+
+ let events = [event];
+ let startDates = ["20220316T050000Z"];
+ let endDates = ["20220316T053000Z"];
+ if (isRecurring) {
+ startDates = [...startDates, "20220317T050000Z", "20220318T050000Z"];
+ endDates = [...endDates, "20220317T053000Z", "20220318T053000Z"];
+ events = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(events.length, 3, "reccurring event has 3 occurrences");
+ }
+
+ for (let [index, occurrence] of events.entries()) {
+ compareProperties(occurrence, {
+ id: "02e79b96",
+ title: isRecurring ? "Repeat Event" : "Single Event",
+ "calendar.name": calendar.name,
+ ...(isRecurring ? { "recurrenceId.icalString": startDates[index] } : {}),
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ description: "An event invitation.",
+ location: "Somewhere",
+ sequence: "2",
+ "x-moz-received-dtstamp": "20220316T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.participationStatus": partStat,
+ "2.id": "mailto:receiver@example.com",
+ });
+ }
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Tests the recognition and application of a minor update exception to an
+ * existing recurring event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMinorExceptionTest(conf) {
+ let { transport, calendar, partStat } = conf;
+ let recurrenceId = cal.createDateTime("20220317T110000Z");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let originalProps = {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: event.title,
+ "calendar.name": calendar.name,
+ "startDate.icalString": event.startDate.icalString,
+ "endDate.icalString": event.endDate.icalString,
+ description: event.getProperty("DESCRIPTION"),
+ location: event.getProperty("LOCATION"),
+ sequence: "0",
+ "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"),
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ };
+
+ Assert.ok(
+ !event.recurrenceInfo.getExceptionFor(recurrenceId),
+ `no exception exists for ${recurrenceId}`
+ );
+
+ transport.reset();
+
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-minor.eml")));
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let updateButton = aboutMessage.document.getElementById("imipUpdateButton");
+ Assert.ok(!updateButton.hidden, `#${updateButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(updateButton, {}, aboutMessage);
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(recurrenceId);
+ return exception;
+ }, "event exception applied");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+
+ info("Verifying relevant properties of the exception");
+ compareProperties(exception, {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: "Exception title",
+ "calendar.name": calendar.name,
+ "startDate.icalString": "20220317T110000Z",
+ "endDate.icalString": "20220317T113000Z",
+ description: "Exception description",
+ location: "Exception location",
+ sequence: "0",
+ "x-moz-received-dtstamp": "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ compareProperties(exception.getAttendees(), {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.id": "mailto:receiver@example.com",
+ "2.participationStatus": partStat,
+ });
+
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences");
+
+ info("Verifying relevant properties of the other occurrences");
+
+ let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"];
+ let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"];
+ for (let [index, occurrence] of occurrences.entries()) {
+ if (occurrence.startDate.compare(recurrenceId) == 0) {
+ continue;
+ }
+ compareProperties(occurrence, {
+ ...originalProps,
+ "recurrenceId.icalString": startDates[index],
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:receiver@example.com",
+ "1.participationStatus": partStat,
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Tests the recognition and application of a major update exception to an
+ * existing recurring event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doMajorExceptionTest(conf) {
+ let { transport, identity, calendar, partStat, noReply } = conf;
+ let recurrenceId = cal.createDateTime("20220317T110000Z");
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ let originalProps = {
+ id: "02e79b96",
+ "recurrenceId.icalString": "20220317T110000Z",
+ title: event.title,
+ "calendar.name": calendar.name,
+ "startDate.icalString": event.startDate.icalString,
+ "endDate.icalString": event.endDate.icalString,
+ description: event.getProperty("DESCRIPTION"),
+ location: event.getProperty("LOCATION"),
+ sequence: "0",
+ "x-moz-received-dtstamp": event.getProperty("x-moz-received-dtstamp"),
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ };
+ let originalPartStat = event
+ .getAttendees()
+ .find(att => att.id == "mailto:receiver@example.com").participationStatus;
+
+ Assert.ok(
+ !event.recurrenceInfo.getExceptionFor(recurrenceId),
+ `no exception exists for ${recurrenceId}`
+ );
+
+ transport.reset();
+
+ let win = await openImipMessage(new FileUtils.File(getTestFilePath("data/exception-major.eml")));
+ if (noReply) {
+ let { button, noReply } = actionIds.single;
+ await clickMenuAction(win, button[partStat], noReply[partStat]);
+ } else {
+ await clickAction(win, actionIds.single.button[partStat]);
+ }
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(recurrenceId);
+ return exception;
+ }, "event exception applied");
+
+ await BrowserTestUtils.closeWindow(win);
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+
+ info("Verifying relevant properties of the exception");
+
+ compareProperties(exception, {
+ ...originalProps,
+ "startDate.icalString": "20220317T050000Z",
+ "endDate.icalString": "20220317T053000Z",
+ sequence: "2",
+ });
+
+ compareProperties(exception.getAttendees(), {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:other@example.com",
+ "1.participationStatus": "NEEDS-ACTION",
+ "2.id": "mailto:receiver@example.com",
+ "2.participationStatus": partStat,
+ });
+
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 3, "reccurring event still has 3 occurrences");
+
+ info("Verifying relevant properties of the other occurrences");
+
+ let startDates = ["20220316T110000Z", "20220317T110000Z", "20220318T110000Z"];
+ let endDates = ["20220316T113000Z", "20220317T113000Z", "20220318T113000Z"];
+ for (let [index, occurrence] of occurrences.entries()) {
+ if (occurrence.startDate.icalString == "20220317T050000Z") {
+ continue;
+ }
+ compareProperties(occurrence, {
+ ...originalProps,
+ "recurrenceId.icalString": startDates[index],
+ "startDate.icalString": startDates[index],
+ "endDate.icalString": endDates[index],
+ });
+
+ let attendees = occurrence.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.id": "mailto:receiver@example.com",
+ "1.participationStatus": originalPartStat,
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+ }
+
+ await calendar.deleteItem(event);
+}
+
+/**
+ * Test the properties of an event created from a minor or major exception where
+ * we have not added the original event and optionally, the attempt to send a
+ * reply.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doExceptionOnlyTest(conf) {
+ let { calendar, transport, identity, partStat, noReply, isMajor } = conf;
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1)).item;
+
+ // Exceptions are still created as recurring events.
+ Assert.ok(event != event.parentItem, "event created is a recurring event");
+ let occurrences = event.parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("10000101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ Assert.equal(occurrences.length, 1, "parent item only has one occurrence");
+ Assert.ok(occurrences[0] == event, "occurrence is the event exception");
+
+ info("Verifying relevant properties of the event");
+ compareProperties(event, {
+ id: "02e79b96",
+ title: isMajor ? event.title : "Exception title",
+ "calendar.name": calendar.name,
+ "recurrenceId.icalString": "20220317T110000Z",
+ "startDate.icalString": isMajor ? "20220317T050000Z" : "20220317T110000Z",
+ "endDate.icalString": isMajor ? "20220317T053000Z" : "20220317T113000Z",
+ description: isMajor ? event.getProperty("DESCRIPTION") : "Exception description",
+ location: isMajor ? event.getProperty("LOCATION") : "Exception location",
+ sequence: isMajor ? "2" : "0",
+ "x-moz-received-dtstamp": isMajor
+ ? event.getProperty("x-moz-received-dtstamp")
+ : "20220318T191602Z",
+ "organizer.id": "mailto:sender@example.com",
+ status: "CONFIRMED",
+ });
+
+ // Alarms should be ignored.
+ Assert.equal(event.getAlarms().length, 0, "event has no reminders");
+
+ info("Verifying attendee list and participation status");
+ let attendees = event.getAttendees();
+ compareProperties(attendees, {
+ "0.id": "mailto:sender@example.com",
+ "0.participationStatus": "ACCEPTED",
+ "1.participationStatus": partStat,
+ "1.id": "mailto:receiver@example.com",
+ "2.id": "mailto:other@example.com",
+ "2.participationStatus": "NEEDS-ACTION",
+ });
+
+ if (noReply) {
+ Assert.equal(
+ transport.sentItems.length,
+ 0,
+ "itip subsystem did not attempt to send a response"
+ );
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+ } else {
+ await doReplyTest(transport, identity, partStat);
+ }
+ await calendar.deleteItem(event.parentItem);
+}
+
+/**
+ * Tests the recognition and application of a cancellation to an existing event.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doCancelTest({ transport, calendar, isRecurring, event, recurrenceId }) {
+ transport.reset();
+
+ let eventId = event.id;
+ if (isRecurring) {
+ // wait for the other occurrences to appear.
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 5, 1);
+ await CalendarTestUtils.monthView.waitForItemAt(window, 3, 6, 1);
+ }
+
+ let cancellationPath = isRecurring
+ ? "data/cancel-repeat-event.eml"
+ : "data/cancel-single-event.eml";
+
+ let cancelMsgFile = new FileUtils.File(getTestFilePath(cancellationPath));
+ if (recurrenceId) {
+ let srcTxt = await IOUtils.readUTF8(cancelMsgFile.path);
+ srcTxt = srcTxt.replaceAll(/RRULE:.+/g, `RECURRENCE-ID:${recurrenceId}`);
+ srcTxt = srcTxt.replaceAll(/SEQUENCE:.+/g, "SEQUENCE:3");
+ cancelMsgFile = FileTestUtils.getTempFile("cancel-occurrence.eml");
+ await IOUtils.writeUTF8(cancelMsgFile.path, srcTxt);
+ }
+
+ let win = await openImipMessage(cancelMsgFile);
+ let aboutMessage = win.document.getElementById("messageBrowser").contentWindow;
+ let deleteButton = aboutMessage.document.getElementById("imipDeleteButton");
+ Assert.ok(!deleteButton.hidden, `#${deleteButton.id} button shown`);
+ EventUtils.synthesizeMouseAtCenter(deleteButton, {}, aboutMessage);
+
+ if (isRecurring && recurrenceId) {
+ // Expects a single occurrence to be cancelled.
+
+ let occurrences;
+ await TestUtils.waitForCondition(async () => {
+ let { parentItem } = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item;
+ occurrences = parentItem.recurrenceInfo.getOccurrences(
+ cal.createDateTime("19700101"),
+ cal.createDateTime("30000101"),
+ Infinity
+ );
+ return occurrences.length == 2;
+ }, "occurrence was deleted");
+
+ Assert.ok(
+ occurrences.every(occ => occ.recurrenceId && occ.recurrenceId != recurrenceId),
+ `occurrence "${recurrenceId}" removed`
+ );
+ Assert.ok(!!(await calendar.getItem(eventId)), "event was not deleted");
+ } else {
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 4, 1);
+
+ if (isRecurring) {
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 5, 1);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 3, 6, 1);
+ }
+
+ await TestUtils.waitForCondition(async () => {
+ let result = await calendar.getItem(eventId);
+ return !result;
+ }, "event was deleted");
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+ Assert.equal(transport.sentItems.length, 0, "itip subsystem did not attempt to send a response");
+ Assert.equal(transport.sentMsgs.length, 0, "no call was made into the mail subsystem");
+}
+
+/**
+ * Tests processing of cancellations to exceptions to recurring events.
+ *
+ * @param {ImipBarActionTestConf} conf
+ */
+async function doCancelExceptionTest(conf) {
+ let { partStat, recurrenceId, calendar } = conf;
+ let invite = new FileUtils.File(getTestFilePath("data/repeat-event.eml"));
+ let win = await openImipMessage(invite);
+ await clickAction(win, actionIds.recurring.button[partStat]);
+
+ let event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ await BrowserTestUtils.closeWindow(win);
+
+ let update = new FileUtils.File(getTestFilePath("data/exception-major.eml"));
+ let updateWin = await openImipMessage(update);
+ await clickAction(updateWin, actionIds.single.button[partStat]);
+
+ let exception;
+ await TestUtils.waitForCondition(async () => {
+ event = (await CalendarTestUtils.monthView.waitForItemAt(window, 3, 4, 1)).item.parentItem;
+ exception = event.recurrenceInfo.getExceptionFor(cal.createDateTime(recurrenceId));
+ return !!exception;
+ }, "exception applied");
+
+ await BrowserTestUtils.closeWindow(updateWin);
+ await doCancelTest({ ...conf, event });
+ await calendar.deleteItem(event);
+}
diff --git a/comm/calendar/test/browser/preferences/browser.ini b/comm/calendar/test/browser/preferences/browser.ini
new file mode 100644
index 0000000000..a06213b220
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser.ini
@@ -0,0 +1,16 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_alarmDefaultValue.js]
+[browser_categoryColors.js]
diff --git a/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js
new file mode 100644
index 0000000000..b1cde7cf11
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser_alarmDefaultValue.js
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test default alarm settings for events and tasks
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { cancelItemDialog } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const DEFVALUE = 43;
+
+add_task(async function testDefaultAlarms() {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+ calendar.setProperty("calendar-main-default", true);
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let localeUnitString = cal.l10n.getCalString("unitDays");
+ let unitString = PluralForm.get(DEFVALUE, localeUnitString).replace("#1", DEFVALUE);
+ let alarmString = (...args) => cal.l10n.getString("calendar-alarms", ...args);
+ let originStringEvent = alarmString("reminderCustomOriginBeginBeforeEvent");
+ let originStringTask = alarmString("reminderCustomOriginBeginBeforeTask");
+ let expectedEventReminder = alarmString("reminderCustomTitle", [unitString, originStringEvent]);
+ let expectedTaskReminder = alarmString("reminderCustomTitle", [unitString, originStringTask]);
+
+ // Configure the preferences.
+ let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "defaultsnoozelength");
+ await handlePrefTab(prefsWindow, prefsDocument);
+
+ // Create New Event.
+ await CalendarTestUtils.openCalendarTab(window);
+
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(window);
+
+ Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom");
+ let reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label");
+ Assert.equal(reminderDetails.value, expectedEventReminder);
+
+ let reminderDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-reminder.xhtml",
+ { callback: handleReminderDialog }
+ );
+ EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow);
+ await reminderDialogPromise;
+
+ let promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ cancelItemDialog(dialogWindow);
+ await promptPromise;
+
+ // Create New Task.
+ await openTasksTab();
+ ({ dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewTask(window));
+
+ Assert.equal(iframeDocument.querySelector(".item-alarm").value, "custom");
+ reminderDetails = iframeDocument.querySelector(".reminder-single-alarms-label");
+ Assert.equal(reminderDetails.value, expectedTaskReminder);
+
+ reminderDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ null,
+ "chrome://calendar/content/calendar-event-dialog-reminder.xhtml",
+ { callback: handleReminderDialog }
+ );
+ EventUtils.synthesizeMouseAtCenter(reminderDetails, {}, iframeWindow);
+ await reminderDialogPromise;
+
+ promptPromise = BrowserTestUtils.promiseAlertDialog("extra1");
+ cancelItemDialog(dialogWindow);
+ await promptPromise;
+});
+
+async function handlePrefTab(prefsWindow, prefsDocument) {
+ function menuList(id, value) {
+ let list = prefsDocument.getElementById(id);
+ list.scrollIntoView();
+ list.click();
+ list.querySelector(`menuitem[value="${value}"]`).click();
+ }
+ // Turn on alarms for events and tasks.
+ menuList("eventdefalarm", "1");
+ menuList("tododefalarm", "1");
+
+ // Selects "days" as a unit.
+ menuList("tododefalarmunit", "days");
+ menuList("eventdefalarmunit", "days");
+
+ function text(id, value) {
+ let input = prefsDocument.getElementById(id);
+ input.scrollIntoView();
+ EventUtils.synthesizeMouse(input, 5, 5, {}, prefsWindow);
+ Assert.equal(prefsDocument.activeElement, input);
+ EventUtils.synthesizeKey("a", { accelKey: true }, prefsWindow);
+ EventUtils.sendString(value, prefsWindow);
+ }
+ // Sets default alarm length for events to DEFVALUE.
+ text("eventdefalarmlen", DEFVALUE.toString());
+ text("tododefalarmlen", DEFVALUE.toString());
+
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.onforevents"), 1);
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.eventalarmlen"), DEFVALUE);
+ Assert.equal(Services.prefs.getStringPref("calendar.alarms.eventalarmunit"), "days");
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.onfortodos"), 1);
+ Assert.equal(Services.prefs.getIntPref("calendar.alarms.todoalarmlen"), DEFVALUE);
+ Assert.equal(Services.prefs.getStringPref("calendar.alarms.todoalarmunit"), "days");
+}
+
+async function handleReminderDialog(remindersWindow) {
+ await new Promise(remindersWindow.setTimeout);
+ let remindersDocument = remindersWindow.document;
+
+ let listbox = remindersDocument.getElementById("reminder-listbox");
+ Assert.equal(listbox.selectedCount, 1);
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ EventUtils.synthesizeMouseAtCenter(
+ remindersDocument.getElementById("reminder-new-button"),
+ {},
+ remindersWindow
+ );
+ Assert.equal(listbox.itemCount, 2);
+ Assert.equal(listbox.selectedCount, 1);
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ function text(id, value) {
+ let input = remindersDocument.getElementById(id);
+ EventUtils.synthesizeMouse(input, 5, 5, {}, remindersWindow);
+ Assert.equal(remindersDocument.activeElement, input);
+ EventUtils.synthesizeKey("a", { accelKey: true }, remindersWindow);
+ EventUtils.sendString(value, remindersWindow);
+ }
+ text("reminder-length", "20");
+ Assert.equal(listbox.selectedItem.reminder.offset.days, 20);
+
+ EventUtils.synthesizeMouseAtCenter(listbox, {}, remindersWindow);
+ EventUtils.synthesizeKey("VK_UP", {}, remindersWindow);
+ Assert.equal(listbox.selectedIndex, 0);
+
+ Assert.equal(listbox.selectedItem.reminder.offset.days, DEFVALUE);
+
+ remindersDocument.querySelector("dialog").getButton("accept").click();
+}
+
+async function openTasksTab() {
+ let tabmail = document.getElementById("tabmail");
+ let tasksMode = tabmail.tabModes.tasks;
+
+ if (tasksMode.tabs.length == 1) {
+ tabmail.selectedTab = tasksMode.tabs[0];
+ } else {
+ let tasksTabButton = document.getElementById("tasksButton");
+ EventUtils.synthesizeMouseAtCenter(tasksTabButton, { clickCount: 1 });
+ }
+
+ is(tasksMode.tabs.length, 1, "tasks tab is open");
+ is(tabmail.selectedTab, tasksMode.tabs[0], "tasks tab is selected");
+
+ await new Promise(resolve => setTimeout(resolve));
+}
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("calendar.alarms.onforevents");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmlen");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.onfortodos");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmlen");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmunit");
+});
diff --git a/comm/calendar/test/browser/preferences/browser_categoryColors.js b/comm/calendar/test/browser/preferences/browser_categoryColors.js
new file mode 100644
index 0000000000..29ad21ce13
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/browser_categoryColors.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+add_task(async function testCategoryColors() {
+ let calendar = CalendarTestUtils.createCalendar("Mochitest", "memory");
+
+ registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ let { prefsWindow, prefsDocument } = await openNewPrefsTab("paneCalendar", "categorieslist");
+
+ let listBox = prefsDocument.getElementById("categorieslist");
+ Assert.equal(listBox.itemChildren.length, 22);
+
+ for (let item of listBox.itemChildren) {
+ info(`${item.firstElementChild.value}: ${item.lastElementChild.style.backgroundColor}`);
+ Assert.ok(item.lastElementChild.style.backgroundColor);
+ }
+
+ // Edit the name and colour of a built-in category.
+
+ let subDialogPromise = BrowserTestUtils.waitForEvent(
+ prefsWindow.gSubDialog._dialogStack,
+ "dialogopen"
+ );
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, 0);
+ EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow);
+
+ await subDialogPromise;
+
+ let subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame;
+ let subDialogDocument = subDialogBrowser.contentDocument;
+ subDialogDocument.getElementById("categoryName").value = "ZZZ Mochitest";
+ subDialogDocument.getElementById("categoryColor").value = "#00CC00";
+ subDialogDocument.body.firstElementChild.getButton("accept").click();
+
+ let listItem = listBox.itemChildren[listBox.itemCount - 1];
+ Assert.equal(listBox.selectedItem, listItem);
+ Assert.equal(listItem.firstElementChild.value, "ZZZ Mochitest");
+ Assert.equal(listItem.lastElementChild.style.backgroundColor, "rgb(0, 204, 0)");
+ Assert.equal(Services.prefs.getCharPref("calendar.category.color.zzz_mochitest"), "#00cc00");
+
+ // Remove the colour of a built-in category.
+
+ subDialogPromise = BrowserTestUtils.waitForEvent(
+ prefsWindow.gSubDialog._dialogStack,
+ "dialogopen"
+ );
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ EventUtils.synthesizeKey("VK_HOME", {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, 0);
+ let itemName = listBox.itemChildren[0].firstElementChild.value;
+ EventUtils.synthesizeMouseAtCenter(prefsDocument.getElementById("editCButton"), {}, prefsWindow);
+
+ await subDialogPromise;
+
+ subDialogBrowser = prefsWindow.gSubDialog._topDialog._frame;
+ await new Promise(subDialogBrowser.contentWindow.setTimeout);
+ subDialogDocument = subDialogBrowser.contentDocument;
+ subDialogDocument.getElementById("useColor").checked = false;
+ subDialogDocument.body.firstElementChild.getButton("accept").click();
+
+ listItem = listBox.itemChildren[0];
+ Assert.equal(listBox.selectedItem, listItem);
+ Assert.equal(listItem.firstElementChild.value, itemName);
+ Assert.equal(listItem.lastElementChild.style.backgroundColor, "");
+ Assert.equal(Services.prefs.getCharPref(`calendar.category.color.${itemName.toLowerCase()}`), "");
+
+ // Remove the added category.
+
+ EventUtils.synthesizeMouse(listBox, 5, 5, {}, prefsWindow);
+ EventUtils.synthesizeKey("VK_END", {}, prefsWindow);
+ Assert.equal(listBox.selectedIndex, listBox.itemCount - 1);
+ EventUtils.synthesizeMouseAtCenter(
+ prefsDocument.getElementById("deleteCButton"),
+ {},
+ prefsWindow
+ );
+});
diff --git a/comm/calendar/test/browser/preferences/head.js b/comm/calendar/test/browser/preferences/head.js
new file mode 100644
index 0000000000..8d6d5d3ab5
--- /dev/null
+++ b/comm/calendar/test/browser/preferences/head.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals openPreferencesTab */
+
+async function openNewPrefsTab(paneID, scrollPaneTo, otherArgs) {
+ let tabmail = document.getElementById("tabmail");
+ let prefsTabMode = tabmail.tabModes.preferencesTab;
+
+ Assert.equal(prefsTabMode.tabs.length, 0, "Prefs tab is not open");
+
+ let prefsDocument = await new Promise(resolve => {
+ Services.obs.addObserver(function documentLoaded(subject) {
+ if (subject.URL.startsWith("about:preferences")) {
+ Services.obs.removeObserver(documentLoaded, "chrome-document-loaded");
+ resolve(subject);
+ }
+ }, "chrome-document-loaded");
+ openPreferencesTab(paneID, scrollPaneTo, otherArgs);
+ });
+ Assert.ok(prefsDocument.URL.startsWith("about:preferences"), "Prefs tab is open");
+
+ prefsDocument = prefsTabMode.tabs[0].browser.contentDocument;
+ let prefsWindow = prefsDocument.ownerGlobal;
+ prefsWindow.resizeTo(screen.availWidth, screen.availHeight);
+ if (paneID) {
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ Assert.equal(prefsWindow.gLastCategory.category, paneID, `Selected pane is ${paneID}`);
+ } else {
+ // If we don't wait here for other scripts to run, they
+ // could be in a bad state if our test closes the tab.
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ }
+
+ registerCleanupOnce();
+
+ await new Promise(resolve => prefsWindow.setTimeout(resolve));
+ if (scrollPaneTo) {
+ Assert.greater(
+ prefsDocument.getElementById("preferencesContainer").scrollTop,
+ 0,
+ "Prefs page did scroll when it was supposed to"
+ );
+ }
+ return { prefsDocument, prefsWindow };
+}
+
+function registerCleanupOnce() {
+ if (registerCleanupOnce.alreadyRegistered) {
+ return;
+ }
+ registerCleanupFunction(closePrefsTab);
+ registerCleanupOnce.alreadyRegistered = true;
+}
+
+async function closePrefsTab() {
+ info("Closing prefs tab");
+ let tabmail = document.getElementById("tabmail");
+ let prefsTab = tabmail.tabModes.preferencesTab.tabs[0];
+ if (prefsTab) {
+ tabmail.closeTab(prefsTab);
+ }
+}
diff --git a/comm/calendar/test/browser/providers/browser.ini b/comm/calendar/test/browser/providers/browser.ini
new file mode 100644
index 0000000000..84d6133696
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser.ini
@@ -0,0 +1,21 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.debug.log=true
+ calendar.debug.log.verbose=true
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_caldavCalendar_cached.js]
+[browser_caldavCalendar_uncached.js]
+[browser_icsCalendar_cached.js]
+[browser_icsCalendar_uncached.js]
+[browser_storageCalendar.js]
diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js
new file mode 100644
index 0000000000..5b725e4d54
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_cached.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+CalDAVServer.open("bob", "bob");
+if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ // This test has issues cleaning up, and it breaks all the subsequent tests.
+ await new Promise(r => setTimeout(r, 1000)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+ await CalDAVServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(() => !calendar.wrappedJSObject.mPendingSync);
+ await fetch(`${CalDAVServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part1Item
+ );
+ await syncChangesTest.runPart1();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part2Item
+ );
+ await syncChangesTest.runPart2();
+
+ CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics");
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js
new file mode 100644
index 0000000000..7489ae4e09
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_caldavCalendar_uncached.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+CalDAVServer.open("bob", "bob");
+if (!Services.logins.findLogins(CalDAVServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(CalDAVServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await CalDAVServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await fetch(`${CalDAVServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part1Item
+ );
+ await syncChangesTest.runPart1();
+
+ await CalDAVServer.putItemInternal(
+ "ad0850e5-8020-4599-86a4-86c90af4e2cd.ics",
+ syncChangesTest.part2Item
+ );
+ await syncChangesTest.runPart2();
+
+ CalDAVServer.deleteItemInternal("ad0850e5-8020-4599-86a4-86c90af4e2cd.ics");
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js
new file mode 100644
index 0000000000..ba788be5b9
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_icsCalendar_cached.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open("bob", "bob");
+if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ // TODO: item notifications from a cached ICS calendar occur outside of batches.
+ // This isn't fatal but it shouldn't happen. Side-effects include alarms firing
+ // twice - once from onAddItem then again at onLoad.
+ //
+ // Remove the next line when this is fixed.
+ calendarObserver._batchRequired = false;
+
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("ics", ICSServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await ICSServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(
+ () =>
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._queue.length == 0 &&
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject._isLocked === false
+ );
+ await fetch(`${ICSServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ // Remove the next line when fixed.
+ calendarObserver._batchRequired = false;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+}).skip(); // Broken.
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await ICSServer.putICSInternal(syncChangesTest.part1Item);
+ await syncChangesTest.runPart1();
+
+ await ICSServer.putICSInternal(syncChangesTest.part2Item);
+ await syncChangesTest.runPart2();
+
+ await ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ END:VCALENDAR
+ `
+ );
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js
new file mode 100644
index 0000000000..ef25408dce
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_icsCalendar_uncached.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open("bob", "bob");
+if (!Services.logins.findLogins(ICSServer.origin, null, "test").length) {
+ // Save a username and password to the login manager.
+ let loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ loginInfo.init(ICSServer.origin, null, "test", "bob", "bob", "", "");
+ Services.logins.addLogin(loginInfo);
+}
+
+let calendar;
+add_setup(async function () {
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar = createCalendar("ics", ICSServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ registerCleanupFunction(async () => {
+ await ICSServer.close();
+ Services.logins.removeAllLogins();
+ removeCalendar(calendar);
+ });
+});
+
+async function promiseIdle() {
+ await TestUtils.waitForCondition(
+ () =>
+ calendar.wrappedJSObject._queue.length == 0 && calendar.wrappedJSObject._isLocked === false
+ );
+ await fetch(`${ICSServer.origin}/ping`);
+}
+
+add_task(async function testAlarms() {
+ calendarObserver._batchRequired = true;
+ await runTestAlarms(calendar);
+
+ // Be sure the calendar has finished deleting the event.
+ await promiseIdle();
+});
+
+add_task(async function testSyncChanges() {
+ await syncChangesTest.setUp();
+
+ await ICSServer.putICSInternal(syncChangesTest.part1Item);
+ await syncChangesTest.runPart1();
+
+ await ICSServer.putICSInternal(syncChangesTest.part2Item);
+ await syncChangesTest.runPart2();
+
+ await ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ END:VCALENDAR
+ `
+ );
+ await syncChangesTest.runPart3();
+
+ // Be sure the calendar has finished all requests.
+ await promiseIdle();
+});
diff --git a/comm/calendar/test/browser/providers/browser_storageCalendar.js b/comm/calendar/test/browser/providers/browser_storageCalendar.js
new file mode 100644
index 0000000000..1a9eb6a30c
--- /dev/null
+++ b/comm/calendar/test/browser/providers/browser_storageCalendar.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let calendar = createCalendar("storage", "moz-storage-calendar://");
+registerCleanupFunction(() => {
+ removeCalendar(calendar);
+});
+
+add_task(function testAlarms() {
+ calendarObserver._batchRequired = false;
+ return runTestAlarms(calendar);
+});
diff --git a/comm/calendar/test/browser/providers/head.js b/comm/calendar/test/browser/providers/head.js
new file mode 100644
index 0000000000..bf58302131
--- /dev/null
+++ b/comm/calendar/test/browser/providers/head.js
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+SimpleTest.requestCompleteLog();
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+let calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ /* calIObserver */
+
+ _batchCount: 0,
+ _batchRequired: true,
+ onStartBatch(calendar) {
+ info(`onStartBatch ${calendar?.id} ${++this._batchCount}`);
+ Assert.equal(
+ calendar,
+ this._expectedCalendar,
+ "onStartBatch should occur on the expected calendar"
+ );
+ },
+ onEndBatch(calendar) {
+ info(`onEndBatch ${calendar?.id} ${this._batchCount--}`);
+ Assert.equal(
+ calendar,
+ this._expectedCalendar,
+ "onEndBatch should occur on the expected calendar"
+ );
+ },
+ onLoad(calendar) {
+ info(`onLoad ${calendar.id}`);
+ Assert.equal(calendar, this._expectedCalendar, "onLoad should occur on the expected calendar");
+ if (this._onLoadPromise) {
+ this._onLoadPromise.resolve();
+ }
+ },
+ onAddItem(item) {
+ info(`onAddItem ${item.calendar.id} ${item.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onAddItem must occur in a batch");
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch");
+ }
+ },
+ onDeleteItem(deletedItem) {
+ info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`);
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, value, oldValue) {},
+ onPropertyDeleting(calendar, name) {},
+};
+
+/**
+ * Create and register a calendar.
+ *
+ * @param {string} type - The calendar provider to use.
+ * @param {string} url - URL of the server.
+ * @param {boolean} useCache - Should this calendar have offline storage?
+ * @returns {calICalendar}
+ */
+function createCalendar(type, url, useCache) {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(url));
+ calendar.name = type + (useCache ? " with cache" : " without cache");
+ calendar.id = cal.getUUID();
+ calendar.setProperty("cache.enabled", useCache);
+ calendar.setProperty("calendar-main-default", true);
+
+ cal.manager.registerCalendar(calendar);
+ calendar = cal.manager.getCalendarById(calendar.id);
+ calendarObserver._expectedCalendar = calendar;
+ calendar.addObserver(calendarObserver);
+
+ info(`Created calendar ${calendar.id}`);
+ return calendar;
+}
+
+/**
+ * Unregister a calendar.
+ *
+ * @param {calICalendar} calendar
+ */
+function removeCalendar(calendar) {
+ calendar.removeObserver(calendarObserver);
+ cal.manager.removeCalendar(calendar);
+}
+
+let alarmService = Cc["@mozilla.org/calendar/alarm-service;1"].getService(Ci.calIAlarmService);
+
+let alarmObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]),
+
+ /* calIAlarmServiceObserver */
+
+ _alarmCount: 0,
+ onAlarm(item, alarm) {
+ info("onAlarm");
+ this._alarmCount++;
+ },
+ onRemoveAlarmsByItem(item) {},
+ onRemoveAlarmsByCalendar(calendar) {},
+ onAlarmsLoaded(calendar) {},
+};
+alarmService.addObserver(alarmObserver);
+registerCleanupFunction(async () => {
+ alarmService.removeObserver(alarmObserver);
+});
+
+/**
+ * Tests the creation, firing, dismissal, modification and deletion of an event with an alarm.
+ * Also checks that the number of events in the unifinder is correct at each stage.
+ *
+ * Passing this test requires the active calendar to fire notifications in the correct sequence.
+ */
+async function runTestAlarms() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day++;
+ start.hour = start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+ let repeatUntil = start.clone();
+ repeatUntil.day += 15;
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToToday(window);
+ Assert.equal(window.unifinderTreeView.rowCount, 0, "unifinder event count");
+
+ alarmObserver._alarmCount = 0;
+
+ let alarmDialogPromise = BrowserTestUtils.promiseAlertDialog(
+ undefined,
+ "chrome://calendar/content/calendar-alarm-dialog.xhtml",
+ {
+ async callback(alarmWindow) {
+ info("Alarm dialog opened");
+ let alarmDocument = alarmWindow.document;
+
+ let list = alarmDocument.getElementById("alarm-richlist");
+ let items = list.querySelectorAll(`richlistitem[is="calendar-alarm-widget-richlistitem"]`);
+ await TestUtils.waitForCondition(() => items.length);
+ Assert.equal(items.length, 1);
+
+ await new Promise(resolve => alarmWindow.setTimeout(resolve, 500));
+
+ let dismissButton = alarmDocument.querySelector("#alarm-dismiss-all-button");
+ EventUtils.synthesizeMouseAtCenter(dismissButton, {}, alarmWindow);
+ },
+ }
+ );
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window);
+ await setData(dialogWindow, iframeWindow, {
+ title: "test event",
+ startdate: start,
+ starttime: start,
+ enddate: end,
+ endtime: end,
+ reminder: "2days",
+ repeat: "weekly",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ await alarmDialogPromise;
+ info("Alarm dialog closed");
+
+ await new Promise(r => setTimeout(r, 2000));
+ Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder");
+
+ Assert.equal(
+ [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length,
+ 0,
+ "alarm dialog did not reappear"
+ );
+ Assert.equal(alarmObserver._alarmCount, 1, "only one alarm");
+ alarmObserver._alarmCount = 0;
+
+ let eventBox = await CalendarTestUtils.multiweekView.waitForItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.ok(!!eventBox.item.parentItem.alarmLastAck);
+
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.editItemOccurrences(window, eventBox));
+ await setData(dialogWindow, iframeWindow, {
+ title: "modified test event",
+ repeat: "weekly",
+ repeatuntil: repeatUntil,
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ Assert.equal(window.unifinderTreeView.rowCount, 1, "there should be one event in the unifinder");
+
+ Services.focus.focusedWindow = window;
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ Assert.equal(
+ [...Services.wm.getEnumerator("Calendar:AlarmWindow")].length,
+ 0,
+ "alarm dialog should not reappear"
+ );
+ Assert.equal(alarmObserver._alarmCount, 0, "there should not be any remaining alarms");
+ alarmObserver._alarmCount = 0;
+
+ eventBox = await CalendarTestUtils.multiweekView.waitForItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.ok(!!eventBox.item.parentItem.alarmLastAck);
+
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ window.calendarController.onSelectionChanged({ detail: window.currentView().getSelectedItems() });
+ await handleDeleteOccurrencePrompt(window, window.currentView(), true);
+
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(
+ window,
+ start.weekday == 0 ? 2 : 1, // Sunday's event is next week.
+ start.weekday + 1,
+ 1
+ );
+ Assert.equal(window.unifinderTreeView.rowCount, 0, "there should be no events in the unifinder");
+}
+
+const syncItem1Name = "holy cow, a new item!";
+const syncItem2Name = "a changed item";
+
+let syncChangesTest = {
+ async setUp() {
+ await CalendarTestUtils.openCalendarTab(window);
+
+ if (document.getElementById("today-pane-panel").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendar-status-todaypane-button"),
+ {}
+ );
+ }
+
+ if (document.getElementById("agenda-panel").collapsed) {
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("today-pane-cycler-next"), {});
+ }
+ },
+
+ get part1Item() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day += 9 - start.weekday;
+ start.hour = 13;
+ start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+
+ return CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:ad0850e5-8020-4599-86a4-86c90af4e2cd
+ SUMMARY:${syncItem1Name}
+ DTSTART:${start.icalString}
+ DTEND:${end.icalString}
+ END:VEVENT
+ END:VCALENDAR
+ `;
+ },
+
+ async runPart1() {
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToToday(window);
+
+ // Sanity check that we have not already synchronized and that there is no
+ // existing item.
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 2, 3, 1),
+ "there should be no existing item in the calendar"
+ );
+
+ // Synchronize.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {});
+
+ // Verify that the item we added appears in the calendar view.
+ let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 3, 1);
+ Assert.equal(item.item.title, syncItem1Name, "view should include newly-added item");
+
+ // Verify that the today pane updates and shows the item we added.
+ await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 1);
+ Assert.equal(
+ getTodayPaneItemTitle(0),
+ syncItem1Name,
+ "today pane should include newly-added item"
+ );
+ Assert.ok(
+ !window.TodayPane.agenda.rows[0].nextElementSibling,
+ "there should be no additional items in the today pane"
+ );
+ },
+
+ get part2Item() {
+ let today = cal.dtz.now();
+ let start = today.clone();
+ start.day += 10 - start.weekday;
+ start.hour = 9;
+ start.minute = start.second = 0;
+ let end = start.clone();
+ end.hour++;
+
+ return CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:ad0850e5-8020-4599-86a4-86c90af4e2cd
+ SUMMARY:${syncItem2Name}
+ DTSTART:${start.icalString}
+ DTEND:${end.icalString}
+ END:VEVENT
+ END:VCALENDAR
+ `;
+ },
+
+ async runPart2() {
+ // Sanity check that we have not already synchronized and that there is no
+ // existing item.
+ Assert.ok(
+ !CalendarTestUtils.multiweekView.getItemAt(window, 2, 4, 1),
+ "there should be no existing item on the specified day"
+ );
+
+ // Synchronize.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("refreshCalendar"), {});
+
+ // Verify that the item has updated in the calendar view.
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1);
+ let item = await CalendarTestUtils.multiweekView.waitForItemAt(window, 2, 4, 1);
+ Assert.equal(item.item.title, syncItem2Name, "view should show updated item");
+
+ // Verify that the today pane updates and shows the updated item.
+ await TestUtils.waitForCondition(
+ () => window.TodayPane.agenda.rowCount == 1 && getTodayPaneItemTitle(0) != syncItem1Name
+ );
+ Assert.equal(getTodayPaneItemTitle(0), syncItem2Name, "today pane should show updated item");
+ Assert.ok(
+ !window.TodayPane.agenda.rows[0].nextElementSibling,
+ "there should be no additional items in the today pane"
+ );
+ },
+
+ async runPart3() {
+ // Synchronize via the calendar context menu.
+ await calendarListContextMenu(
+ document.querySelector("#calendar-list > li:nth-child(2)"),
+ "list-calendar-context-reload"
+ );
+
+ // Verify that the item is removed from the calendar view.
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 3, 1);
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 2, 4, 1);
+
+ // Verify that the item is removed from the today pane.
+ await TestUtils.waitForCondition(() => window.TodayPane.agenda.rowCount == 0);
+ },
+};
+
+function getTodayPaneItemTitle(idx) {
+ const row = window.TodayPane.agenda.rows[idx];
+ return row.querySelector(".agenda-listitem-title").textContent;
+}
+
+async function calendarListContextMenu(target, menuItem) {
+ await new Promise(r => setTimeout(r));
+ window.focus();
+ await TestUtils.waitForCondition(
+ () => Services.focus.focusedWindow == window,
+ "waiting for window to be focused"
+ );
+
+ let contextMenu = document.getElementById("list-calendars-context-menu");
+ let shownPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" });
+ await shownPromise;
+
+ if (menuItem) {
+ let hiddenPromise = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ contextMenu.activateItem(document.getElementById(menuItem));
+ await hiddenPromise;
+ }
+}
diff --git a/comm/calendar/test/browser/recurrence/browser.ini b/comm/calendar/test/browser/recurrence/browser.ini
new file mode 100644
index 0000000000..633ed27dde
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser.ini
@@ -0,0 +1,23 @@
+[default]
+dupe-manifest =
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+tags = recurrence
+
+[browser_annual.js]
+[browser_biweekly.js]
+[browser_daily.js]
+[browser_lastDayOfMonth.js]
+[browser_recurrenceNavigation.js]
+[browser_weeklyN.js]
+[browser_weeklyUntil.js]
+[browser_weeklyWithException.js]
diff --git a/comm/calendar/test/browser/recurrence/browser_annual.js b/comm/calendar/test/browser/recurrence/browser_annual.js
new file mode 100644
index 0000000000..54c7198f05
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_annual.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const STARTYEAR = 1950;
+const EPOCH = 1970;
+
+add_task(async function testAnnualRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, STARTYEAR, 1, 1);
+
+ // Create yearly recurring all-day event.
+ let eventBox = dayView.getAllDayHeader(window);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "yearly" });
+ await saveAndCloseItemDialog(dialogWindow);
+ await TestUtils.waitForCondition(
+ () => CalendarTestUtils.dayView.getAllDayItemAt(window, 1),
+ "recurring all-day event created"
+ );
+
+ let checkYears = [STARTYEAR, STARTYEAR + 1, EPOCH - 1, EPOCH, EPOCH + 1];
+ for (let year of checkYears) {
+ await CalendarTestUtils.goToDate(window, year, 1, 1);
+ let date = new Date(Date.UTC(year, 0, 1));
+ let column = date.getUTCDay() + 1;
+
+ // day view
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await dayView.waitForAllDayItemAt(window, 1);
+
+ // week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await weekView.waitForAllDayItemAt(window, column, 1);
+
+ // multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await multiweekView.waitForItemAt(window, 1, column, 1);
+
+ // month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await monthView.waitForItemAt(window, 1, column, 1);
+ }
+
+ // Delete event.
+ await CalendarTestUtils.goToDate(window, checkYears[0], 1, 1);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ const box = await dayView.waitForAllDayItemAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await TestUtils.waitForCondition(() => !dayView.getAllDayItemAt(window, 1), "No all-day events");
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_biweekly.js b/comm/calendar/test/browser/recurrence/browser_biweekly.js
new file mode 100644
index 0000000000..6889822375
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_biweekly.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+add_task(async function testBiweeklyRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // Create biweekly event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: "bi.weekly" });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ for (let i = 0; i < 4; i++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 14);
+ }
+
+ // Check week view.
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ for (let i = 0; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, 7, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+ }
+
+ // Check multiweek view.
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // Always two occurrences in view, 1st and 3rd or 2nd and 4th week.
+ for (let i = 0; i < 5; i++) {
+ await multiweekView.waitForItemAt(window, (i % 2) + 1, 7, 1);
+ Assert.ok(multiweekView.getItemAt(window, (i % 2) + 3, 7, 1));
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // Check month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 31);
+
+ // January
+ await monthView.waitForItemAt(window, 5, 7, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // February
+ await monthView.waitForItemAt(window, 2, 7, 1);
+ Assert.ok(monthView.getItemAt(window, 4, 7, 1));
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // March
+ await monthView.waitForItemAt(window, 2, 7, 1);
+
+ let box = monthView.getItemAt(window, 4, 7, 1);
+ Assert.ok(box);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+
+ await monthView.waitForNoItemAt(window, 4, 7, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_daily.js b/comm/calendar/test/browser/recurrence/browser_daily.js
new file mode 100644
index 0000000000..42ffc4c8db
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_daily.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var {
+ calendarViewBackward,
+ calendarViewForward,
+ setCalendarView,
+ dayView,
+ weekView,
+ multiweekView,
+ monthView,
+} = CalendarTestUtils;
+
+const HOUR = 8;
+const TITLE = "Event";
+
+add_task(async function testDailyRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Create daily event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE,
+ repeat: "daily",
+ repeatuntil: cal.createDateTime("20090320T000000Z"),
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view for 7 days.
+ for (let day = 1; day <= 7; day++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await calendarViewForward(window, 1);
+ }
+
+ // Check week view for 2 weeks.
+ await setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await weekView.waitForEventBoxAt(window, day, 1);
+ }
+
+ await calendarViewForward(window, 1);
+
+ for (let day = 1; day <= 7; day++) {
+ await weekView.waitForEventBoxAt(window, day, 1);
+ }
+
+ // Check multiweek view for 4 weeks.
+ await setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await multiweekView.waitForItemAt(window, 1, day, 1);
+ }
+
+ for (let week = 2; week <= 4; week++) {
+ for (let day = 1; day <= 7; day++) {
+ await multiweekView.waitForItemAt(window, week, day, 1);
+ }
+ }
+ // Check month view for all 5 weeks.
+ await setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let day = 5; day <= 7; day++) {
+ await monthView.waitForItemAt(window, 1, day, 1);
+ }
+
+ for (let week = 2; week <= 5; week++) {
+ for (let day = 1; day <= 7; day++) {
+ await monthView.waitForItemAt(window, week, day, 1);
+ }
+ }
+
+ // Delete 3rd January occurrence.
+ let saturday = await monthView.waitForItemAt(window, 1, 7, 1);
+ EventUtils.synthesizeMouseAtCenter(saturday, {}, window);
+ await handleDeleteOccurrencePrompt(window, saturday, false);
+
+ // Verify in all views.
+ await monthView.waitForNoItemAt(window, 1, 7, 1);
+
+ await setCalendarView(window, "multiweek");
+ Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1));
+
+ await setCalendarView(window, "week");
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ await setCalendarView(window, "day");
+ Assert.ok(!dayView.getEventBoxAt(window, 1));
+
+ // Go to previous day to edit event to occur only on weekdays.
+ await calendarViewBackward(window, 1);
+
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1));
+ await setData(dialogWindow, iframeWindow, { repeat: "every.weekday" });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view for 7 days.
+ let dates = [
+ [2009, 1, 3],
+ [2009, 1, 4],
+ ];
+ for (let [y, m, d] of dates) {
+ await CalendarTestUtils.goToDate(window, y, m, d);
+ Assert.ok(!dayView.getEventBoxAt(window, 1));
+ }
+
+ // Check week view for 2 weeks.
+ await setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 0; i <= 1; i++) {
+ await weekView.waitForNoEventBoxAt(window, 1, 1);
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+ await calendarViewForward(window, 1);
+ }
+
+ // Check multiweek view for 4 weeks.
+ await setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 1; i <= 4; i++) {
+ await multiweekView.waitForNoItemAt(window, i, 1, 1);
+ Assert.ok(!multiweekView.getItemAt(window, i, 7, 1));
+ }
+
+ // Check month view for all 5 weeks.
+ await setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ for (let i = 1; i <= 5; i++) {
+ await monthView.waitForNoItemAt(window, i, 1, 1);
+ Assert.ok(!monthView.getItemAt(window, i, 7, 1));
+ }
+
+ // Delete event.
+ let day = monthView.getItemAt(window, 1, 5, 1);
+ EventUtils.synthesizeMouseAtCenter(day, {}, window);
+ await handleDeleteOccurrencePrompt(window, day, true);
+ await monthView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js
new file mode 100644
index 0000000000..bc7e01556a
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_lastDayOfMonth.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { setCalendarView, dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+add_task(async function testLastDayOfMonthRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2008, 1, 31); // Start with a leap year.
+
+ // Create monthly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // data tuple: [year, month, day, row in month view]
+ // note: Month starts here with 1 for January.
+ let checkingData = [
+ [2008, 1, 31, 5],
+ [2008, 2, 29, 5],
+ [2008, 3, 31, 6],
+ [2008, 4, 30, 5],
+ [2008, 5, 31, 5],
+ [2008, 6, 30, 5],
+ [2008, 7, 31, 5],
+ [2008, 8, 31, 6],
+ [2008, 9, 30, 5],
+ [2008, 10, 31, 5],
+ [2008, 11, 30, 6],
+ [2008, 12, 31, 5],
+ [2009, 1, 31, 5],
+ [2009, 2, 28, 4],
+ [2009, 3, 31, 5],
+ ];
+ // Check all dates.
+ for (let [y, m, d, correctRow] of checkingData) {
+ let date = new Date(Date.UTC(y, m - 1, d));
+ let column = date.getUTCDay() + 1;
+
+ await CalendarTestUtils.goToDate(window, y, m, d);
+
+ // day view
+ await setCalendarView(window, "day");
+ await dayView.waitForEventBoxAt(window, 1);
+
+ // week view
+ await setCalendarView(window, "week");
+ await weekView.waitForEventBoxAt(window, column, 1);
+
+ // multiweek view
+ await setCalendarView(window, "multiweek");
+ await multiweekView.waitForItemAt(window, 1, column, 1);
+
+ // month view
+ await setCalendarView(window, "month");
+ await monthView.waitForItemAt(window, correctRow, column, 1);
+ }
+
+ // Delete event.
+ await CalendarTestUtils.goToDate(
+ window,
+ checkingData[0][0],
+ checkingData[0][1],
+ checkingData[0][2]
+ );
+ await setCalendarView(window, "day");
+ let box = await dayView.waitForEventBoxAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+ // monthly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "2");
+
+ // last day of month
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("montly-period-relative-date-radio"),
+ {},
+ recurrenceWindow
+ );
+ await menulistSelect(recurrenceDocument.getElementById("monthly-ordinal"), "-1");
+ await menulistSelect(recurrenceDocument.getElementById("monthly-weekday"), "-1");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js
new file mode 100644
index 0000000000..8dfe7287ce
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_recurrenceNavigation.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const calendar = CalendarTestUtils.createCalendar("Minimonths", "memory");
+
+registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+add_task(async function testRecurrenceNavigation() {
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ let eventDate = cal.createDateTime("20200201T000001Z");
+ window.goToDate(eventDate);
+
+ let newEventBtn = document.querySelector("#sidePanelNewEvent");
+ let getEventWin = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventBtn, {});
+
+ let eventWin = await getEventWin;
+ let iframe = eventWin.document.querySelector("iframe");
+
+ let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(win) {
+ let container = await TestUtils.waitForCondition(() => {
+ return win.document.querySelector("#recurrencePreviewContainer");
+ }, `The recurrence container exists`);
+
+ let initialMonth = await TestUtils.waitForCondition(() => {
+ return container.querySelector(`calendar-minimonth[month="1"][year="2020"]`);
+ }, `Initial month exists`);
+ Assert.ok(!initialMonth.hidden, `Initial month is visible on load`);
+
+ let nextButton = container.querySelector("#recurrenceNext");
+ Assert.ok(nextButton, `Next button exists`);
+ nextButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+
+ let nextMonth = container.querySelector(`calendar-minimonth[month="2"][year="2020"]`);
+ Assert.ok(nextMonth, `Next month exists`);
+ Assert.ok(!nextMonth.hidden, `Next month is visible`);
+
+ let previousButton = container.querySelector("#recurrencePrevious");
+ Assert.ok(previousButton, `Previous button exists`);
+ previousButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(previousButton, {}, win);
+ Assert.ok(!initialMonth.hidden, `Previous month is visible after using previous button`);
+
+ // Check that future dates display
+ nextButton.scrollIntoView();
+ for (let index = 0; index < 5; index++) {
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+ }
+
+ let futureMonth = await TestUtils.waitForCondition(() => {
+ return container.querySelector(`calendar-minimonth[month="6"][year="2020"]`);
+ }, `Future month exist`);
+ Assert.ok(!futureMonth.hidden, `Future month is visible after using next button`);
+
+ // Ensure the number of minimonths shown is the amount we expect.
+ let defaultMinimonthCount = "3";
+ let actualVisibleMinimonthCount = container.querySelectorAll(
+ `calendar-minimonth:not([hidden])`
+ ).length;
+ Assert.equal(
+ defaultMinimonthCount,
+ actualVisibleMinimonthCount,
+ `Default minimonth visible count matches actual: ${actualVisibleMinimonthCount}`
+ );
+
+ // Go back 5 times; we should go back to the initial month.
+ for (let index = 0; index < 5; index++) {
+ EventUtils.synthesizeMouseAtCenter(previousButton, {}, win);
+ }
+ Assert.ok(!initialMonth.hidden, `Initial month is visible`);
+
+ // Close window at end of tests for this item
+ await BrowserTestUtils.closeWindow(win);
+ },
+ }
+ );
+
+ let repeatMenu = iframe.contentDocument.querySelector("#item-repeat");
+ repeatMenu.value = "custom";
+ repeatMenu.doCommand();
+ await getRepeatWin;
+
+ await BrowserTestUtils.closeWindow(eventWin);
+});
+
+add_task(async function testRecurrenceCreationOfMonths() {
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ let eventDate = cal.createDateTime("20200101T000001Z");
+ window.goToDate(eventDate);
+
+ let newEventBtn = document.querySelector("#sidePanelNewEvent");
+ let getEventWin = CalendarTestUtils.waitForEventDialog("edit");
+ EventUtils.synthesizeMouseAtCenter(newEventBtn, {});
+
+ let eventWin = await getEventWin;
+ let iframe = eventWin.document.querySelector("iframe");
+
+ let getRepeatWin = BrowserTestUtils.promiseAlertDialogOpen(
+ "",
+ "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml",
+ {
+ async callback(win) {
+ let container = win.document.querySelector("#recurrencePreviewContainer");
+ let nextButton = container.querySelector("#recurrenceNext");
+ nextButton.scrollIntoView();
+ for (let index = 0; index < 10; index++) {
+ EventUtils.synthesizeMouseAtCenter(nextButton, {}, win);
+ }
+
+ let futureMonth = container.querySelector(`calendar-minimonth[month="10"][year="2020"]`);
+ Assert.ok(futureMonth, `Dynamically created future month exists`);
+ Assert.ok(!futureMonth.hidden, `Dynamically created future month is visible`);
+
+ // Close window at end of tests for this item
+ await BrowserTestUtils.closeWindow(win);
+ },
+ }
+ );
+
+ let repeatMenu = iframe.contentDocument.querySelector("#item-repeat");
+ repeatMenu.value = "custom";
+ repeatMenu.doCommand();
+ await getRepeatWin;
+
+ await BrowserTestUtils.closeWindow(eventWin);
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_rotated.ini b/comm/calendar/test/browser/recurrence/browser_rotated.ini
new file mode 100644
index 0000000000..2385ed9324
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_rotated.ini
@@ -0,0 +1,24 @@
+[default]
+head = head.js
+dupe-manifest =
+prefs =
+ calendar.item.promptDelete=false
+ calendar.test.rotateViews=true
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+tags = recurrence-rotated
+
+[browser_annual.js]
+[browser_biweekly.js]
+[browser_daily.js]
+[browser_lastDayOfMonth.js]
+[browser_weeklyN.js]
+[browser_weeklyUntil.js]
+[browser_weeklyWithException.js]
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyN.js b/comm/calendar/test/browser/recurrence/browser_weeklyN.js
new file mode 100644
index 0000000000..e32ab470f1
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyN.js
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+
+/*
+ * This test is intended to verify that events recurring on a weekly basis are
+ * correctly created and displayed. The event should recur on multiple days in
+ * the week, skip days, and be limited to a certain number of recurrences in
+ * order to verify that these parameters are respected. Deletion should delete
+ * all event occurrences when appropriate.
+ */
+add_task(async function testWeeklyNRecurrence() {
+ async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // Select weekly recurrence
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let monLabel = cal.l10n.getDateFmtString("day.2.Mmm");
+ let tueLabel = cal.l10n.getDateFmtString("day.3.Mmm");
+ let wedLabel = cal.l10n.getDateFmtString("day.4.Mmm");
+ let friLabel = cal.l10n.getDateFmtString("day.6.Mmm");
+ let satLabel = cal.l10n.getDateFmtString("day.7.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Selected date is a Monday, so it should already be selected
+ Assert.ok(
+ dayPicker.querySelector(`[label="${monLabel}"]`).checked,
+ "Monday should already be selected"
+ );
+
+ // Select Tuesday, Wednesday, Friday, and Saturday as additional days for
+ // event occurrences
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${tueLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wedLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${friLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${satLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Create a total of four events
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-for"),
+ {},
+ recurrenceWindow
+ );
+ recurrenceDocument.getElementById("repeat-ntimes-count").value = "4";
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+ }
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Create event recurring on a weekly basis
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Verify in the day view that events were created for Monday through Wednesday
+ for (let i = 0; i < 3; i++) {
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // No event should have been created on Thursday because it was not selected
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // An event should have been created for Friday because it was selected
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // No event should have been created on Saturday due to four event limit
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // Validate event creation and lack of Saturday event in week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ for (let i = 2; i < 5; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await weekView.waitForNoEventBoxAt(window, 5, 1);
+ await weekView.waitForEventBoxAt(window, 6, 1);
+ await weekView.waitForNoEventBoxAt(window, 7, 1);
+
+ // Validate event creation and lack of Saturday event in multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+
+ for (let i = 2; i < 5; i++) {
+ await multiweekView.waitForItemAt(window, 1, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await multiweekView.waitForNoItemAt(window, 1, 5, 1);
+ await multiweekView.waitForItemAt(window, 1, 6, 1);
+ await multiweekView.waitForNoItemAt(window, 1, 7, 1);
+
+ // Validate event creation and lack of Saturday event in month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+
+ for (let i = 2; i < 5; i++) {
+ // This should be the second week in the month
+ await monthView.waitForItemAt(window, 2, i, 1);
+ }
+
+ // No event Thursday or Saturday, event on Friday
+ await monthView.waitForNoItemAt(window, 2, 5, 1);
+ await monthView.waitForItemAt(window, 2, 6, 1);
+ await monthView.waitForNoItemAt(window, 2, 7, 1);
+
+ // Delete event
+ let box = await monthView.waitForItemAt(window, 2, 2, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+
+ // All occurrences should have been deleted
+ for (let i = 2; i < 5; i++) {
+ await monthView.waitForNoItemAt(window, 2, i, 1);
+ }
+
+ await monthView.waitForNoItemAt(window, 2, 6, 1);
+});
+
+/*
+ * This test is intended to catch instances in which we aren't correctly setting
+ * the week start value of recurrences. For example, if the user has set their
+ * week to start on Saturday, then creates a recurring event running every other
+ * Saturday, Sunday, and Monday, they expect to see events on the initial
+ * Saturday, Sunday, Monday, skip a week, repeat. However, week start defaults
+ * to Monday, so if it is not correctly set, they would see events on the
+ * initial Saturday and Sunday, nothing on Monday, but an event on the following
+ * Monday.
+ */
+add_task(async function testRecurrenceAcrossWeekStart() {
+ // Sanity check that we're not testing against a default value
+ const initialWeekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ Assert.notEqual(initialWeekStart, 6, "week start should not be Saturday");
+
+ // Set week start to Saturday
+ Services.prefs.setIntPref("calendar.week.start", 6);
+ registerCleanupFunction(() => {
+ Services.prefs.setIntPref("calendar.week.start", initialWeekStart);
+ });
+
+ async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // Select weekly recurrence
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ // Recur every two weeks
+ recurrenceDocument.getElementById("weekly-weeks").value = "2";
+
+ let satLabel = cal.l10n.getDateFmtString("day.7.Mmm");
+ let sunLabel = cal.l10n.getDateFmtString("day.1.Mmm");
+ let monLabel = cal.l10n.getDateFmtString("day.2.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Selected date is a Saturday, so it should already be selected
+ Assert.ok(
+ dayPicker.querySelector(`[label="${satLabel}"]`).checked,
+ "Saturday should already be checked"
+ );
+
+ // Select Sunday and Monday as additional days for event occurrences
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${sunLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${monLabel}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Create a total of six events
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-for"),
+ {},
+ recurrenceWindow
+ );
+ recurrenceDocument.getElementById("repeat-ntimes-count").value = "6";
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+ }
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2022, 10, 15);
+
+ // Create event recurring every other week
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Open week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ // Verify events created on Saturday, Sunday, Monday of first week
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+
+ // Verify no events created on Saturday, Sunday, Monday of second week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForNoEventBoxAt(window, i, 1);
+ }
+
+ // Verify events created on Saturday, Sunday, Monday of third week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ for (let i = 1; i < 4; i++) {
+ await weekView.waitForEventBoxAt(window, i, 1);
+ }
+});
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js
new file mode 100644
index 0000000000..c9780e9428
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyUntil.js
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { formatDate, menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const ENDDATE = cal.createDateTime("20090126T000000Z"); // Last Monday in month.
+const HOUR = 8;
+
+add_task(async function testWeeklyUntilRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5); // Monday
+
+ // Create weekly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: "Event", repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check day view.
+ for (let week = 0; week < 3; week++) {
+ // Monday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Wednesday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Friday
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 3);
+ }
+
+ // Monday, last occurrence
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 2);
+
+ // Wednesday
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // Check week view.
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ for (let week = 0; week < 3; week++) {
+ // Monday
+ await weekView.waitForEventBoxAt(window, 2, 1);
+
+ // Wednesday
+ await weekView.waitForEventBoxAt(window, 4, 1);
+
+ // Friday
+ await weekView.waitForEventBoxAt(window, 6, 1);
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ // Monday, last occurrence
+ await weekView.waitForEventBoxAt(window, 2, 1);
+ // Wednesday
+ await weekView.waitForNoEventBoxAt(window, 4, 1);
+
+ // Check multiweek view.
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ for (let week = 1; week < 4; week++) {
+ // Monday
+ await multiweekView.waitForItemAt(window, week, 2, 1);
+ // Wednesday
+ await multiweekView.waitForItemAt(window, week, 4, 1);
+ // Friday
+ await multiweekView.waitForItemAt(window, week, 6, 1);
+ }
+
+ // Monday, last occurrence
+ await multiweekView.waitForItemAt(window, 4, 2, 1);
+
+ // Wednesday
+ await multiweekView.waitForNoItemAt(window, 4, 4, 1);
+
+ // Check month view.
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ // starts on week 2 in month-view
+ for (let week = 2; week < 5; week++) {
+ // Monday
+ await monthView.waitForItemAt(window, week, 2, 1);
+ // Wednesday
+ await monthView.waitForItemAt(window, week, 4, 1);
+ // Friday
+ await monthView.waitForItemAt(window, week, 6, 1);
+ }
+
+ // Monday, last occurrence
+ await monthView.waitForItemAt(window, 5, 2, 1);
+
+ // Wednesday
+ await monthView.waitForNoItemAt(window, 5, 4, 1);
+
+ // Delete event.
+ let box = monthView.getItemAt(window, 2, 2, 1);
+ EventUtils.synthesizeMouseAtCenter(box, {}, window);
+ await handleDeleteOccurrencePrompt(window, box, true);
+ await monthView.waitForNoItemAt(window, 2, 2, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+ // Check Wednesday and Friday too.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wed}"]`),
+ {},
+ recurrenceWindow
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${fri}"]`),
+ {},
+ recurrenceWindow
+ );
+
+ // Set until date.
+ EventUtils.synthesizeMouseAtCenter(
+ recurrenceDocument.getElementById("recurrence-range-until"),
+ {},
+ recurrenceWindow
+ );
+
+ // Delete previous date.
+ let untilInput = recurrenceDocument.getElementById("repeat-until-date");
+ untilInput.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, recurrenceWindow);
+ untilInput.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, recurrenceWindow);
+
+ let endDateString = formatDate(ENDDATE);
+ EventUtils.sendString(endDateString, recurrenceWindow);
+
+ // Move focus to ensure the date is selected.
+ untilInput.focus();
+ EventUtils.synthesizeKey("VK_TAB", {}, recurrenceWindow);
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js
new file mode 100644
index 0000000000..fbe007ea45
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/browser_weeklyWithException.js
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { handleDeleteOccurrencePrompt } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+
+var { menulistSelect, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { dayView, weekView, multiweekView, monthView } = CalendarTestUtils;
+
+const HOUR = 8;
+const STARTDATE = cal.createDateTime("20090106T000000Z");
+const TITLE = "Event";
+
+add_task(async function testWeeklyWithExceptionRecurrence() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Create weekly recurring event.
+ let eventBox = dayView.getHourBoxAt(window, HOUR);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ await setData(dialogWindow, iframeWindow, { title: TITLE, repeat: setRecurrence });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ let eventItem = await dayView.waitForEventBoxAt(window, 1);
+ let icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // Move 5th January occurrence to 6th January.
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrenceAt(window, 1));
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE,
+ startdate: STARTDATE,
+ enddate: STARTDATE,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ await CalendarTestUtils.goToDate(window, 2009, 1, 6);
+ eventItem = await dayView.waitForEventBoxAt(window, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence-exception.svg");
+
+ // Change recurrence rule.
+ await CalendarTestUtils.goToDate(window, 2009, 1, 7);
+ ({ dialogWindow, iframeWindow } = await dayView.editEventOccurrencesAt(window, 1));
+ await setData(dialogWindow, iframeWindow, {
+ title: "Event",
+ repeat: changeRecurrence,
+ });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check two weeks.
+ // day view
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+
+ // Assert exactly two.
+ Assert.ok(!!(await dayView.waitForEventBoxAt(window, 1)));
+ Assert.ok(!!(await dayView.waitForEventBoxAt(window, 2)));
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // next week
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForEventBoxAt(window, 1);
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ // week view
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+
+ // Assert exactly two on Tuesday.
+ Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 1)));
+ Assert.ok(!!(await weekView.waitForEventBoxAt(window, 3, 2)));
+
+ // Wait for the last occurrence because this appears last.
+ eventItem = await weekView.waitForEventBoxAt(window, 6, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ Assert.ok(!weekView.getEventBoxAt(window, 1, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 2, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 4, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 5, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ await weekView.waitForEventBoxAt(window, 6, 1);
+ Assert.ok(!weekView.getEventBoxAt(window, 1, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 2, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 3, 1));
+ Assert.ok(!!weekView.getEventBoxAt(window, 4, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 5, 1));
+ Assert.ok(!weekView.getEventBoxAt(window, 7, 1));
+
+ // multiweek view
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 5);
+ // Wait for the first items, then check the ones not to be present.
+ // Assert exactly two.
+ await multiweekView.waitForItemAt(window, 1, 3, 1, 1);
+ Assert.ok(multiweekView.getItemAt(window, 1, 3, 2, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 3, 3, 1));
+ // Then check no item on the 5th.
+ Assert.ok(!multiweekView.getItemAt(window, 1, 2, 1));
+ Assert.ok(multiweekView.getItemAt(window, 1, 4, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 5, 1));
+ Assert.ok(multiweekView.getItemAt(window, 1, 6, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 1, 7, 1));
+
+ Assert.ok(!multiweekView.getItemAt(window, 2, 1, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 2, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 3, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 4, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 2, 5, 1));
+ Assert.ok(multiweekView.getItemAt(window, 2, 6, 1));
+ Assert.ok(!multiweekView.getItemAt(window, 2, 7, 1));
+
+ eventItem = multiweekView.getItemAt(window, 2, 4, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // month view
+ await CalendarTestUtils.setCalendarView(window, "month");
+ // Wait for the first items, then check the ones not to be present.
+ // Assert exactly two.
+ // start on the second week
+ await monthView.waitForItemAt(window, 2, 3, 1);
+ Assert.ok(monthView.getItemAt(window, 2, 3, 2));
+ Assert.ok(!monthView.getItemAt(window, 2, 3, 3));
+ // Then check no item on the 5th.
+ Assert.ok(!monthView.getItemAt(window, 2, 2, 1));
+ Assert.ok(monthView.getItemAt(window, 2, 4, 1));
+ Assert.ok(!monthView.getItemAt(window, 2, 5, 1));
+ Assert.ok(monthView.getItemAt(window, 2, 6, 1));
+ Assert.ok(!monthView.getItemAt(window, 2, 7, 1));
+
+ Assert.ok(!monthView.getItemAt(window, 3, 1, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 2, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 3, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 4, 1));
+ Assert.ok(!monthView.getItemAt(window, 3, 5, 1));
+ Assert.ok(monthView.getItemAt(window, 3, 6, 1));
+ Assert.ok(!monthView.getItemAt(window, 3, 7, 1));
+
+ eventItem = monthView.getItemAt(window, 3, 6, 1);
+ icon = eventItem.querySelector(".item-recurrence-icon");
+ Assert.equal(icon.src, "chrome://messenger/skin/icons/new/recurrence.svg");
+ Assert.ok(!icon.hidden);
+
+ // Delete event.
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 12);
+ eventBox = await dayView.waitForEventBoxAt(window, 1);
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ await handleDeleteOccurrencePrompt(window, eventBox, true);
+ await dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+async function setRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+
+ // Check Wednesday and Friday too.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${wed}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked");
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${fri}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
+
+async function changeRecurrence(recurrenceWindow) {
+ let recurrenceDocument = recurrenceWindow.document;
+
+ // weekly
+ await menulistSelect(recurrenceDocument.getElementById("period-list"), "1");
+
+ let mon = cal.l10n.getDateFmtString("day.2.Mmm");
+ let tue = cal.l10n.getDateFmtString("day.3.Mmm");
+ let wed = cal.l10n.getDateFmtString("day.4.Mmm");
+ let fri = cal.l10n.getDateFmtString("day.6.Mmm");
+
+ let dayPicker = recurrenceDocument.getElementById("daypicker-weekday");
+
+ // Check old rule.
+ // Starting from Monday so it should be checked.
+ Assert.ok(dayPicker.querySelector(`[label="${mon}"]`).checked, "mon checked");
+ Assert.ok(dayPicker.querySelector(`[label="${wed}"]`).checked, "wed checked");
+ Assert.ok(dayPicker.querySelector(`[label="${fri}"]`).checked, "fri checked");
+
+ // Check Tuesday.
+ EventUtils.synthesizeMouseAtCenter(
+ dayPicker.querySelector(`[label="${tue}"]`),
+ {},
+ recurrenceWindow
+ );
+ Assert.ok(dayPicker.querySelector(`[label="${tue}"]`).checked, "tue checked");
+
+ let button = recurrenceDocument.querySelector("dialog").getButton("accept");
+ button.scrollIntoView();
+ // Close dialog.
+ EventUtils.synthesizeMouseAtCenter(button, {}, recurrenceWindow);
+}
diff --git a/comm/calendar/test/browser/recurrence/head.js b/comm/calendar/test/browser/recurrence/head.js
new file mode 100644
index 0000000000..efcee250b6
--- /dev/null
+++ b/comm/calendar/test/browser/recurrence/head.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// The tests in this folder frequently take too long. Give them more time.
+requestLongerTimeout(2);
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+/* globals toggleOrientation */
+
+let isRotated =
+ document.getElementById("calendar_toggle_orientation_command").getAttribute("checked") == "true";
+let shouldBeRotated = Services.prefs.getBoolPref("calendar.test.rotateViews", false);
+
+if (isRotated != shouldBeRotated) {
+ toggleOrientation();
+}
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
diff --git a/comm/calendar/test/browser/timezones/browser.ini b/comm/calendar/test/browser/timezones/browser.ini
new file mode 100644
index 0000000000..2fe2a0b4ea
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser.ini
@@ -0,0 +1,17 @@
+[default]
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.week.start=0
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+support-files = data/**
+
+[browser_minimonth.js]
+[browser_timezones.js]
+skip-if = debug # Takes way too long, bug 1746973.
diff --git a/comm/calendar/test/browser/timezones/browser_minimonth.js b/comm/calendar/test/browser/timezones/browser_minimonth.js
new file mode 100644
index 0000000000..eba6bb2485
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser_minimonth.js
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests the minimonth widget in a range of time zones. It will fail if the
+ * widget loses time zone awareness.
+ */
+
+/* eslint-disable no-restricted-syntax */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+add_setup(async function () {
+ await CalendarTestUtils.openCalendarTab(window);
+});
+
+registerCleanupFunction(async function () {
+ await CalendarTestUtils.closeCalendarTab(window);
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+});
+
+async function subtest() {
+ let zone = cal.dtz.defaultTimezone;
+ info(`Running test in ${zone.tzid}`);
+
+ // Set the minimonth to display August 2016.
+ let minimonth = document.getElementById("calMinimonth");
+ minimonth.showMonth(new Date(2016, 7, 15));
+
+ Assert.deepEqual(
+ [...minimonth.dayBoxes.keys()],
+ [
+ "2016-07-31",
+ "2016-08-01",
+ "2016-08-02",
+ "2016-08-03",
+ "2016-08-04",
+ "2016-08-05",
+ "2016-08-06",
+ "2016-08-07",
+ "2016-08-08",
+ "2016-08-09",
+ "2016-08-10",
+ "2016-08-11",
+ "2016-08-12",
+ "2016-08-13",
+ "2016-08-14",
+ "2016-08-15",
+ "2016-08-16",
+ "2016-08-17",
+ "2016-08-18",
+ "2016-08-19",
+ "2016-08-20",
+ "2016-08-21",
+ "2016-08-22",
+ "2016-08-23",
+ "2016-08-24",
+ "2016-08-25",
+ "2016-08-26",
+ "2016-08-27",
+ "2016-08-28",
+ "2016-08-29",
+ "2016-08-30",
+ "2016-08-31",
+ "2016-09-01",
+ "2016-09-02",
+ "2016-09-03",
+ "2016-09-04",
+ "2016-09-05",
+ "2016-09-06",
+ "2016-09-07",
+ "2016-09-08",
+ "2016-09-09",
+ "2016-09-10",
+ ],
+ "day boxes are stored with the correct keys"
+ );
+
+ function check(date, row, column) {
+ if (date instanceof Date) {
+ info(date);
+ } else {
+ info(`${date} ${date.timezone.tzid}`);
+ }
+ if (row && column) {
+ Assert.equal(minimonth.getBoxForDate(date), minimonth.mCalBox.rows[row].cells[column]);
+ } else {
+ Assert.equal(minimonth.getBoxForDate(date), null);
+ }
+ }
+
+ let dateWithZone = cal.createDateTime();
+
+ // Dates without timezones or the local timezone.
+
+ // All of these represent the 1st of August.
+ check(new Date(2016, 7, 1), 1, 2);
+ check(new Date(2016, 7, 1, 9, 0, 0), 1, 2);
+ check(new Date(2016, 7, 1, 22, 0, 0), 1, 2);
+
+ check(cal.createDateTime("20160801"), 1, 2);
+ check(cal.createDateTime("20160801T030000"), 1, 2);
+ check(cal.createDateTime("20160801T210000"), 1, 2);
+
+ dateWithZone.resetTo(2016, 7, 1, 3, 0, 0, zone);
+ check(dateWithZone, 1, 2);
+ dateWithZone.resetTo(2016, 7, 1, 21, 0, 0, zone);
+ check(dateWithZone, 1, 2);
+
+ // All of these represent the 31st of August.
+ check(new Date(2016, 7, 31), 5, 4);
+ check(new Date(2016, 7, 31, 9, 0, 0), 5, 4);
+ check(new Date(2016, 7, 31, 22, 0, 0), 5, 4);
+
+ check(cal.createDateTime("20160831"), 5, 4);
+ check(cal.createDateTime("20160831T030000"), 5, 4);
+ check(cal.createDateTime("20160831T210000"), 5, 4);
+
+ dateWithZone.resetTo(2016, 7, 31, 3, 0, 0, zone);
+ check(dateWithZone, 5, 4);
+ dateWithZone.resetTo(2016, 7, 31, 21, 0, 0, zone);
+ check(dateWithZone, 5, 4);
+
+ // August a year earlier shouldn't be displayed.
+ check(new Date(2015, 7, 15));
+ check(cal.createDateTime("20150815"));
+ dateWithZone.resetTo(2015, 7, 15, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // The Saturday of the previous week shouldn't be displayed.
+ check(new Date(2016, 6, 30));
+ check(cal.createDateTime("20160730"));
+ dateWithZone.resetTo(2016, 6, 30, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // The Sunday of the next week shouldn't be displayed.
+ check(new Date(2016, 8, 11));
+ check(cal.createDateTime("20160911"));
+ dateWithZone.resetTo(2016, 8, 11, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // August a year later shouldn't be displayed.
+ check(new Date(2017, 7, 15));
+ check(cal.createDateTime("20170815"));
+ dateWithZone.resetTo(2017, 7, 15, 0, 0, 0, zone);
+ check(dateWithZone);
+
+ // UTC dates.
+
+ check(cal.createDateTime("20160801T030000Z"), 1, zone.tzid == "America/Vancouver" ? 1 : 2);
+ check(cal.createDateTime("20160801T210000Z"), 1, zone.tzid == "Pacific/Auckland" ? 3 : 2);
+
+ check(cal.createDateTime("20160831T030000Z"), 5, zone.tzid == "America/Vancouver" ? 3 : 4);
+ check(cal.createDateTime("20160831T210000Z"), 5, zone.tzid == "Pacific/Auckland" ? 5 : 4);
+
+ // Dates in different zones.
+
+ let auckland = cal.timezoneService.getTimezone("Pacific/Auckland");
+ let vancouver = cal.timezoneService.getTimezone("America/Vancouver");
+
+ // Early in Auckland is the previous day everywhere else.
+ dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, auckland);
+ check(dateWithZone, 3, zone.tzid == "Pacific/Auckland" ? 2 : 1);
+
+ // Late in Auckland is the same day everywhere.
+ dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, auckland);
+ check(dateWithZone, 3, 2);
+
+ // Early in Vancouver is the same day everywhere.
+ dateWithZone.resetTo(2016, 7, 15, 3, 0, 0, vancouver);
+ check(dateWithZone, 3, 2);
+
+ // Late in Vancouver is the next day everywhere else.
+ dateWithZone.resetTo(2016, 7, 15, 21, 0, 0, vancouver);
+ check(dateWithZone, 3, zone.tzid == "America/Vancouver" ? 2 : 3);
+
+ // Reset the minimonth to a different month.
+ minimonth.showMonth(new Date(2016, 9, 15));
+}
+
+/**
+ * Run the test at UTC+12.
+ */
+add_task(async function auckland() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Auckland");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC+2.
+ */
+add_task(async function berlin() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC.
+ */
+add_task(async function utc() {
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+ await subtest();
+});
+
+/**
+ * Run the test at UTC-7.
+ */
+add_task(async function vancouver() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Vancouver");
+ await subtest();
+});
diff --git a/comm/calendar/test/browser/timezones/browser_timezones.js b/comm/calendar/test/browser/timezones/browser_timezones.js
new file mode 100644
index 0000000000..41ce97027a
--- /dev/null
+++ b/comm/calendar/test/browser/timezones/browser_timezones.js
@@ -0,0 +1,867 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+requestLongerTimeout(3);
+
+var { findEventsInNode } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var DATES = [
+ [2009, 1, 1],
+ [2009, 4, 2],
+ [2009, 4, 16],
+ [2009, 4, 30],
+ [2009, 7, 2],
+ [2009, 10, 15],
+ [2009, 10, 29],
+ [2009, 11, 5],
+];
+
+var TIMEZONES = [
+ "America/St_Johns",
+ "America/Caracas", // standard time UTC-4:30 from 2007 to 2016
+ "America/Phoenix",
+ "America/Los_Angeles",
+ "America/Buenos_Aires", // standard time UTC-3, DST UTC-4 from October 2008 to March 2009
+ "Europe/Paris",
+ "Asia/Katmandu",
+ "Australia/Adelaide",
+];
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+add_setup(async () => {
+ registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+ Services.prefs.setStringPref("calendar.timezone.local", "UTC");
+ });
+
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Create weekly recurring events in all TIMEZONES.
+ let times = [
+ [4, 30],
+ [5, 0],
+ [3, 0],
+ [3, 0],
+ [9, 0],
+ [14, 0],
+ [19, 45],
+ [1, 30],
+ ];
+ let time = cal.createDateTime();
+ for (let i = 0; i < TIMEZONES.length; i++) {
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, i + 11);
+ let { dialogWindow, iframeWindow } = await CalendarTestUtils.editNewEvent(window, eventBox);
+ time.hour = times[i][0];
+ time.minute = times[i][1];
+
+ // Set event data.
+ await setData(dialogWindow, iframeWindow, {
+ title: TIMEZONES[i],
+ repeat: "weekly",
+ repeatuntil: cal.createDateTime("20091231T000000Z"),
+ starttime: time,
+ timezone: TIMEZONES[i],
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+ }
+});
+
+add_task(async function testTimezones3_checkStJohns() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/St_Johns");
+ let times = [
+ [
+ [4, 30],
+ [6, 0],
+ [6, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [13, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [9, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [7, 0],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ [12, 30],
+ ],
+ [
+ [4, 30],
+ [6, 0],
+ [6, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ [11, 30],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones4_checkCaracas() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Caracas");
+ let times = [
+ [
+ [3, 30],
+ [5, 0],
+ [5, 30],
+ [6, 30],
+ [6, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [11, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [7, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [2, 30],
+ [5, 0],
+ [5, 30],
+ [5, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ [
+ [3, 30],
+ [5, 0],
+ [5, 30],
+ [6, 30],
+ [7, 30],
+ [8, 30],
+ [9, 30],
+ [10, 30],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones5_checkPhoenix() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Phoenix");
+ let times = [
+ [
+ [1, 0],
+ [2, 30],
+ [3, 0],
+ [4, 0],
+ [4, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [1, 0],
+ [2, 30],
+ [3, 0],
+ [4, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones6_checkLosAngeles() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Los_Angeles");
+ let times = [
+ [
+ [0, 0],
+ [1, 30],
+ [2, 0],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [9, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [5, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [2, 30],
+ [3, 0],
+ [3, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ [8, 0],
+ ],
+ [
+ [0, 0],
+ [1, 30],
+ [2, 0],
+ [3, 0],
+ [4, 0],
+ [5, 0],
+ [6, 0],
+ [7, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones7_checkBuenosAires() {
+ Services.prefs.setStringPref("calendar.timezone.local", "America/Argentina/Buenos_Aires");
+ let times = [
+ [
+ [6, 0],
+ [7, 30],
+ [8, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [13, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [9, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [4, 0],
+ [6, 30],
+ [7, 0],
+ [7, 0],
+ [9, 0],
+ [10, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ [
+ [5, 0],
+ [6, 30],
+ [7, 0],
+ [8, 0],
+ [9, 0],
+ [10, 0],
+ [11, 0],
+ [12, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones8_checkParis() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Europe/Paris");
+ let times = [
+ [
+ [9, 0],
+ [10, 30],
+ [11, 0],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [17, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [18, 0],
+ ],
+ [
+ [9, 0],
+ [11, 30],
+ [12, 0],
+ [12, 0],
+ [14, 0],
+ [14, 0],
+ [16, 0],
+ [17, 0],
+ ],
+ [
+ [8, 0],
+ [10, 30],
+ [11, 0],
+ [11, 0],
+ [13, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ [
+ [9, 0],
+ [10, 30],
+ [11, 0],
+ [12, 0],
+ [13, 0],
+ [14, 0],
+ [15, 0],
+ [16, 0],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones9_checkKathmandu() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Asia/Kathmandu");
+ let times = [
+ [
+ [13, 45],
+ [15, 15],
+ [15, 45],
+ [16, 45],
+ [16, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [21, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [17, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [12, 45],
+ [15, 15],
+ [15, 45],
+ [15, 45],
+ [17, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ [
+ [13, 45],
+ [15, 15],
+ [15, 45],
+ [16, 45],
+ [17, 45],
+ [18, 45],
+ [19, 45],
+ [20, 45],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+add_task(async function testTimezones10_checkAdelaide() {
+ Services.prefs.setStringPref("calendar.timezone.local", "Australia/Adelaide");
+ let times = [
+ [
+ [18, 30],
+ [20, 0],
+ [20, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [22, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [16, 30],
+ [19, 0],
+ [19, 30],
+ [19, 30],
+ [21, 30],
+ [21, 30],
+ [23, 30],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [22, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [17, 30],
+ [20, 0],
+ [20, 30],
+ [20, 30],
+ [22, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ [
+ [18, 30],
+ [20, 0],
+ [20, 30],
+ [21, 30],
+ [22, 30],
+ [23, 30],
+ [0, 30, +1],
+ [1, 30, +1],
+ ],
+ ];
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("calendarButton"), {}, window);
+ await CalendarTestUtils.setCalendarView(window, "day");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ await verify(DATES, TIMEZONES, times);
+});
+
+async function verify(dates, timezones, times) {
+ function* datetimes() {
+ for (let idx = 0; idx < dates.length; idx++) {
+ yield [dates[idx][0], dates[idx][1], dates[idx][2], times[idx]];
+ }
+ }
+ let allowedDifference = 3;
+
+ for (let [selectedYear, selectedMonth, selectedDay, selectedTime] of datetimes()) {
+ info(`Verifying on day ${selectedDay}, month ${selectedMonth}, year ${selectedYear}`);
+ await CalendarTestUtils.goToDate(window, selectedYear, selectedMonth, selectedDay);
+
+ // Find event with timezone tz.
+ for (let tzIdx = 0; tzIdx < timezones.length; tzIdx++) {
+ let [hour, minutes, day] = selectedTime[tzIdx];
+ info(
+ `Verifying at ${hour} hours, ${minutes} minutes (offset: ${day || "none"}) ` +
+ `in timezone "${timezones[tzIdx]}"`
+ );
+
+ // following day
+ if (day == 1) {
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ } else if (day == -1) {
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+ }
+
+ let hourRect = CalendarTestUtils.dayView.getHourBoxAt(window, hour).getBoundingClientRect();
+ let timeY = hourRect.y + hourRect.height * (minutes / 60);
+
+ // Wait for at least one event box to exist.
+ await CalendarTestUtils.dayView.waitForEventBoxAt(window, 1);
+
+ let eventPositions = Array.from(CalendarTestUtils.dayView.getEventBoxes(window))
+ .filter(node => node.mOccurrence.title == timezones[tzIdx])
+ .map(node => node.getBoundingClientRect().y);
+
+ dump(`Looking for event at ${timeY}: found ${eventPositions.join(", ")}\n`);
+
+ if (day == 1) {
+ await CalendarTestUtils.calendarViewBackward(window, 1);
+ } else if (day == -1) {
+ await CalendarTestUtils.calendarViewForward(window, 1);
+ }
+
+ Assert.ok(
+ eventPositions.some(pos => Math.abs(timeY - pos) < allowedDifference),
+ `There should be an event box that starts at ${hour} hours, ${minutes} minutes`
+ );
+ }
+ }
+}
diff --git a/comm/calendar/test/browser/views/browser.ini b/comm/calendar/test/browser/views/browser.ini
new file mode 100644
index 0000000000..0aae8af9d0
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser.ini
@@ -0,0 +1,32 @@
+[default]
+head = head.js
+prefs =
+ calendar.item.promptDelete=false
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ # Default start of the week Thursday to make sure calendar isn't relying on
+ # built-in assumptions of week start.
+ calendar.week.start=4
+ # Default Sunday to "not a weekend" and Wednesday to "weekend" to make sure
+ # calendar isn't relying on built-in assumptions of work days.
+ calendar.week.d0sundaysoff=false
+ calendar.week.d3wednesdaysoff=true
+ # Default work hours to be from 3:00 to 12:00 to make sure calendar isn't
+ # relying on built-in assumptions of work hours.
+ calendar.view.daystarthour=3
+ calendar.view.dayendhour=12
+ calendar.view.visiblehours=3
+ mail.provider.suppress_dialog_on_startup=true
+ mail.spotlight.firstRunDone=true
+ mail.winsearch.firstRunDone=true
+ mailnews.start_page.override_url=about:blank
+ mailnews.start_page.url=about:blank
+subsuite = thunderbird
+
+[browser_dayView.js]
+[browser_monthView.js]
+[browser_multiweekView.js]
+[browser_propertyChanges.js]
+[browser_taskView.js]
+[browser_viewSwitch.js]
+[browser_weekView.js]
diff --git a/comm/calendar/test/browser/views/browser_dayView.js b/comm/calendar/test/browser/views/browser_dayView.js
new file mode 100644
index 0000000000..ba68a85eea
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_dayView.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Day View Event";
+const TITLE2 = "Day View Event Changed";
+const DESC = "Day View Event Description";
+
+add_setup(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+});
+
+add_task(async function testDayView() {
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ let dayView = document.getElementById("day-view");
+ // Verify date in view.
+ await TestUtils.waitForCondition(
+ () => dayView.dayColumns[0]?.date.icalString == "20090101",
+ "Inspecting the date"
+ );
+
+ // Create event at 8 AM.
+ let eventBox = CalendarTestUtils.dayView.getHourBoxAt(window, 8);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ let someDate = cal.createDateTime();
+ someDate.resetTo(2009, 0, 1, 8, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.dayView.editEventAt(window, 1));
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.dayView.getEventBoxAt(window, 1);
+ if (!eventBox) {
+ return false;
+ }
+
+ let eventName = eventBox.querySelector(".event-name-label");
+ return eventName.textContent == TITLE2;
+ }, "event was modified in the view");
+
+ // Delete event
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.dayView.waitForNoEventBoxAt(window, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
+
+add_task(async function testDayViewDateLabel() {
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let heading = CalendarTestUtils.dayView.getColumnHeading(window);
+ let labelSpan = heading.querySelector("span:not([hidden])");
+
+ Assert.equal(
+ labelSpan.textContent,
+ "Wednesday Apr 13",
+ "the date label should contain the displayed date in a human-readable string"
+ );
+});
+
+add_task(async function testDayViewCurrentDayHighlight() {
+ // Sanity check that this date (which should be in the past) is not today's
+ // date.
+ let today = new Date();
+ Assert.ok(today.getUTCFullYear() != 2022 || today.getUTCMonth() != 3 || today.getUTCDate() != 13);
+
+ // When displaying days which are not the current day, there should be no
+ // highlight.
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ !container.classList.contains("day-column-today"),
+ "the displayed date should not be highlighted if it is not the current day"
+ );
+
+ // When displaying the current day, it should be highlighted.
+ await CalendarTestUtils.goToToday(window);
+
+ container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ container.classList.contains("day-column-today"),
+ "the displayed date should be highlighted if it is the current day"
+ );
+});
+
+add_task(async function testDayViewWorkDayHighlight() {
+ // The test configuration sets Sunday to be a work day, so it should not have
+ // the weekend background.
+ await CalendarTestUtils.goToDate(window, 2022, 4, 10);
+
+ let container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ !container.classList.contains("day-column-weekend"),
+ "the displayed date should not be highlighted if it is a work day"
+ );
+
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ container = CalendarTestUtils.dayView.getColumnContainer(window);
+ Assert.ok(
+ container.classList.contains("day-column-weekend"),
+ "the displayed date should be highlighted if it is not a work day"
+ );
+});
+
+add_task(async function testDayViewNavbar() {
+ await CalendarTestUtils.goToDate(window, 2022, 4, 13);
+
+ let intervalDescription = CalendarTestUtils.getNavBarIntervalDescription(window);
+ Assert.equal(
+ intervalDescription.textContent,
+ "Wednesday, April 13, 2022",
+ "interval description should contain a description of the displayed date"
+ );
+
+ // Note that the value 14 here tests calculation of the calendar week based on
+ // the starting day of the week; if the calculation built in an assumption of
+ // Sunday or Monday as the starting day of the week, we would get 15 here.
+ let calendarWeek = CalendarTestUtils.getNavBarCalendarWeekBox(window);
+ Assert.equal(
+ calendarWeek.textContent,
+ "CW: 14",
+ "calendar week label should contain an indicator of which week contains displayed date"
+ );
+});
+
+add_task(async function testDayViewTodayButton() {
+ // Though this code is cribbed from the CalendarTestUtils, it should be
+ // duplicated in case the utility implementation changes.
+ let todayButton = CalendarTestUtils.getNavBarTodayButton(window);
+
+ EventUtils.synthesizeMouseAtCenter(todayButton, {}, window);
+ await CalendarTestUtils.ensureViewLoaded(window);
+
+ let displayedDate = CalendarTestUtils.dayView.getEventColumn(window).date;
+
+ let today = new Date();
+ Assert.equal(
+ displayedDate.year,
+ today.getUTCFullYear(),
+ "year of displayed date should be this year"
+ );
+ Assert.equal(
+ displayedDate.month,
+ today.getUTCMonth(),
+ "month of displayed date should be this month"
+ );
+ Assert.equal(displayedDate.day, today.getUTCDate(), "day of displayed date should be today");
+});
diff --git a/comm/calendar/test/browser/views/browser_monthView.js b/comm/calendar/test/browser/views/browser_monthView.js
new file mode 100644
index 0000000000..f3a385a3f5
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_monthView.js
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Month View Event";
+const TITLE2 = "Month View Event Changed";
+const DESC = "Month View Event Description";
+
+add_task(async function testMonthView() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "month");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector(
+ '#month-view td[selected="true"] > calendar-month-day-box'
+ );
+ return dateLabel && dateLabel.mDate.icalString == "20090101";
+ }, "Inspecting the date");
+
+ // Create event.
+ // Thursday of 2009-01-05 should be the selected box in the first row with default settings.
+ let hour = new Date().getUTCHours(); // Remember time at click.
+ let eventBox = CalendarTestUtils.monthView.getDayBox(window, 1, 5);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ // Next full hour except last hour hour of the day.
+ let nextHour = hour == 23 ? hour : (hour + 1) % 24;
+ let someDate = cal.dtz.now();
+ someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.monthView.editItemAt(window, 1, 5, 1));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ let eventName;
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.monthView.getItemAt(window, 1, 5, 1);
+ if (!eventBox) {
+ return false;
+ }
+ eventName = eventBox.querySelector(".event-name-label").textContent;
+ return eventName == TITLE2;
+ }, "event name did not update in time");
+
+ Assert.equal(eventName, TITLE2);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.monthView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_multiweekView.js b/comm/calendar/test/browser/views/browser_multiweekView.js
new file mode 100644
index 0000000000..feb8fcd3ec
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_multiweekView.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const TITLE1 = "Multiweek View Event";
+const TITLE2 = "Multiweek View Event Changed";
+const DESC = "Multiweek View Event Description";
+
+add_task(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "multiweek");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector(
+ '#multiweek-view td[selected="true"] > calendar-month-day-box'
+ );
+ return dateLabel && dateLabel.mDate.icalString == "20090101";
+ }, "Inspecting the date");
+
+ // Create event.
+ // Thursday of 2009-01-05 should be the selected box in the first row with default settings.
+ let hour = new Date().getUTCHours(); // Remember time at click.
+ let eventBox = CalendarTestUtils.multiweekView.getDayBox(window, 1, 5);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ // Next full hour except last hour hour of the day.
+ let nextHour = hour == 23 ? hour : (hour + 1) % 24;
+ let someDate = cal.dtz.now();
+ someDate.resetTo(2009, 0, 5, nextHour, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.multiweekView.editItemAt(
+ window,
+ 1,
+ 5,
+ 1
+ ));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.multiweekView.getItemAt(window, 1, 5, 1);
+ if (eventBox === null) {
+ return false;
+ }
+ let eventName = eventBox.querySelector(".event-name-label");
+ return eventName && eventName.textContent == TITLE2;
+ }, "Wait for the new title");
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.multiweekView.waitForNoItemAt(window, 1, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_propertyChanges.js b/comm/calendar/test/browser/views/browser_propertyChanges.js
new file mode 100644
index 0000000000..79848a0e73
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_propertyChanges.js
@@ -0,0 +1,248 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Tests that changes in a calendar's properties are reflected in the current view. */
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+
+let composite = cal.view.getCompositeCalendar(window);
+
+// This is the calendar we're going to change the properties of.
+let thisCalendar = CalendarTestUtils.createCalendar("This Calendar", "memory");
+thisCalendar.setProperty("color", "#ffee22");
+
+// This calendar isn't going to change, and we'll check it doesn't.
+let notThisCalendar = CalendarTestUtils.createCalendar("Not This Calendar", "memory");
+notThisCalendar.setProperty("color", "#dd3333");
+
+add_setup(async function () {
+ let { dedent } = CalendarTestUtils;
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 1
+ DTSTART;VALUE=DATE:20160205
+ DTEND;VALUE=DATE:20160206
+ END:VEVENT
+ `)
+ );
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 2
+ DTSTART:20160205T130000Z
+ DTEND:20160205T150000Z
+ END:VEVENT
+ `)
+ );
+ await thisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:This Event 3
+ DTSTART;VALUE=DATE:20160208
+ DTEND;VALUE=DATE:20160209
+ RRULE:FREQ=DAILY;INTERVAL=2;COUNT=3
+ END:VEVENT
+ `)
+ );
+
+ await notThisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:Not This Event 1
+ DTSTART;VALUE=DATE:20160205
+ DTEND;VALUE=DATE:20160207
+ END:VEVENT
+ `)
+ );
+ await notThisCalendar.addItem(
+ new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:Not This Event 2
+ DTSTART:20160205T140000Z
+ DTEND:20160205T170000Z
+ END:VEVENT
+ `)
+ );
+});
+
+/**
+ * Assert whether the given event box is draggable (editable).
+ *
+ * @param {MozCalendarEventBox} eventBox - The event box to test.
+ * @param {boolean} draggable - Whether we expect it to be draggable.
+ * @param {string} message - A message for assertions.
+ */
+async function assertCanDrag(eventBox, draggable, message) {
+ // Hover to see if the drag gripbars appear.
+ let enterPromise = BrowserTestUtils.waitForEvent(eventBox, "mouseenter");
+ EventUtils.synthesizeMouseAtCenter(eventBox, { type: "mouseover" }, window);
+ await enterPromise;
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.startGripbar),
+ draggable,
+ `Start gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+ Assert.equal(
+ BrowserTestUtils.is_visible(eventBox.endGripbar),
+ draggable,
+ `End gripbar should be ${draggable ? "visible" : "hidden"} on hover: ${message}`
+ );
+}
+
+/**
+ * Assert whether the given event element is editable.
+ *
+ * @param {Element} eventElement - The event element to test.
+ * @param {boolean} editable - Whether we expect it to be editable.
+ * @param {string} message - A message for assertions.
+ */
+async function assertEditable(eventElement, editable, message) {
+ // FIXME: Have more ways to test if an event is editable (e.g. test the
+ // context menu)
+ if (eventElement.matches("calendar-event-box")) {
+ await CalendarTestUtils.assertEventBoxDraggable(eventElement, editable, editable, message);
+ }
+}
+
+async function subTest(viewName, boxSelector, thisBoxCount, notThisBoxCount) {
+ async function makeChangeWithReload(changeFunction) {
+ await changeFunction();
+ await CalendarTestUtils.ensureViewLoaded(window);
+ }
+
+ async function checkBoxItems(expectedCount, checkFunction) {
+ await TestUtils.waitForCondition(
+ () => view.querySelectorAll(boxSelector).length == expectedCount,
+ "waiting for the correct number of boxes to be displayed"
+ );
+ let boxItems = view.querySelectorAll(boxSelector);
+
+ if (!checkFunction) {
+ return;
+ }
+
+ for (let boxItem of boxItems) {
+ // TODO: why is it named `item` in some places and `occurrence` elsewhere?
+ let isThisCalendar =
+ (boxItem.item && boxItem.item.calendar == thisCalendar) ||
+ boxItem.occurrence.calendar == thisCalendar;
+ await checkFunction(boxItem, isThisCalendar);
+ }
+ }
+
+ let view = document.getElementById(`${viewName}-view`);
+
+ await CalendarTestUtils.setCalendarView(window, viewName);
+ await CalendarTestUtils.goToDate(window, 2016, 2, 5);
+
+ info("Check initial state.");
+
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ let style = getComputedStyle(boxItem);
+
+ if (isThisCalendar) {
+ Assert.equal(style.backgroundColor, "rgb(255, 238, 34)", "item background correct");
+ Assert.equal(style.color, "rgb(34, 34, 34)", "item foreground correct");
+ } else {
+ Assert.equal(
+ style.backgroundColor,
+ "rgb(221, 51, 51)",
+ "item background correct (not target calendar)"
+ );
+ Assert.equal(
+ style.color,
+ "rgb(255, 255, 255)",
+ "item foreground correct (not target calendar)"
+ );
+ }
+ await assertEditable(boxItem, true, "Initial event");
+ });
+
+ info("Change color.");
+
+ thisCalendar.setProperty("color", "#16a765");
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ let style = getComputedStyle(boxItem);
+
+ if (isThisCalendar) {
+ Assert.equal(style.backgroundColor, "rgb(22, 167, 101)", "item background correct");
+ Assert.equal(style.color, "rgb(255, 255, 255)", "item foreground correct");
+ } else {
+ Assert.equal(
+ style.backgroundColor,
+ "rgb(221, 51, 51)",
+ "item background correct (not target calendar)"
+ );
+ Assert.equal(
+ style.color,
+ "rgb(255, 255, 255)",
+ "item foreground correct (not target calendar)"
+ );
+ }
+ });
+
+ info("Reset color.");
+ thisCalendar.setProperty("color", "#ffee22");
+
+ info("Disable.");
+
+ thisCalendar.setProperty("disabled", true);
+ await checkBoxItems(notThisBoxCount);
+
+ info("Enable.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("disabled", false));
+ await checkBoxItems(thisBoxCount + notThisBoxCount);
+
+ info("Hide.");
+
+ composite.removeCalendar(thisCalendar);
+ await checkBoxItems(notThisBoxCount);
+
+ info("Show.");
+
+ await makeChangeWithReload(() => composite.addCalendar(thisCalendar));
+ await checkBoxItems(thisBoxCount + notThisBoxCount);
+
+ info("Set read-only.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", true));
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async (boxItem, isThisCalendar) => {
+ if (isThisCalendar) {
+ await assertEditable(boxItem, false, "In readonly calendar");
+ } else {
+ await assertEditable(boxItem, true, "In non-readonly calendar");
+ }
+ });
+
+ info("Clear read-only.");
+
+ await makeChangeWithReload(() => thisCalendar.setProperty("readOnly", false));
+ await checkBoxItems(thisBoxCount + notThisBoxCount, async boxItem => {
+ await assertEditable(boxItem, true, "In non-readonly calendar after clearing");
+ });
+}
+
+add_task(async function testMonthView() {
+ await subTest("month", "calendar-month-day-box-item", 5, 3);
+});
+
+add_task(async function testMultiWeekView() {
+ await subTest("multiweek", "calendar-month-day-box-item", 5, 3);
+});
+
+add_task(async function testWeekView() {
+ await subTest("week", "calendar-editable-item, .multiday-events-list calendar-event-box", 4, 3);
+});
+
+add_task(async function testDayView() {
+ await subTest("day", "calendar-editable-item, .multiday-events-list calendar-event-box", 2, 2);
+});
+
+registerCleanupFunction(async () => {
+ CalendarTestUtils.removeCalendar(thisCalendar);
+ CalendarTestUtils.removeCalendar(notThisCalendar);
+});
diff --git a/comm/calendar/test/browser/views/browser_taskView.js b/comm/calendar/test/browser/views/browser_taskView.js
new file mode 100644
index 0000000000..c049c9668f
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_taskView.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { MID_SLEEP, execEventDialogCallback } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarUtils.jsm"
+);
+var { saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+const TITLE = "Task";
+const DESCRIPTION = "1. Do A\n2. Do B";
+const PERCENTCOMPLETE = "50";
+
+add_task(async function () {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ // Open task view.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("tasksButton"), {}, window);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ // Make sure that testing calendar is selected.
+ let calList = document.querySelector(`#calendar-list > [calendar-id="${calendar.id}"]`);
+ Assert.ok(calList);
+ EventUtils.synthesizeMouseAtCenter(calList, {}, window);
+
+ let taskTreeNode = document.getElementById("calendar-task-tree");
+ Assert.equal(taskTreeNode.mTaskArray.length, 0);
+
+ // Add task.
+ let taskInput = document.getElementById("view-task-edit-field");
+ taskInput.focus();
+ EventUtils.sendString(TITLE, window);
+ EventUtils.synthesizeKey("VK_RETURN", {}, window);
+
+ // Verify added.
+ await TestUtils.waitForCondition(
+ () => taskTreeNode.mTaskArray.length == 1,
+ "Added Task did not appear"
+ );
+
+ // Last added task is automatically selected so verify detail window data.
+ Assert.equal(document.getElementById("calendar-task-details-title").textContent, TITLE);
+
+ // Open added task
+ // Double-click on completion checkbox is ignored as opening action, so don't
+ // click at immediate left where the checkbox is located.
+ let eventWindowPromise = CalendarTestUtils.waitForEventDialog("edit");
+ let treeChildren = document.querySelector("#calendar-task-tree .calendar-task-treechildren");
+ Assert.ok(treeChildren);
+ EventUtils.synthesizeMouse(treeChildren, 50, 0, { clickCount: 2 }, window);
+
+ await eventWindowPromise;
+ await execEventDialogCallback(async (taskWindow, iframeWindow) => {
+ // Verify calendar.
+ Assert.equal(iframeWindow.document.getElementById("item-calendar").value, "Test");
+
+ await setData(taskWindow, iframeWindow, {
+ status: "needs-action",
+ percent: PERCENTCOMPLETE,
+ description: DESCRIPTION,
+ });
+
+ await saveAndCloseItemDialog(taskWindow);
+ });
+
+ Assert.less(taskTreeNode.mTaskArray.length, 2, "Should not have added task");
+ Assert.greater(taskTreeNode.mTaskArray.length, 0, "Should not have removed task");
+
+ // Verify description and status in details pane.
+ await TestUtils.waitForCondition(() => {
+ let desc = document.getElementById("calendar-task-details-description");
+ return desc && desc.contentDocument.body.innerText == DESCRIPTION;
+ }, "Calendar task description");
+ Assert.equal(document.getElementById("calendar-task-details-status").textContent, "Needs Action");
+
+ // This is a hack.
+ taskTreeNode.getTaskAtRow(0).calendar.setProperty("capabilities.priority.supported", true);
+
+ // Set high priority and verify it in detail pane.
+ EventUtils.synthesizeMouseAtCenter(document.getElementById("task-actions-priority"), {}, window);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ let priorityMenu = document.querySelector(
+ "#task-actions-priority-menupopup > .priority-1-menuitem"
+ );
+ Assert.ok(priorityMenu);
+ EventUtils.synthesizeMouseAtCenter(priorityMenu, {}, window);
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("calendar-task-details-priority-high").hidden,
+ "#calendar-task-details-priority-high did not show"
+ );
+
+ // Verify that tooltip shows status, priority and percent complete.
+ let toolTipNode = document.getElementById("taskTreeTooltip");
+ toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0));
+
+ function getTooltipDescription(index) {
+ return toolTipNode.querySelector(
+ `.tooltipHeaderTable > tr:nth-of-type(${index}) > .tooltipHeaderDescription`
+ ).textContent;
+ }
+
+ // Name
+ Assert.equal(getTooltipDescription(1), TITLE);
+ // Calendar
+ Assert.equal(getTooltipDescription(2), "Test");
+ // Priority
+ Assert.equal(getTooltipDescription(3), "High");
+ // Status
+ Assert.equal(getTooltipDescription(4), "Needs Action");
+ // Complete
+ Assert.equal(getTooltipDescription(5), PERCENTCOMPLETE + "%");
+
+ // Mark completed, verify.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("task-actions-markcompleted"),
+ {},
+ window
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, MID_SLEEP));
+
+ toolTipNode.ownerGlobal.showToolTip(toolTipNode, taskTreeNode.getTaskAtRow(0));
+ Assert.equal(getTooltipDescription(4), "Completed");
+
+ // Delete task and verify.
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("calendar-delete-task-button"),
+ {},
+ window
+ );
+ await TestUtils.waitForCondition(
+ () => taskTreeNode.mTaskArray.length == 0,
+ "Task did not delete"
+ );
+
+ let tabmail = document.getElementById("tabmail");
+ tabmail.closeTab(tabmail.currentTabInfo);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/browser_viewSwitch.js b/comm/calendar/test/browser/views/browser_viewSwitch.js
new file mode 100644
index 0000000000..e730f3d797
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_viewSwitch.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests that the time indicator is restarted and scroll position is restored
+ * when switching tabs or views.
+ */
+
+/**
+ * Wait until the view's timebar shows the given number of visible hours.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view.
+ * @param {number} numHours - The expected number of visible hours.
+ *
+ * @returns {Promise} - Promise that resolves when the timebar has numHours
+ * visible hours.
+ */
+function waitForVisibleHours(view, numHours) {
+ // The timebar is the only scrollable child in its column (the others are
+ // sticky), so the difference between the scroll area's scrollTopMax and the
+ // timebar's clientHeight should give us the visible height.
+ return TestUtils.waitForCondition(() => {
+ let timebarHeight = view.timebar.clientHeight;
+ let visiblePx = timebarHeight - view.grid.scrollTopMax;
+ let expectPx = (numHours / 24) * timebarHeight;
+ // Allow up to 3px difference to accommodate accumulated integer rounding
+ // errors (e.g. clientHeight is a rounded integer, whilst client rectangles
+ // and expectPx are floating).
+ return Math.abs(visiblePx - expectPx) < 3;
+ }, `${view.id} should have ${numHours} hours visible`);
+}
+
+/**
+ * Wait until the view's timebar's first visible hour is the given hour.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view.
+ * @param {number} hour - The expected first visible hour.
+ *
+ * @returns {Promise} - Promise that resolves when the timebar has the given
+ * first visible hour.
+ */
+function waitForFirstVisibleHour(view, hour) {
+ return TestUtils.waitForCondition(() => {
+ let expectPx = (hour / 24) * view.timebar.clientHeight;
+ let actualPx = view.grid.scrollTop;
+ return Math.abs(actualPx - expectPx) < 3;
+ }, `${view.id} first visible hour should be ${hour}`);
+}
+
+/**
+ * Perform a scroll on the view by one hour.
+ *
+ * @param {CalendarMultidayBaseView} view - The calendar view to scroll.
+ * @param {boolean} scrollDown - Whether to scroll down, otherwise scrolls up.
+ */
+async function doScroll(view, scrollDown) {
+ let scrollPromise = BrowserTestUtils.waitForEvent(view.grid, "scroll");
+ let viewRect = view.getBoundingClientRect();
+ EventUtils.synthesizeWheel(
+ view.grid,
+ viewRect.width / 2,
+ viewRect.height / 2,
+ { deltaY: scrollDown ? 1 : -1, deltaMode: WheelEvent.DOM_DELTA_LINE },
+ window
+ );
+ await scrollPromise;
+}
+
+add_task(async function () {
+ let expectedVisibleHours = 3;
+ let expectedStartHour = 3;
+
+ let tabmail = document.getElementById("tabmail");
+ Assert.equal(tabmail.tabInfo.length, 1);
+
+ Assert.equal(Services.prefs.getIntPref("calendar.view.daystarthour"), expectedStartHour);
+ Assert.equal(Services.prefs.getIntPref("calendar.view.dayendhour"), 12);
+ Assert.equal(Services.prefs.getIntPref("calendar.view.visiblehours"), expectedVisibleHours);
+
+ // Open the day view, check the display matches the prefs.
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ let dayView = document.getElementById("day-view");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Scroll down 3 hours. We'll check this scroll position later.
+ await doScroll(dayView, true);
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 1);
+
+ await doScroll(dayView, true);
+ await doScroll(dayView, true);
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Open the week view, check the display matches the prefs.
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ let weekView = document.getElementById("week-view");
+
+ await waitForFirstVisibleHour(weekView, expectedStartHour);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+
+ // Scroll up 1 hour.
+ await doScroll(weekView, false);
+ await waitForFirstVisibleHour(weekView, expectedStartHour - 1);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+
+ // Go back to the day view, check the timer and scroll position.
+
+ await CalendarTestUtils.setCalendarView(window, "day");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Switch away from the calendar tab.
+
+ tabmail.switchToTab(0);
+
+ // Switch back to the calendar tab. Check scroll position.
+
+ tabmail.switchToTab(1);
+ Assert.equal(window.currentView().id, "day-view");
+
+ await waitForFirstVisibleHour(dayView, expectedStartHour + 3);
+ await waitForVisibleHours(dayView, expectedVisibleHours);
+
+ // Go back to the week view. Check scroll position.
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+
+ await waitForFirstVisibleHour(weekView, expectedStartHour - 1);
+ await waitForVisibleHours(weekView, expectedVisibleHours);
+});
diff --git a/comm/calendar/test/browser/views/browser_weekView.js b/comm/calendar/test/browser/views/browser_weekView.js
new file mode 100644
index 0000000000..0835da2f23
--- /dev/null
+++ b/comm/calendar/test/browser/views/browser_weekView.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatDate, formatTime, saveAndCloseItemDialog, setData } = ChromeUtils.import(
+ "resource://testing-common/calendar/ItemEditingHelpers.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var TITLE1 = "Week View Event";
+var TITLE2 = "Week View Event Changed";
+var DESC = "Week View Event Description";
+
+add_task(async function testWeekView() {
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ await CalendarTestUtils.setCalendarView(window, "week");
+ await CalendarTestUtils.goToDate(window, 2009, 1, 1);
+
+ // Verify date.
+ await TestUtils.waitForCondition(() => {
+ let dateLabel = document.querySelector("#week-view .day-column-selected calendar-event-column");
+ return dateLabel?.date.icalString == "20090101";
+ }, "Date is selected");
+
+ // Create event at 8 AM.
+ // Thursday of 2009-01-05 is 4th with default settings.
+ let eventBox = CalendarTestUtils.weekView.getHourBoxAt(window, 5, 8);
+ let { dialogWindow, iframeWindow, iframeDocument } = await CalendarTestUtils.editNewEvent(
+ window,
+ eventBox
+ );
+
+ // Check that the start time is correct.
+ let someDate = cal.createDateTime();
+ someDate.resetTo(2009, 0, 5, 8, 0, 0, cal.dtz.UTC);
+
+ let startPicker = iframeDocument.getElementById("event-starttime");
+ Assert.equal(startPicker._datepicker._inputField.value, formatDate(someDate));
+ Assert.equal(startPicker._timepicker._inputField.value, formatTime(someDate));
+
+ // Fill in title, description and calendar.
+ await setData(dialogWindow, iframeWindow, {
+ title: TITLE1,
+ description: DESC,
+ calendar: "Test",
+ });
+
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // If it was created successfully, it can be opened.
+ ({ dialogWindow, iframeWindow } = await CalendarTestUtils.weekView.editEventAt(window, 5, 1));
+ // Change title and save changes.
+ await setData(dialogWindow, iframeWindow, { title: TITLE2 });
+ await saveAndCloseItemDialog(dialogWindow);
+
+ // Check if name was saved.
+ let eventName;
+ await TestUtils.waitForCondition(() => {
+ eventBox = CalendarTestUtils.weekView.getEventBoxAt(window, 5, 1);
+ if (!eventBox) {
+ return false;
+ }
+ eventName = eventBox.querySelector(".event-name-label").textContent;
+ return eventName == TITLE2;
+ }, "event name did not update in time");
+
+ Assert.equal(eventName, TITLE2);
+
+ // Delete event.
+ EventUtils.synthesizeMouseAtCenter(eventBox, {}, window);
+ eventBox.focus();
+ EventUtils.synthesizeKey("VK_DELETE", {}, window);
+ await CalendarTestUtils.weekView.waitForNoEventBoxAt(window, 5, 1);
+
+ Assert.ok(true, "Test ran to completion");
+});
diff --git a/comm/calendar/test/browser/views/head.js b/comm/calendar/test/browser/views/head.js
new file mode 100644
index 0000000000..c0f924d9b5
--- /dev/null
+++ b/comm/calendar/test/browser/views/head.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+const calendarViewsInitialState = CalendarTestUtils.saveCalendarViewsState(window);
+
+registerCleanupFunction(async () => {
+ await CalendarTestUtils.restoreCalendarViewsState(window, calendarViewsInitialState);
+});
diff --git a/comm/calendar/test/moz.build b/comm/calendar/test/moz.build
new file mode 100644
index 0000000000..8c6dc6b9db
--- /dev/null
+++ b/comm/calendar/test/moz.build
@@ -0,0 +1,30 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.ini",
+ "browser/contextMenu/browser.ini",
+ "browser/eventDialog/browser.ini",
+ "browser/invitations/browser.ini",
+ "browser/preferences/browser.ini",
+ "browser/providers/browser.ini",
+ "browser/recurrence/browser.ini",
+ "browser/recurrence/browser_rotated.ini",
+ "browser/timezones/browser.ini",
+ "browser/views/browser.ini",
+]
+
+TESTING_JS_MODULES.calendar += [
+ "CalDAVServer.jsm",
+ "CalendarTestUtils.jsm",
+ "CalendarUtils.jsm",
+ "ICSServer.jsm",
+ "ItemEditingHelpers.jsm",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "unit/providers/xpcshell.ini",
+ "unit/xpcshell.ini",
+]
diff --git a/comm/calendar/test/unit/data/bug1790339.sql b/comm/calendar/test/unit/data/bug1790339.sql
new file mode 100644
index 0000000000..ad464de995
--- /dev/null
+++ b/comm/calendar/test/unit/data/bug1790339.sql
@@ -0,0 +1,194 @@
+-- Data for test_bug1790339.js. There's two events here, one saved with folded ICAL strings
+-- (as libical would do) and one with unfolded ICAL strings (as ical.js does).
+--
+-- This file contains significant white-space and is deliberately saved with Windows line-endings.
+
+CREATE TABLE cal_calendar_schema_version (version INTEGER);
+CREATE TABLE cal_attendees (
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT,
+ icalString TEXT);
+CREATE TABLE cal_recurrence (item_id TEXT, cal_id TEXT, icalString TEXT);
+CREATE TABLE cal_properties (
+ item_id TEXT,
+ key TEXT,
+ value BLOB,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ cal_id TEXT);
+CREATE TABLE cal_events (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ event_start INTEGER,
+ event_end INTEGER,
+ event_stamp INTEGER,
+ event_start_tz TEXT,
+ event_end_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_todos (
+ cal_id TEXT,
+ id TEXT,
+ time_created INTEGER,
+ last_modified INTEGER,
+ title TEXT,
+ priority INTEGER,
+ privacy TEXT,
+ ical_status TEXT,
+ flags INTEGER,
+ todo_entry INTEGER,
+ todo_due INTEGER,
+ todo_completed INTEGER,
+ todo_complete INTEGER,
+ todo_entry_tz TEXT,
+ todo_due_tz TEXT,
+ todo_completed_tz TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ alarm_last_ack INTEGER,
+ todo_stamp INTEGER,
+ offline_journal INTEGER);
+CREATE TABLE cal_tz_version (version TEXT);
+CREATE TABLE cal_metadata (cal_id TEXT, item_id TEXT, value BLOB);
+CREATE TABLE cal_alarms (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_relations (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_attachments (
+ item_id TEXT,
+ cal_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ icalString TEXT);
+CREATE TABLE cal_parameters (
+ cal_id TEXT,
+ item_id TEXT,
+ recurrence_id INTEGER,
+ recurrence_id_tz TEXT,
+ key1 TEXT,
+ key2 TEXT,
+ value BLOB);
+
+INSERT INTO cal_calendar_schema_version VALUES (23);
+
+INSERT INTO cal_events (
+ cal_id,
+ id,
+ time_created,
+ last_modified,
+ title,
+ flags,
+ event_start,
+ event_end,
+ event_stamp,
+ event_start_tz,
+ event_end_tz
+) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 1663028606000000,
+ 1663030277000000,
+ 'test',
+ 86,
+ 1663032600000000,
+ 1663037100000000,
+ 1663030277000000,
+ 'Pacific/Auckland',
+ 'Pacific/Auckland'
+);
+
+INSERT INTO cal_attachments (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central
+ /thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTACH:https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2'
+);
+
+INSERT INTO cal_attendees (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:
+ test@example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=Test Person:mailto:test@example.com'
+);
+
+INSERT INTO cal_recurrence (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,
+ SA,SU'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RRULE:FREQ=WEEKLY;UNTIL=20220913T013000Z;INTERVAL=22;BYDAY=MO,TU,WE,TH,FR,SA,SU'
+);
+
+INSERT INTO cal_relations (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@
+ example.com'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'RELATED-TO;RELTYPE=SIBLING:19960401-080045-4000F192713@example.com'
+);
+
+INSERT INTO cal_alarms (cal_id, item_id, icalString) VALUES (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-111111111111',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s
+ essential that you don''t forget.
+END:VALARM
+'
+), (
+ '00000000-0000-0000-0000-000000000000',
+ '00000000-0000-0000-0000-222222222222',
+ 'BEGIN:VALARM
+ACTION:DISPLAY
+TRIGGER:-PT5M
+DESCRIPTION:Make sure you don''t miss this very very important event. It''s essential that you don''t forget.
+END:VALARM
+'
+);
diff --git a/comm/calendar/test/unit/data/import.ics b/comm/calendar/test/unit/data/import.ics
new file mode 100644
index 0000000000..b6e7a965d7
--- /dev/null
+++ b/comm/calendar/test/unit/data/import.ics
@@ -0,0 +1,24 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VEVENT
+SUMMARY:Event One
+DTSTART:20190101T150000
+DTEND:20190101T160000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Four
+DTSTART:20190101T180000
+DTEND:20190101T190000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Three
+DTSTART:20190101T170000
+DTEND:20190101T180000
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Event Two
+DTSTART:20190101T160000
+DTEND:20190101T170000
+END:VEVENT
+END:VCALENDAR
diff --git a/comm/calendar/test/unit/head.js b/comm/calendar/test/unit/head.js
new file mode 100644
index 0000000000..2b7b9c99fa
--- /dev/null
+++ b/comm/calendar/test/unit/head.js
@@ -0,0 +1,337 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs");
+var { FileUtils } = ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs");
+
+var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs");
+
+ChromeUtils.defineModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm");
+
+updateAppInfo();
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function createDate(aYear, aMonth, aDay, aHasTime, aHour, aMinute, aSecond, aTimezone) {
+ let date = Cc["@mozilla.org/calendar/datetime;1"].createInstance(Ci.calIDateTime);
+ date.resetTo(
+ aYear,
+ aMonth,
+ aDay,
+ aHour || 0,
+ aMinute || 0,
+ aSecond || 0,
+ aTimezone || cal.dtz.UTC
+ );
+ date.isDate = !aHasTime;
+ return date;
+}
+
+function createEventFromIcalString(icalString) {
+ if (/^BEGIN:VCALENDAR/.test(icalString)) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(icalString);
+ let items = parser.getItems();
+ cal.ASSERT(items.length == 1);
+ return items[0].QueryInterface(Ci.calIEvent);
+ }
+ let event = Cc["@mozilla.org/calendar/event;1"].createInstance(Ci.calIEvent);
+ event.icalString = icalString;
+ return event;
+}
+
+function createTodoFromIcalString(icalString) {
+ let todo = Cc["@mozilla.org/calendar/todo;1"].createInstance(Ci.calITodo);
+ todo.icalString = icalString;
+ return todo;
+}
+
+function getMemoryCal() {
+ return Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+}
+
+function getStorageCal() {
+ // Whenever we get the storage calendar we need to request a profile,
+ // otherwise the cleanup functions will not run
+ do_get_profile();
+
+ // create URI
+ let db = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ db.append("test_storage.sqlite");
+ let uri = Services.io.newFileURI(db);
+
+ // Make sure timezone service is initialized
+ Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null);
+
+ // create storage calendar
+ let stor = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+ stor.uri = uri;
+ stor.id = cal.getUUID();
+ return stor;
+}
+
+/**
+ * Return an item property as string.
+ *
+ * @param aItem
+ * @param string aProp possible item properties: start, end, duration,
+ * generation, title,
+ * id, calendar, creationDate, lastModifiedTime,
+ * stampTime, priority, privacy, status,
+ * alarmLastAck, recurrenceStartDate
+ * and any property that can be obtained using getProperty()
+ */
+function getProps(aItem, aProp) {
+ let value = null;
+ switch (aProp) {
+ case "start":
+ value = aItem.startDate || aItem.entryDate || null;
+ break;
+ case "end":
+ value = aItem.endDate || aItem.dueDate || null;
+ break;
+ case "duration":
+ value = aItem.duration || null;
+ break;
+ case "generation":
+ value = aItem.generation;
+ break;
+ case "title":
+ value = aItem.title;
+ break;
+ case "id":
+ value = aItem.id;
+ break;
+ case "calendar":
+ value = aItem.calendar.id;
+ break;
+ case "creationDate":
+ value = aItem.creationDate;
+ break;
+ case "lastModifiedTime":
+ value = aItem.lastModifiedTime;
+ break;
+ case "stampTime":
+ value = aItem.stampTime;
+ break;
+ case "priority":
+ value = aItem.priority;
+ break;
+ case "privacy":
+ value = aItem.privacy;
+ break;
+ case "status":
+ value = aItem.status;
+ break;
+ case "alarmLastAck":
+ value = aItem.alarmLastAck;
+ break;
+ case "recurrenceStartDate":
+ value = aItem.recurrenceStartDate;
+ break;
+ default:
+ value = aItem.getProperty(aProp);
+ }
+ if (value) {
+ return value.toString();
+ }
+ return null;
+}
+
+function compareItemsSpecific(aLeftItem, aRightItem, aPropArray) {
+ if (!aPropArray) {
+ // left out: "id", "calendar", "lastModifiedTime", "generation",
+ // "stampTime" as these are expected to change
+ aPropArray = [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "creationDate",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ];
+ }
+ if (aLeftItem instanceof Ci.calIEvent) {
+ aLeftItem.QueryInterface(Ci.calIEvent);
+ } else if (aLeftItem instanceof Ci.calITodo) {
+ aLeftItem.QueryInterface(Ci.calITodo);
+ }
+ for (let i = 0; i < aPropArray.length; i++) {
+ equal(getProps(aLeftItem, aPropArray[i]), getProps(aRightItem, aPropArray[i]));
+ }
+}
+
+/**
+ * Unfold ics lines by removing any \r\n or \n followed by a linear whitespace
+ * (space or htab).
+ *
+ * @param aLine The line to unfold
+ * @returns The unfolded line
+ */
+function ics_unfoldline(aLine) {
+ return aLine.replace(/\r?\n[ \t]/g, "");
+}
+
+/**
+ * Dedent the template string tagged with this function to make indented data
+ * easier to read. Usage:
+ *
+ * let data = dedent`
+ * This is indented data it will be unindented so that the first line has
+ * no leading spaces and the second is indented by two spaces.
+ * `;
+ *
+ * @param strings The string fragments from the template string
+ * @param ...values The interpolated values
+ * @returns The interpolated, dedented string
+ */
+function dedent(strings, ...values) {
+ let parts = [];
+ // Perform variable interpolation
+ let minIndent = Infinity;
+ for (let [i, string] of strings.entries()) {
+ let innerparts = string.split("\n");
+ if (i == 0) {
+ innerparts.shift();
+ }
+ if (i == strings.length - 1) {
+ innerparts.pop();
+ }
+ for (let [j, ip] of innerparts.entries()) {
+ let match = ip.match(/^(\s*)\S*/);
+ if (j != 0) {
+ minIndent = Math.min(minIndent, match[1].length);
+ }
+ }
+ parts.push(innerparts);
+ }
+
+ return parts
+ .map((part, i) => {
+ return (
+ part
+ .map((line, j) => {
+ return j == 0 && i > 0 ? line : line.substr(minIndent);
+ })
+ .join("\n") + (i < values.length ? values[i] : "")
+ );
+ })
+ .join("");
+}
+
+/**
+ * Read a JSON file and return the JS object
+ */
+function readJSONFile(aFile) {
+ let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
+ try {
+ stream.init(aFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+ let bytes = NetUtil.readInputStream(stream, stream.available());
+ let data = JSON.parse(new TextDecoder().decode(bytes));
+ return data;
+ } catch (ex) {
+ dump("readJSONFile: Error reading JSON file: " + ex);
+ } finally {
+ stream.close();
+ }
+ return false;
+}
+
+function do_load_timezoneservice(callback) {
+ do_test_pending();
+ cal.timezoneService.startup({
+ onResult() {
+ do_test_finished();
+ callback();
+ },
+ });
+}
+
+function do_load_calmgr(callback) {
+ do_test_pending();
+ cal.manager.startup({
+ onResult() {
+ do_test_finished();
+ callback();
+ },
+ });
+}
+
+function do_calendar_startup(callback) {
+ let obs = {
+ observe() {
+ Services.obs.removeObserver(this, "calendar-startup-done");
+ do_test_finished();
+ executeSoon(callback);
+ },
+ };
+
+ let startupService = Cc["@mozilla.org/calendar/startup-service;1"].getService(
+ Ci.nsISupports
+ ).wrappedJSObject;
+
+ if (startupService.started) {
+ callback();
+ } else {
+ do_test_pending();
+ Services.obs.addObserver(obs, "calendar-startup-done");
+ if (this._profileInitialized) {
+ Services.obs.notifyObservers(null, "profile-after-change", "xpcshell-do-get-profile");
+ } else {
+ do_get_profile(true);
+ }
+ }
+}
+
+/**
+ * Monkey patch the function with the name x on obj and overwrite it with func.
+ * The first parameter of this function is the original function that can be
+ * called at any time.
+ *
+ * @param obj The object the function is on.
+ * @param name The string name of the function.
+ * @param func The function to monkey patch with.
+ */
+function monkeyPatch(obj, x, func) {
+ let old = obj[x];
+ obj[x] = function () {
+ let parent = old.bind(obj);
+ let args = Array.from(arguments);
+ args.unshift(parent);
+ try {
+ return func.apply(obj, args);
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+ };
+}
+
+/**
+ * Asserts the properties of an actual extract parser result to what was
+ * expected.
+ *
+ * @param {object} actual - Mostly the actual output of parse().
+ * @param {object} expected - The expected output.
+ * @param {string} level - The variable name to refer to report on.
+ */
+function compareExtractResults(actual, expected, level = "") {
+ for (let [key, value] of Object.entries(expected)) {
+ let qualifiedKey = [level, Array.isArray(expected) ? `[${key}]` : `.${key}`].join("");
+ if (value && typeof value == "object") {
+ Assert.ok(actual[key], `${qualifiedKey} is not null`);
+ compareExtractResults(actual[key], value, qualifiedKey);
+ continue;
+ }
+ Assert.equal(actual[key], value, `${qualifiedKey} has value "${value}"`);
+ }
+}
diff --git a/comm/calendar/test/unit/providers/head.js b/comm/calendar/test/unit/providers/head.js
new file mode 100644
index 0000000000..3c995ab31a
--- /dev/null
+++ b/comm/calendar/test/unit/providers/head.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { PromiseUtils } = ChromeUtils.importESModule("resource://gre/modules/PromiseUtils.sys.mjs");
+
+var { updateAppInfo } = ChromeUtils.importESModule("resource://testing-common/AppInfo.sys.mjs");
+updateAppInfo();
+
+// The tests in this directory each do the same thing, with slight variations as needed for each
+// calendar provider. The core of the test lives in this file and the tests call it when ready.
+
+do_get_profile();
+add_setup(async () => {
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+ await new Promise(resolve => cal.timezoneService.startup({ onResult: resolve }));
+ cal.manager.addCalendarObserver(calendarObserver);
+});
+
+let calendarObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ /* calIObserver */
+
+ _batchCount: 0,
+ _batchRequired: true,
+ onStartBatch(calendar) {
+ info(`onStartBatch ${calendar?.id} ${++this._batchCount}`);
+ Assert.equal(calendar, this._expectedCalendar);
+ Assert.equal(this._batchCount, 1, "onStartBatch must not occur in a batch");
+ },
+ onEndBatch(calendar) {
+ info(`onEndBatch ${calendar?.id} ${this._batchCount--}`);
+ Assert.equal(calendar, this._expectedCalendar);
+ Assert.equal(this._batchCount, 0, "onEndBatch must occur in a batch");
+ },
+ onLoad(calendar) {
+ info(`onLoad ${calendar.id}`);
+ Assert.equal(this._batchCount, 0, "onLoad must not occur in a batch");
+ Assert.equal(calendar, this._expectedCalendar);
+ if (this._onLoadPromise) {
+ this._onLoadPromise.resolve();
+ }
+ },
+ onAddItem(item) {
+ info(`onAddItem ${item.calendar.id} ${item.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onAddItem must occur in a batch");
+ }
+ if (this._onAddItemPromise) {
+ this._onAddItemPromise.resolve();
+ }
+ },
+ onModifyItem(newItem, oldItem) {
+ info(`onModifyItem ${newItem.calendar.id} ${newItem.id}`);
+ if (this._batchRequired) {
+ Assert.equal(this._batchCount, 1, "onModifyItem must occur in a batch");
+ }
+ if (this._onModifyItemPromise) {
+ this._onModifyItemPromise.resolve();
+ }
+ },
+ onDeleteItem(deletedItem) {
+ info(`onDeleteItem ${deletedItem.calendar.id} ${deletedItem.id}`);
+ if (this._onDeleteItemPromise) {
+ this._onDeleteItemPromise.resolve();
+ }
+ },
+ onError(calendar, errNo, message) {},
+ onPropertyChanged(calendar, name, value, oldValue) {},
+ onPropertyDeleting(calendar, name) {},
+};
+
+/**
+ * Create and register a calendar.
+ *
+ * @param {string} type - The calendar provider to use.
+ * @param {string} url - URL of the server.
+ * @param {boolean} useCache - Should this calendar have offline storage?
+ * @returns {calICalendar}
+ */
+function createCalendar(type, url, useCache) {
+ let calendar = cal.manager.createCalendar(type, Services.io.newURI(url));
+ calendar.name = type + (useCache ? " with cache" : " without cache");
+ calendar.id = cal.getUUID();
+ calendar.setProperty("cache.enabled", useCache);
+
+ cal.manager.registerCalendar(calendar);
+ calendar = cal.manager.getCalendarById(calendar.id);
+ calendarObserver._expectedCalendar = calendar;
+
+ info(`Created calendar ${calendar.id}`);
+ return calendar;
+}
+
+/**
+ * Creates an event and adds it to the given calendar.
+ *
+ * @param {calICalendar} calendar
+ * @returns {calIEvent}
+ */
+async function runAddItem(calendar) {
+ let event = new CalEvent();
+ event.id = "6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a";
+ event.title = "New event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ await calendar.addItem(event);
+ await Promise.any([
+ calendarObserver._onAddItemPromise.promise,
+ calendarObserver._onModifyItemPromise.promise,
+ ]);
+
+ return event;
+}
+
+/**
+ * Modifies the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runModifyItem(calendar) {
+ let event = await calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ let clone = event.clone();
+ clone.title = "Modified event";
+
+ calendarObserver._onModifyItemPromise = PromiseUtils.defer();
+ await calendar.modifyItem(clone, event);
+ await calendarObserver._onModifyItemPromise.promise;
+}
+
+/**
+ * Deletes the event from runAddItem.
+ *
+ * @param {calICalendar} calendar
+ */
+async function runDeleteItem(calendar) {
+ let event = await calendar.getItem("6b7dd6f6-d6f0-4e93-a953-bb5473c4c47a");
+
+ calendarObserver._onDeleteItemPromise = PromiseUtils.defer();
+ await calendar.deleteItem(event);
+ await calendarObserver._onDeleteItemPromise.promise;
+}
diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js
new file mode 100644
index 0000000000..7a61973644
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_caldavCalendar_cached.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+add_setup(async function () {
+ CalDAVServer.open();
+ await CalDAVServer.putItemInternal(
+ "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+});
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.privileges = null;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(!calendar.readOnly, "calendar was not marked read-only");
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests modifyItem() does not hang when the server reports no actual
+ * modifications were made.
+ */
+add_task(async function testModifyItemWithNoChanges() {
+ let event = new CalEvent();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb";
+ event.title = "A New Event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+ await calendar.addItem(event);
+
+ let clone = event.clone();
+ clone.title = "A Modified Event";
+
+ let putItemInternal = CalDAVServer.putItemInternal;
+ CalDAVServer.putItemInternal = () => {};
+
+ let modifiedEvent = await calendar.modifyItem(clone, event);
+ CalDAVServer.putItemInternal = putItemInternal;
+
+ Assert.ok(modifiedEvent, "an event was returned");
+ Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned");
+
+ await calendar.deleteItem(modifiedEvent);
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests that an error response from the server when syncing doesn't delete
+ * items from the local calendar.
+ */
+add_task(async function testSyncError1() {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should exist when first connected"
+ );
+
+ info("syncing with rate limit error");
+ CalDAVServer.throwRateLimitErrors = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ CalDAVServer.throwRateLimitErrors = false;
+ info("sync with rate limit error complete");
+
+ Assert.equal(
+ calendar.getProperty("currentStatus"),
+ Cr.NS_OK,
+ "calendar should not be in an error state"
+ );
+ Assert.equal(calendar.getProperty("disabled"), null, "calendar should not be disabled");
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should still exist after error response"
+ );
+
+ info("syncing without rate limit error");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ info("sync without rate limit error complete");
+
+ Assert.ok(
+ await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"),
+ "item should still exist after successful sync"
+ );
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests that multiple pages of item responses from the server when syncing
+ * doesn't result in items being deleted from the local calendar.
+ *
+ * The server has a page size of 3, although this test should pass regardless
+ * of the page size.
+ */
+add_task(async function testSyncError2() {
+ // Add some items to the server so multiple requests are required to get
+ // them all. There's already one item on the server.
+ for (let i = 0; i < 3; i++) {
+ await CalDAVServer.putItemInternal(
+ `fake-uid-${i}.ics`,
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:fake-uid-${i}
+ SUMMARY:event ${i}
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+ }
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ let items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null);
+ Assert.equal(items.length, 4, "all items added to calendar when first connected");
+
+ info("forced syncing with multiple pages");
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.mWebdavSyncToken = null;
+ calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject.saveCalendarProperties();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ calendar.refresh();
+ await calendarObserver._onLoadPromise.promise;
+ info("forced sync with multiple pages complete");
+
+ items = await calendar.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_TYPE_ALL, 0, null, null);
+ Assert.equal(items.length, 4, "all items still in calendar after forced refresh");
+
+ cal.manager.unregisterCalendar(calendar);
+
+ // Delete the added items.
+ for (let i = 0; i < 3; i++) {
+ CalDAVServer.deleteItemInternal(`fake-uid-${i}.ics`);
+ }
+});
diff --git a/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js
new file mode 100644
index 0000000000..025a1ba871
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_caldavCalendar_uncached.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalDAVServer } = ChromeUtils.import("resource://testing-common/calendar/CalDAVServer.jsm");
+
+add_setup(async function () {
+ CalDAVServer.open();
+ await CalDAVServer.putItemInternal(
+ "5a9fa76c-93f3-4ad8-9f00-9e52aedd2821.ics",
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+ );
+});
+registerCleanupFunction(() => CalDAVServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._batchRequired = true;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests calendars that return status 404 for "current-user-privilege-set" are
+ * not flagged read-only.
+ */
+add_task(async function testCalendarWithNoPrivSupport() {
+ CalDAVServer.privileges = null;
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(!calendar.readOnly, "calendar was not marked read-only");
+
+ cal.manager.unregisterCalendar(calendar);
+});
+
+/**
+ * Tests modifyItem() does not hang when the server reports no actual
+ * modifications were made.
+ */
+add_task(async function testModifyItemWithNoChanges() {
+ let event = new CalEvent();
+ let calendar = createCalendar("caldav", CalDAVServer.url, false);
+ event.id = "6f6dd7b6-0fbd-39e4-359a-a74c4c3745bb";
+ event.title = "A New Event";
+ event.startDate = cal.createDateTime("20200303T205500Z");
+ event.endDate = cal.createDateTime("20200303T210200Z");
+ await calendar.addItem(event);
+
+ let clone = event.clone();
+ clone.title = "A Modified Event";
+
+ let putItemInternal = CalDAVServer.putItemInternal;
+ CalDAVServer.putItemInternal = () => {};
+
+ let modifiedEvent = await calendar.modifyItem(clone, event);
+ CalDAVServer.putItemInternal = putItemInternal;
+
+ Assert.ok(modifiedEvent, "an event was returned");
+ Assert.equal(modifiedEvent.title, event.title, "the un-modified event is returned");
+
+ await calendar.deleteItem(modifiedEvent);
+ cal.manager.unregisterCalendar(calendar);
+});
diff --git a/comm/calendar/test/unit/providers/test_icsCalendar_cached.js b/comm/calendar/test/unit/providers/test_icsCalendar_cached.js
new file mode 100644
index 0000000000..9bc1b127da
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_icsCalendar_cached.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function () {
+ // TODO: item notifications from a cached ICS calendar occur outside of batches.
+ // This isn't fatal but it shouldn't happen. Side-effects include alarms firing
+ // twice - once from onAddItem then again at onLoad.
+ //
+ // Remove the next line when this is fixed.
+ calendarObserver._batchRequired = false;
+
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, true);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js b/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js
new file mode 100644
index 0000000000..db5db6e99f
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_icsCalendar_uncached.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { ICSServer } = ChromeUtils.import("resource://testing-common/calendar/ICSServer.jsm");
+
+ICSServer.open();
+ICSServer.putICSInternal(
+ CalendarTestUtils.dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ UID:5a9fa76c-93f3-4ad8-9f00-9e52aedd2821
+ SUMMARY:exists before time
+ DTSTART:20210401T120000Z
+ DTEND:20210401T130000Z
+ END:VEVENT
+ END:VCALENDAR
+ `
+);
+registerCleanupFunction(() => ICSServer.close());
+
+add_task(async function () {
+ calendarObserver._onAddItemPromise = PromiseUtils.defer();
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ let calendar = createCalendar("ics", ICSServer.url, false);
+ await calendarObserver._onAddItemPromise.promise;
+ await calendarObserver._onLoadPromise.promise;
+ info("calendar set-up complete");
+
+ Assert.ok(await calendar.getItem("5a9fa76c-93f3-4ad8-9f00-9e52aedd2821"));
+
+ info("creating the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runAddItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("modifying the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runModifyItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+
+ info("deleting the item");
+ calendarObserver._onLoadPromise = PromiseUtils.defer();
+ await runDeleteItem(calendar);
+ await calendarObserver._onLoadPromise.promise;
+});
diff --git a/comm/calendar/test/unit/providers/test_storageCalendar.js b/comm/calendar/test/unit/providers/test_storageCalendar.js
new file mode 100644
index 0000000000..778f9f9251
--- /dev/null
+++ b/comm/calendar/test/unit/providers/test_storageCalendar.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function () {
+ let calendar = createCalendar("storage", "moz-storage-calendar://");
+
+ info("creating the item");
+ calendarObserver._batchRequired = false;
+ await runAddItem(calendar);
+
+ info("modifying the item");
+ await runModifyItem(calendar);
+
+ info("deleting the item");
+ await runDeleteItem(calendar);
+});
diff --git a/comm/calendar/test/unit/providers/xpcshell.ini b/comm/calendar/test/unit/providers/xpcshell.ini
new file mode 100644
index 0000000000..272fef7c78
--- /dev/null
+++ b/comm/calendar/test/unit/providers/xpcshell.ini
@@ -0,0 +1,11 @@
+[default]
+head = head.js
+prefs =
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+
+[test_caldavCalendar_cached.js]
+[test_caldavCalendar_uncached.js]
+[test_icsCalendar_cached.js]
+[test_icsCalendar_uncached.js]
+[test_storageCalendar.js]
diff --git a/comm/calendar/test/unit/test_CalendarFileImporter.js b/comm/calendar/test/unit/test_CalendarFileImporter.js
new file mode 100644
index 0000000000..358a82942d
--- /dev/null
+++ b/comm/calendar/test/unit/test_CalendarFileImporter.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+var { CalendarFileImporter } = ChromeUtils.import("resource:///modules/CalendarFileImporter.jsm");
+
+/**
+ * Test CalendarFileImporter can import ics file correctly.
+ */
+async function test_importIcsFile() {
+ let importer = new CalendarFileImporter();
+
+ // Parse items from ics file should work.
+ let items = await importer.parseIcsFile(do_get_file("data/import.ics"));
+ equal(items.length, 4);
+
+ // Create a temporary calendar.
+ let calendar = CalendarTestUtils.createCalendar();
+ registerCleanupFunction(() => {
+ CalendarTestUtils.removeCalendar(calendar);
+ });
+
+ // Put items to the temporary calendar should work.
+ await importer.startImport(items, calendar);
+ let result = await calendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ cal.createDateTime("20190101T000000"),
+ cal.createDateTime("20190102T000000")
+ );
+ equal(result.length, 4);
+}
+
+function run_test() {
+ do_get_profile();
+
+ add_test(() => {
+ do_calendar_startup(async () => {
+ await test_importIcsFile();
+ run_next_test();
+ });
+ });
+}
diff --git a/comm/calendar/test/unit/test_alarm.js b/comm/calendar/test/unit/test_alarm.js
new file mode 100644
index 0000000000..14b2ce76bd
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarm.js
@@ -0,0 +1,674 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_tests() {
+ test_initial_creation();
+
+ test_display_alarm();
+ test_email_alarm();
+ test_audio_alarm();
+ test_custom_alarm();
+ test_repeat();
+ test_xprop();
+
+ test_dates();
+
+ test_clone();
+ test_immutable();
+ test_serialize();
+ test_strings();
+}
+
+function run_test() {
+ do_calendar_startup(run_tests);
+}
+
+function test_initial_creation() {
+ dump("Testing initial creation...");
+ let alarm = new CalAlarm();
+
+ let passed;
+ try {
+ // eslint-disable-next-line no-unused-expressions
+ alarm.icalString;
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Fresh calIAlarm should not produce a valid icalString");
+ }
+ dump("Done\n");
+}
+
+function test_display_alarm() {
+ dump("Testing DISPLAY alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to DISPLAY, make sure this was not rejected
+ alarm.action = "DISPLAY";
+ equal(alarm.action, "DISPLAY");
+
+ // Set a Description, REQUIRED for ACTION:DISPLAY
+ alarm.description = "test";
+ equal(alarm.description, "test");
+
+ // SUMMARY is not valid for ACTION:DISPLAY
+ alarm.summary = "test";
+ equal(alarm.summary, null);
+
+ // No attendees allowed
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:horst";
+
+ throws(() => {
+ // DISPLAY alarm should not be able to save attendees
+ alarm.addAttendee(attendee);
+ }, /Alarm type AUDIO\/DISPLAY may not have attendees/);
+
+ throws(() => {
+ // DISPLAY alarm should not be able to save attachment
+ alarm.addAttachment(new CalAttachment());
+ }, /Alarm type DISPLAY may not have attachments/);
+
+ dump("Done\n");
+}
+
+function test_email_alarm() {
+ dump("Testing EMAIL alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to DISPLAY, make sure this was not rejected
+ alarm.action = "EMAIL";
+ equal(alarm.action, "EMAIL");
+
+ // Set a Description, REQUIRED for ACTION:EMAIL
+ alarm.description = "description";
+ equal(alarm.description, "description");
+
+ // Set a Summary, REQUIRED for ACTION:EMAIL
+ alarm.summary = "summary";
+ equal(alarm.summary, "summary");
+
+ // Set an offset of some sort
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration();
+
+ // Check for at least one attendee
+ let attendee1 = new CalAttendee();
+ attendee1.id = "mailto:horst";
+ let attendee2 = new CalAttendee();
+ attendee2.id = "mailto:gustav";
+
+ equal(alarm.getAttendees().length, 0);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+ alarm.addAttendee(attendee2);
+ equal(alarm.getAttendees().length, 2);
+ alarm.addAttendee(attendee1);
+ let addedAttendees = alarm.getAttendees();
+ equal(addedAttendees.length, 2);
+ equal(addedAttendees[0].wrappedJSObject, attendee2);
+ equal(addedAttendees[1].wrappedJSObject, attendee1);
+
+ ok(!!alarm.icalComponent.serializeToICS().match(/mailto:horst/));
+ ok(!!alarm.icalComponent.serializeToICS().match(/mailto:gustav/));
+
+ alarm.deleteAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+
+ alarm.clearAttendees();
+ equal(alarm.getAttendees().length, 0);
+
+ // Make sure attendees are correctly folded/imported
+ alarm.icalString = dedent`
+ BEGIN:VALARM
+ ACTION:EMAIL
+ TRIGGER;VALUE=DURATION:-PT5M
+ ATTENDEE:mailto:test@example.com
+ ATTENDEE:mailto:test@example.com
+ ATTENDEE:mailto:test2@example.com
+ END:VALARM
+ `;
+ equal(alarm.icalString.match(/ATTENDEE:mailto:test@example.com/g).length, 1);
+ equal(alarm.icalString.match(/ATTENDEE:mailto:test2@example.com/g).length, 1);
+
+ // TODO test attachments
+ dump("Done\n");
+}
+
+function test_audio_alarm() {
+ dump("Testing AUDIO alarms...");
+ let alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ // Set ACTION to AUDIO, make sure this was not rejected
+ alarm.action = "AUDIO";
+ equal(alarm.action, "AUDIO");
+
+ // No Description for ACTION:AUDIO
+ alarm.description = "description";
+ equal(alarm.description, null);
+
+ // No Summary, for ACTION:AUDIO
+ alarm.summary = "summary";
+ equal(alarm.summary, null);
+
+ // No attendees allowed
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:horst";
+
+ try {
+ alarm.addAttendee(attendee);
+ do_throw("AUDIO alarm should not be able to save attendees");
+ } catch (e) {
+ // TODO looks like this test is disabled. Why?
+ }
+
+ // Test attachments
+ let sound = new CalAttachment();
+ sound.uri = Services.io.newURI("file:///sound.wav");
+ let sound2 = new CalAttachment();
+ sound2.uri = Services.io.newURI("file:///sound2.wav");
+
+ // Adding an attachment should work
+ alarm.addAttachment(sound);
+ let addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+ equal(addedAttachments[0].wrappedJSObject, sound);
+ ok(alarm.icalString.includes("ATTACH:file:///sound.wav"));
+
+ // Adding twice shouldn't change anything
+ alarm.addAttachment(sound);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+ equal(addedAttachments[0].wrappedJSObject, sound);
+
+ try {
+ alarm.addAttachment(sound2);
+ do_throw("Adding a second attachment should fail for type AUDIO");
+ } catch (e) {
+ // TODO looks like this test is disabled. Why?
+ }
+
+ // Deleting should work
+ alarm.deleteAttachment(sound);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+
+ // As well as clearing
+ alarm.addAttachment(sound);
+ alarm.clearAttachments();
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+
+ // AUDIO alarms should only be allowing one attachment, and folding any with the same value
+ alarm.icalString = dedent`
+ BEGIN:VALARM
+ ACTION:AUDIO
+ TRIGGER;VALUE=DURATION:-PT5M
+ ATTACH:Basso
+ UID:28F8007B-FE56-442E-917C-1F4E48DD406A
+ X-APPLE-DEFAULT-ALARM:TRUE
+ ATTACH:Basso
+ END:VALARM
+ `;
+ equal(alarm.icalString.match(/ATTACH:Basso/g).length, 1);
+
+ dump("Done\n");
+}
+
+function test_custom_alarm() {
+ dump("Testing X-SMS (custom) alarms...");
+ let alarm = new CalAlarm();
+ // Set ACTION to a custom value, make sure this was not rejected
+ alarm.action = "X-SMS";
+ equal(alarm.action, "X-SMS");
+
+ // There is no restriction on DESCRIPTION for custom alarms
+ alarm.description = "description";
+ equal(alarm.description, "description");
+
+ // There is no restriction on SUMMARY for custom alarms
+ alarm.summary = "summary";
+ equal(alarm.summary, "summary");
+
+ // Test for attendees
+ let attendee1 = new CalAttendee();
+ attendee1.id = "mailto:horst";
+ let attendee2 = new CalAttendee();
+ attendee2.id = "mailto:gustav";
+
+ equal(alarm.getAttendees().length, 0);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+ alarm.addAttendee(attendee2);
+ equal(alarm.getAttendees().length, 2);
+ alarm.addAttendee(attendee1);
+ equal(alarm.getAttendees().length, 2);
+
+ alarm.deleteAttendee(attendee1);
+ equal(alarm.getAttendees().length, 1);
+
+ alarm.clearAttendees();
+ equal(alarm.getAttendees().length, 0);
+
+ // Test for attachments
+ let attach1 = new CalAttachment();
+ attach1.uri = Services.io.newURI("file:///example.txt");
+ let attach2 = new CalAttachment();
+ attach2.uri = Services.io.newURI("file:///example2.txt");
+
+ alarm.addAttachment(attach1);
+ alarm.addAttachment(attach2);
+
+ let addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 2);
+ equal(addedAttachments[0].wrappedJSObject, attach1);
+ equal(addedAttachments[1].wrappedJSObject, attach2);
+
+ alarm.deleteAttachment(attach1);
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 1);
+
+ alarm.clearAttachments();
+ addedAttachments = alarm.getAttachments();
+ equal(addedAttachments.length, 0);
+}
+
+// Check if any combination of REPEAT and DURATION work as expected.
+function test_repeat() {
+ dump("Testing REPEAT and DURATION properties...");
+ let alarm = new CalAlarm();
+
+ // Check initial value
+ equal(alarm.repeat, 0);
+ equal(alarm.repeatOffset, null);
+ equal(alarm.repeatDate, null);
+
+ // Should not be able to get REPEAT when DURATION is not set
+ alarm.repeat = 1;
+ equal(alarm.repeat, 0);
+
+ // Both REPEAT and DURATION should be accessible, when the two are set.
+ alarm.repeatOffset = cal.createDuration();
+ notEqual(alarm.repeatOffset, null);
+ notEqual(alarm.repeat, 0);
+
+ // Should not be able to get DURATION when REPEAT is not set
+ alarm.repeat = null;
+ equal(alarm.repeatOffset, null);
+
+ // Should be able to unset alarm DURATION attribute. (REPEAT already tested above)
+ try {
+ alarm.repeatOffset = null;
+ } catch (e) {
+ do_throw("Could not set repeatOffset attribute to null" + e);
+ }
+
+ // Check unset value
+ equal(alarm.repeat, 0);
+ equal(alarm.repeatOffset, null);
+
+ // Check repeatDate
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ alarm.repeat = 1;
+ alarm.repeatOffset = cal.createDuration();
+ alarm.repeatOffset.inSeconds = 3600;
+
+ let date = alarm.alarmDate.clone();
+ date.second += 3600;
+ equal(alarm.repeatDate.icalString, date.icalString);
+
+ dump("Done\n");
+}
+
+function test_xprop() {
+ dump("Testing X-Props...");
+ let alarm = new CalAlarm();
+ alarm.setProperty("X-PROP", "X-VALUE");
+ ok(alarm.hasProperty("X-PROP"));
+ equal(alarm.getProperty("X-PROP"), "X-VALUE");
+ alarm.deleteProperty("X-PROP");
+ ok(!alarm.hasProperty("X-PROP"));
+ equal(alarm.getProperty("X-PROP"), null);
+
+ // also check X-MOZ-LASTACK prop
+ let date = cal.createDateTime();
+ alarm.setProperty("X-MOZ-LASTACK", date.icalString);
+ alarm.action = "DISPLAY";
+ alarm.description = "test";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT5M");
+ ok(alarm.icalComponent.serializeToICS().includes(date.icalString));
+
+ alarm.deleteProperty("X-MOZ-LASTACK");
+ ok(!alarm.icalComponent.serializeToICS().includes(date.icalString));
+ dump("Done\n");
+}
+
+function test_dates() {
+ dump("Testing alarm dates...");
+ let passed;
+ // Initial value
+ let alarm = new CalAlarm();
+ equal(alarm.alarmDate, null);
+ equal(alarm.offset, null);
+
+ // Set an offset and check it
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ let offset = cal.createDuration("-PT5M");
+ alarm.offset = offset;
+ equal(alarm.alarmDate, null);
+ equal(alarm.offset, offset);
+ try {
+ alarm.alarmDate = cal.createDateTime();
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Setting alarmDate when alarm is relative should not succeed");
+ }
+
+ // Set an absolute time and check it
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ let alarmDate = createDate(2007, 0, 1, true, 2, 0, 0);
+ alarm.alarmDate = alarmDate;
+ equal(alarm.alarmDate.icalString, alarmDate.icalString);
+ equal(alarm.offset, null);
+ try {
+ alarm.offset = cal.createDuration();
+ passed = false;
+ } catch (e) {
+ passed = true;
+ }
+ if (!passed) {
+ do_throw("Setting offset when alarm is absolute should not succeed");
+ }
+
+ dump("Done\n");
+}
+
+var propMap = {
+ related: Ci.calIAlarm.ALARM_RELATED_START,
+ repeat: 1,
+ action: "X-TEST",
+ description: "description",
+ summary: "summary",
+ offset: cal.createDuration("PT4M"),
+ repeatOffset: cal.createDuration("PT1M"),
+};
+var clonePropMap = {
+ related: Ci.calIAlarm.ALARM_RELATED_END,
+ repeat: 2,
+ action: "X-CHANGED",
+ description: "description-changed",
+ summary: "summary-changed",
+ offset: cal.createDuration("PT5M"),
+ repeatOffset: cal.createDuration("PT2M"),
+};
+
+function test_immutable() {
+ dump("Testing immutable alarms...");
+ let alarm = new CalAlarm();
+ // Set up each attribute
+ for (let prop in propMap) {
+ alarm[prop] = propMap[prop];
+ }
+
+ // Set up some extra props
+ alarm.setProperty("X-FOO", "X-VAL");
+ alarm.setProperty("X-DATEPROP", cal.createDateTime());
+ alarm.addAttendee(new CalAttendee());
+
+ // Initial checks
+ ok(alarm.isMutable);
+ alarm.makeImmutable();
+ ok(!alarm.isMutable);
+ alarm.makeImmutable();
+ ok(!alarm.isMutable);
+
+ // Check each attribute
+ for (let prop in propMap) {
+ try {
+ alarm[prop] = propMap[prop];
+ } catch (e) {
+ equal(e.result, Cr.NS_ERROR_OBJECT_IS_IMMUTABLE);
+ continue;
+ }
+ do_throw("Attribute " + prop + " was writable while item was immutable");
+ }
+
+ // Functions
+ throws(() => {
+ alarm.setProperty("X-FOO", "changed");
+ }, /Can not modify immutable data container/);
+
+ throws(() => {
+ alarm.deleteProperty("X-FOO");
+ }, /Can not modify immutable data container/);
+
+ ok(!alarm.getProperty("X-DATEPROP").isMutable);
+
+ dump("Done\n");
+}
+
+function test_clone() {
+ dump("Testing cloning alarms...");
+ let alarm = new CalAlarm();
+ // Set up each attribute
+ for (let prop in propMap) {
+ alarm[prop] = propMap[prop];
+ }
+
+ // Set up some extra props
+ alarm.setProperty("X-FOO", "X-VAL");
+ alarm.setProperty("X-DATEPROP", cal.createDateTime());
+ alarm.addAttendee(new CalAttendee());
+
+ // Make a copy
+ let newAlarm = alarm.clone();
+ newAlarm.makeImmutable();
+ newAlarm = newAlarm.clone();
+ ok(newAlarm.isMutable);
+
+ // Check if item is still the same
+ // TODO This is not quite optimal, maybe someone can find a better way to do
+ // the comparisons.
+ for (let prop in propMap) {
+ if (prop == "item") {
+ equal(alarm.item.icalString, newAlarm.item.icalString);
+ } else {
+ try {
+ alarm[prop].QueryInterface(Ci.nsISupports);
+ equal(alarm[prop].icalString, newAlarm[prop].icalString);
+ } catch {
+ equal(alarm[prop], newAlarm[prop]);
+ }
+ }
+ }
+
+ // Check if changes on the cloned object do not affect the original object.
+ for (let prop in clonePropMap) {
+ newAlarm[prop] = clonePropMap[prop];
+ dump("Checking " + prop + "...");
+ notEqual(alarm[prop], newAlarm[prop]);
+ dump("OK!\n");
+ break;
+ }
+
+ // Check x props
+ alarm.setProperty("X-FOO", "BAR");
+ equal(alarm.getProperty("X-FOO"), "BAR");
+ let date = alarm.getProperty("X-DATEPROP");
+ equal(date.isMutable, true);
+
+ // Test xprop params
+ alarm.icalString =
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER:-PT15M\n" +
+ "X-FOO;X-PARAM=PARAMVAL:BAR\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM";
+
+ newAlarm = alarm.clone();
+ equal(alarm.icalString, newAlarm.icalString);
+
+ dump("Done\n");
+}
+
+function test_serialize() {
+ // most checks done by other tests, these don't fit into categories
+ let alarm = new CalAlarm();
+
+ throws(
+ () => {
+ alarm.icalComponent = cal.icsService.createIcalComponent("BARF");
+ },
+ /0x80070057/,
+ "Invalid Argument"
+ );
+
+ function addProp(name, value) {
+ let prop = cal.icsService.createIcalProperty(name);
+ prop.value = value;
+ comp.addProperty(prop);
+ }
+ function addActionDisplay() {
+ addProp("ACTION", "DISPLAY");
+ }
+ function addActionEmail() {
+ addProp("ACTION", "EMAIL");
+ }
+ function addTrigger() {
+ addProp("TRIGGER", "-PT15M");
+ }
+ function addDescr() {
+ addProp("DESCRIPTION", "TEST");
+ }
+ function addDuration() {
+ addProp("DURATION", "-PT15M");
+ }
+ function addRepeat() {
+ addProp("REPEAT", "1");
+ }
+ function addAttendee() {
+ addProp("ATTENDEE", "mailto:horst");
+ }
+ function addAttachment() {
+ addProp("ATTACH", "data:yeah");
+ }
+
+ // All is there, should not throw
+ let comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addDuration();
+ addRepeat();
+ alarm.icalComponent = comp;
+ alarm.toString();
+
+ // Attachments and attendees
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionEmail();
+ addTrigger();
+ addDescr();
+ addAttendee();
+ addAttachment();
+ alarm.icalComponent = comp;
+ alarm.toString();
+
+ // Missing action
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addTrigger();
+ addDescr();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing trigger
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addDescr();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing duration with repeat
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addRepeat();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+
+ // Missing repeat with duration
+ throws(
+ () => {
+ comp = cal.icsService.createIcalComponent("VALARM");
+ addActionDisplay();
+ addTrigger();
+ addDescr();
+ addDuration();
+ alarm.icalComponent = comp;
+ },
+ /Illegal value/,
+ "Invalid Argument"
+ );
+}
+
+function test_strings() {
+ // Serializing the string shouldn't throw, but we don't really care about
+ // the string itself.
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+ alarm.toString();
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration();
+ alarm.toString();
+ alarm.toString(new CalTodo());
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration();
+ alarm.toString();
+ alarm.toString(new CalTodo());
+
+ alarm.offset = cal.createDuration("P1D");
+ alarm.toString();
+
+ alarm.offset = cal.createDuration("PT1H");
+ alarm.toString();
+
+ alarm.offset = cal.createDuration("-PT1H");
+ alarm.toString();
+}
diff --git a/comm/calendar/test/unit/test_alarmservice.js b/comm/calendar/test/unit/test_alarmservice.js
new file mode 100644
index 0000000000..df61dc029b
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarmservice.js
@@ -0,0 +1,606 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+var EXPECT_NONE = 0;
+var EXPECT_FIRED = 1;
+var EXPECT_TIMER = 2;
+
+function do_check_xor(a, b, aMessage) {
+ return ok((a && !b) || (!a && b), aMessage);
+}
+
+var alarmObserver = {
+ QueryInterface: ChromeUtils.generateQI(["calIAlarmServiceObserver"]),
+
+ service: null,
+ firedMap: {},
+ expectedMap: {},
+ pendingOps: {},
+
+ onAlarm(aItem, aAlarm) {
+ this.firedMap[aItem.hashId] = this.firedMap[aItem.hashId] || {};
+ this.firedMap[aItem.hashId][aAlarm.icalString] = true;
+ },
+
+ onNotification(item) {},
+
+ onRemoveAlarmsByItem(aItem) {
+ if (aItem.hashId in this.firedMap) {
+ delete this.firedMap[aItem.hashId];
+ }
+ },
+
+ onRemoveAlarmsByCalendar() {},
+
+ onAlarmsLoaded(aCalendar) {
+ this.checkLoadStatus();
+ if (aCalendar.id in this.pendingOps) {
+ this.pendingOps[aCalendar.id].call();
+ }
+ },
+
+ async doOnAlarmsLoaded(aCalendar) {
+ this.checkLoadStatus();
+ if (
+ aCalendar.id in this.service.mLoadedCalendars &&
+ this.service.mLoadedCalendars[aCalendar.id]
+ ) {
+ // the calendar's alarms have already been loaded
+ } else {
+ await new Promise(resolve => {
+ // the calendar hasn't been fully loaded yet, set as a pending operation
+ this.pendingOps[aCalendar.id] = resolve;
+ });
+ }
+ },
+
+ getTimer(aCalendarId, aItemId, aAlarmStr) {
+ return aCalendarId in this.service.mTimerMap &&
+ aItemId in this.service.mTimerMap[aCalendarId] &&
+ aAlarmStr in this.service.mTimerMap[aCalendarId][aItemId]
+ ? this.service.mTimerMap[aCalendarId][aItemId][aAlarmStr]
+ : null;
+ },
+
+ expectResult(aCalendar, aItem, aAlarm, aExpected) {
+ let expectedAndTitle = {
+ expected: aExpected,
+ title: aItem.title,
+ };
+ this.expectedMap[aCalendar.id] = this.expectedMap[aCalendar.id] || {};
+ this.expectedMap[aCalendar.id][aItem.hashId] =
+ this.expectedMap[aCalendar.id][aItem.hashId] || {};
+ this.expectedMap[aCalendar.id][aItem.hashId][aAlarm.icalString] = expectedAndTitle;
+ },
+
+ expectOccurrences(aCalendar, aItem, aAlarm, aExpectedArray) {
+ // we need to be earlier than the first occurrence
+ let date = aItem.startDate.clone();
+ date.second -= 1;
+
+ for (let expected of aExpectedArray) {
+ let occ = aItem.recurrenceInfo.getNextOccurrence(date);
+ occ.QueryInterface(Ci.calIEvent);
+ date = occ.startDate;
+ this.expectResult(aCalendar, occ, aAlarm, expected);
+ }
+ },
+
+ checkExpected(aMessage) {
+ for (let calId in this.expectedMap) {
+ for (let id in this.expectedMap[calId]) {
+ for (let icalString in this.expectedMap[calId][id]) {
+ let expectedAndTitle = this.expectedMap[calId][id][icalString];
+ // if no explicit message has been passed, take the item title
+ let message = typeof aMessage == "string" ? aMessage : expectedAndTitle.title;
+ // only alarms expected as fired should exist in our fired alarm map
+ do_check_xor(
+ expectedAndTitle.expected != EXPECT_FIRED,
+ id in this.firedMap && icalString in this.firedMap[id],
+ message + "; check fired"
+ );
+ // only alarms expected as timers should exist in the service's timer map
+ do_check_xor(
+ expectedAndTitle.expected != EXPECT_TIMER,
+ !!this.getTimer(calId, id, icalString),
+ message + "; check timer"
+ );
+ }
+ }
+ }
+ },
+
+ checkLoadStatus() {
+ for (let calId in this.service.mLoadedCalendars) {
+ if (!this.service.mLoadedCalendars[calId]) {
+ // at least one calendar hasn't finished loading alarms
+ ok(this.service.isLoading);
+ return;
+ }
+ }
+ ok(!this.service.isLoading);
+ },
+
+ clear() {
+ this.firedMap = {};
+ this.pendingOps = {};
+ this.expectedMap = {};
+ },
+};
+
+add_setup(async function () {
+ do_get_profile();
+ await new Promise(resolve =>
+ do_calendar_startup(() => {
+ alarmObserver.service = Cc["@mozilla.org/calendar/alarm-service;1"].getService(
+ Ci.calIAlarmService
+ ).wrappedJSObject;
+ ok(!alarmObserver.service.mStarted);
+ alarmObserver.service.startup(null);
+ ok(alarmObserver.service.mStarted);
+
+ // we need to replace the existing observers with our observer
+ for (let obs of alarmObserver.service.mObservers.values()) {
+ alarmObserver.service.removeObserver(obs);
+ }
+ alarmObserver.service.addObserver(alarmObserver);
+ resolve();
+ })
+ );
+});
+
+function createAlarmFromDuration(aOffset) {
+ let alarm = new CalAlarm();
+
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration(aOffset);
+
+ return alarm;
+}
+
+function createEventWithAlarm(aCalendar, aStart, aEnd, aOffset, aRRule) {
+ let alarm = null;
+ let item = new CalEvent();
+
+ item.id = cal.getUUID();
+ item.calendar = aCalendar;
+ item.startDate = aStart || cal.dtz.now();
+ item.endDate = aEnd || cal.dtz.now();
+ if (aOffset) {
+ alarm = createAlarmFromDuration(aOffset);
+ item.addAlarm(alarm);
+ }
+ if (aRRule) {
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule(aRRule));
+ }
+ return [item, alarm];
+}
+
+async function addTestItems(aCalendar) {
+ let item, alarm;
+
+ // alarm on an item starting more than a month in the past should not fire
+ let date = cal.dtz.now();
+ date.day -= 32;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "P7D");
+ item.title = "addTestItems Test 1";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ await aCalendar.addItem(item);
+
+ // alarm 15 minutes ago should fire
+ date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M");
+ item.title = "addTestItems Test 2";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ await aCalendar.addItem(item);
+
+ // alarm within 6 hours should have a timer set
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT1H");
+ item.title = "addTestItems Test 3";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ await aCalendar.addItem(item);
+
+ // alarm more than 6 hours in the future should not have a timer set
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT7H");
+ item.title = "addTestItems Test 4";
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ await aCalendar.addItem(item);
+
+ // test multiple alarms on an item
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date);
+ item.title = "addTestItems Test 5";
+ const firedOffsets = [
+ ["-PT1H", EXPECT_FIRED],
+ ["-PT15M", EXPECT_FIRED],
+ ["PT1H", EXPECT_TIMER],
+ ["PT7H", EXPECT_NONE],
+ ["P7D", EXPECT_NONE],
+ ];
+
+ firedOffsets.forEach(([offset, expected]) => {
+ alarm = createAlarmFromDuration(offset);
+ item.addAlarm(alarm);
+ alarmObserver.expectResult(aCalendar, item, alarm, expected);
+ });
+ await aCalendar.addItem(item);
+
+ // Bug 1344068 - Alarm with lastAck on exception, should take parent lastAck.
+ // Alarm 15 minutes ago should fire.
+ date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY;COUNT=1");
+ item.title = "addTestItems Test 6";
+
+ // Parent item is acknowledged before alarm, so it should fire.
+ let lastAck = item.startDate.clone();
+ lastAck.hour -= 1;
+ item.alarmLastAck = lastAck;
+
+ // Occurrence is acknowledged after alarm (start date), so if the alarm
+ // service wrongly uses the exception occurrence then we catch it.
+ let occ = item.recurrenceInfo.getOccurrenceFor(item.startDate);
+ occ.alarmLastAck = item.startDate.clone();
+ item.recurrenceInfo.modifyException(occ, true);
+
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, [EXPECT_FIRED]);
+ await aCalendar.addItem(item);
+
+ // daily repeating event starting almost 2 full days ago. The alarms on the first 2 occurrences
+ // should fire, and a timer should be set for the next occurrence only
+ date = cal.dtz.now();
+ date.hour -= 47;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=DAILY");
+ item.title = "addTestItems Test 7";
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, [
+ EXPECT_FIRED,
+ EXPECT_FIRED,
+ EXPECT_TIMER,
+ EXPECT_NONE,
+ EXPECT_NONE,
+ ]);
+ await aCalendar.addItem(item);
+
+ // monthly repeating event starting 2 months and a day ago. The alarms on the first 2 occurrences
+ // should be ignored, the alarm on the next occurrence only should fire.
+ // Missing recurrences of the event in particular days of the year generate exceptions to the
+ // regular sequence of alarms.
+ date = cal.dtz.now();
+ let statusAlarmSequences = {
+ reg: [EXPECT_NONE, EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE],
+ excep1: [EXPECT_NONE, EXPECT_FIRED, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE],
+ excep2: [EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE, EXPECT_NONE],
+ };
+ let expected = [];
+ if (date.day == 1) {
+ // Exceptions for missing occurrences on months with 30 days when the event starts on 31st.
+ let sequence = [
+ "excep1",
+ "reg",
+ "excep2",
+ "excep1",
+ "reg",
+ "excep1",
+ "reg",
+ "excep1",
+ "reg",
+ "excep2",
+ "excep1",
+ "reg",
+ ][date.month];
+ expected = statusAlarmSequences[sequence];
+ } else if (date.day == 30 && (date.month == 2 || date.month == 3)) {
+ // Exceptions for missing occurrences or different start date caused by February.
+ let leapYear = date.endOfYear.yearday == 366;
+ expected = leapYear ? statusAlarmSequences.reg : statusAlarmSequences.excep1;
+ } else if (date.day == 31 && date.month == 2) {
+ // Exceptions for missing occurrences caused by February.
+ expected = statusAlarmSequences.excep1;
+ } else {
+ // Regular sequence of alarms expected for all the others days.
+ expected = statusAlarmSequences.reg;
+ }
+ date.month -= 2;
+ date.day -= 1;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT15M", "RRULE:FREQ=MONTHLY");
+ item.title = "addTestItems Test 8";
+ alarmObserver.expectOccurrences(aCalendar, item, alarm, expected);
+ await aCalendar.addItem(item);
+}
+
+async function doModifyItemTest(aCalendar) {
+ let item, alarm;
+
+ // begin with item starting before the alarm date range
+ let date = cal.dtz.now();
+ date.day -= 32;
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "PT0S");
+ await aCalendar.addItem(item);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.checkExpected("doModifyItemTest Test 1");
+
+ // move event into the fired range
+ let oldItem = item.clone();
+ date.day += 31;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.checkExpected("doModifyItemTest Test 2");
+
+ // move event into the timer range
+ oldItem = item.clone();
+ date.hour += 25;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 3");
+
+ // move event past the timer range
+ oldItem = item.clone();
+ date.hour += 6;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.checkExpected("doModifyItemTest Test 4");
+
+ // re-move the event in the timer range and verify that the timer
+ // doesn't change when the timezone changes to floating (bug 1300493).
+ oldItem = item.clone();
+ date.hour -= 6;
+ item.startDate = date.clone();
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 5");
+ let oldTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ oldItem = item.clone();
+ // change the timezone to floating
+ item.startDate.timezone = cal.dtz.floating;
+ item.generation++;
+ await aCalendar.modifyItem(item, oldItem);
+ // the alarm must still be timer and with the same value (apart from milliseconds)
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doModifyItemTest Test 5, floating timezone");
+ let newTimer = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ ok(
+ newTimer.delay - oldTimer.delay <= 1000,
+ "doModifyItemTest Test 5, floating timezone; check timer value"
+ );
+}
+
+async function doDeleteItemTest(aCalendar) {
+ alarmObserver.clear();
+ let item, alarm;
+ let item2, alarm2;
+
+ // create a fired alarm and a timer
+ let date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "PT1H");
+ item.title = "doDeleteItemTest item Test 1";
+ item2.title = "doDeleteItemTest item2 Test 1";
+ await aCalendar.addItem(item);
+ await aCalendar.addItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_TIMER);
+ alarmObserver.checkExpected();
+
+ // item deletion should clear the fired alarm and timer
+ await aCalendar.deleteItem(item);
+ await aCalendar.deleteItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_NONE);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE);
+ alarmObserver.checkExpected("doDeleteItemTest, cleared fired alarm and timer");
+}
+
+async function doAcknowledgeTest(aCalendar) {
+ alarmObserver.clear();
+ let item, alarm;
+ let item2, alarm2;
+
+ // create the fired alarms
+ let date = cal.dtz.now();
+ [item, alarm] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ [item2, alarm2] = createEventWithAlarm(aCalendar, date, date, "-PT5M");
+ item.title = "doAcknowledgeTest item Test 1";
+ item2.title = "doAcknowledgeTest item2 Test 1";
+ await aCalendar.addItem(item);
+ await aCalendar.addItem(item2);
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_FIRED);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_FIRED);
+ alarmObserver.checkExpected();
+
+ // test snooze alarm
+ alarmObserver.service.snoozeAlarm(item, alarm, cal.createDuration("PT1H"));
+ alarmObserver.expectResult(aCalendar, item, alarm, EXPECT_TIMER);
+ alarmObserver.checkExpected("doAcknowledgeTest, test snooze alarm");
+
+ // the snoozed alarm timer delay should be close to an hour
+ let tmr = alarmObserver.getTimer(aCalendar.id, item.hashId, alarm.icalString);
+ ok(
+ Math.abs(tmr.delay - 3600000) <= 1000,
+ "doAcknowledgeTest, snoozed alarm timer delay close to an hour"
+ );
+
+ // test dismiss alarm
+ alarmObserver.service.dismissAlarm(item2, alarm2);
+ alarmObserver.expectResult(aCalendar, item2, alarm2, EXPECT_NONE);
+ alarmObserver.checkExpected("doAcknowledgeTest, test dismiss alarm");
+}
+
+async function doRunTest(aOnCalendarCreated) {
+ alarmObserver.clear();
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = cal.getUUID();
+
+ if (aOnCalendarCreated) {
+ await aOnCalendarCreated(memory);
+ }
+
+ cal.manager.registerCalendar(memory);
+ await alarmObserver.doOnAlarmsLoaded(memory);
+ return memory;
+}
+
+/**
+ * Test the initial alarm loading of a calendar with existing data.
+ */
+add_task(async function test_loadCalendar() {
+ await doRunTest(async memory => addTestItems(memory));
+ alarmObserver.checkExpected();
+});
+
+/**
+ * Test adding alarm data to a calendar already registered.
+ */
+add_task(async function test_addItems() {
+ let memory = await doRunTest();
+ await addTestItems(memory);
+ alarmObserver.checkExpected();
+});
+
+/**
+ * Test response to modification of alarm data.
+ */
+add_task(async function test_modifyItems() {
+ let memory = await doRunTest();
+ await doModifyItemTest(memory);
+ await doDeleteItemTest(memory);
+ await doAcknowledgeTest(memory);
+});
+
+/**
+ * Test an array of timers has expected delay values.
+ *
+ * @param {nsITimer[]} timers - An array of nsITimer.
+ * @param {number[]} expected - Expected delays in seconds.
+ */
+function matchTimers(timers, expected) {
+ let delays = timers.map(timer => timer.delay / 1000);
+ let matched = true;
+ for (let i = 0; i < delays.length; i++) {
+ if (Math.abs(delays[i] - expected[i]) > 2) {
+ matched = false;
+
+ break;
+ }
+ }
+ ok(matched, `Delays=${delays} should match Expected=${expected}`);
+}
+
+/**
+ * Test notification timers are set up correctly when add/modify/remove a
+ * calendar item.
+ */
+add_task(async function test_notificationTimers() {
+ let memory = await doRunTest();
+ // Add an item.
+ let date = cal.dtz.now();
+ date.hour += 1;
+ let item, oldItem;
+ [item] = createEventWithAlarm(memory, date, date, null);
+ await memory.addItem(item);
+ equal(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id],
+ undefined,
+ "should have no notification timer"
+ );
+
+ // Set the pref to have one notifiaction.
+ Services.prefs.setCharPref("calendar.notifications.times", "-PT1H");
+ oldItem = item.clone();
+ date.hour += 1;
+ item.startDate = date.clone();
+ item.generation++;
+ await memory.modifyItem(item, oldItem);
+ // Should have one notification timer
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]);
+
+ // Set the pref to have three notifiactions.
+ Services.prefs.setCharPref("calendar.notifications.times", "END:PT2M,PT0M,END:-PT30M,-PT5M");
+ oldItem = item.clone();
+ date.hour -= 1;
+ item.startDate = date.clone();
+ date.hour += 1;
+ item.endDate = date.clone();
+ item.generation++;
+ await memory.modifyItem(item, oldItem);
+ // Should have four notification timers.
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [
+ 3300, // 55 minutes
+ 3600, // 60 minutes
+ 5400, // 90 minutes, which is 30 minutes before the end (END:-PT30M)
+ 7320, // 122 minutes, which is 2 minutes after the end (END:PT2M)
+ ]);
+
+ alarmObserver.service.removeFiredNotificationTimer(item);
+ // Should have three notification timers.
+ matchTimers(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId],
+ [3600, 5400, 7320]
+ );
+
+ await memory.deleteItem(item);
+ equal(
+ alarmObserver.service.mNotificationTimerMap[item.calendar.id],
+ undefined,
+ "notification timers should be removed"
+ );
+
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
+
+/**
+ * Test notification timers are set up correctly according to the calendar level
+ * notifications.times config.
+ */
+add_task(async function test_calendarLevelNotificationTimers() {
+ let loaded = false;
+ let item;
+ let memory = await doRunTest();
+
+ if (!loaded) {
+ loaded = true;
+ // Set the global pref to have one notifiaction.
+ Services.prefs.setCharPref("calendar.notifications.times", "-PT1H");
+
+ // Add an item.
+ let date = cal.dtz.now();
+ date.hour += 2;
+ [item] = createEventWithAlarm(memory, date, date, null);
+ await memory.addItem(item);
+
+ // Should have one notification timer.
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [3600]);
+ // Set the calendar level pref to have two notification timers.
+ memory.setProperty("notifications.times", "-PT5M,PT0M");
+ }
+
+ await TestUtils.waitForCondition(
+ () => alarmObserver.service.mNotificationTimerMap[item.calendar.id]?.[item.hashId].length == 2
+ );
+ // Should have two notification timers
+ matchTimers(alarmObserver.service.mNotificationTimerMap[item.calendar.id][item.hashId], [
+ 6900, // 105 minutes
+ 7200, // 120 minutes
+ ]);
+
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("calendar.notifications.times");
+});
diff --git a/comm/calendar/test/unit/test_alarmutils.js b/comm/calendar/test/unit/test_alarmutils.js
new file mode 100644
index 0000000000..e51453367d
--- /dev/null
+++ b/comm/calendar/test/unit/test_alarmutils.js
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+add_task(async function test_setDefaultValues_events() {
+ let item, alarm;
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "yards");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 20);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-PT20M");
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 0);
+ item = new CalEvent();
+ cal.alarms.setDefaultValues(item);
+ equal(item.getAlarms().length, 0);
+
+ let mockCalendar = {
+ getProperty() {
+ return ["SHOUT"];
+ },
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onforevents", 1);
+ Services.prefs.setStringPref("calendar.alarms.eventalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.eventalarmlen", 60);
+ item = new CalEvent();
+ item.calendar = mockCalendar;
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "SHOUT");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.clearUserPref("calendar.alarms.onforevents");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.eventalarmlen");
+});
+
+add_task(async function test_setDefaultValues_tasks() {
+ let item, alarm;
+ let calnow = cal.dtz.now;
+ let nowDate = cal.createDateTime("20150815T120000");
+ cal.dtz.now = function () {
+ return nowDate;
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60);
+ item = new CalTodo();
+ equal(item.entryDate, null);
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-P2DT12H");
+ equal(item.entryDate.icalString, nowDate.icalString);
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "yards");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 20);
+ item = new CalTodo();
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "DISPLAY");
+ equal(alarm.offset.icalString, "-PT20M");
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 0);
+ item = new CalTodo();
+ cal.alarms.setDefaultValues(item);
+ equal(item.getAlarms().length, 0);
+
+ let mockCalendar = {
+ getProperty() {
+ return ["SHOUT"];
+ },
+ };
+
+ Services.prefs.setIntPref("calendar.alarms.onfortodos", 1);
+ Services.prefs.setStringPref("calendar.alarms.todoalarmunit", "hours");
+ Services.prefs.setIntPref("calendar.alarms.todoalarmlen", 60);
+ item = new CalTodo();
+ item.calendar = mockCalendar;
+ cal.alarms.setDefaultValues(item);
+ alarm = item.getAlarms()[0];
+ ok(alarm);
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_START);
+ equal(alarm.action, "SHOUT");
+ equal(alarm.offset.icalString, "-P2DT12H");
+
+ Services.prefs.clearUserPref("calendar.alarms.onfortodos");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmunit");
+ Services.prefs.clearUserPref("calendar.alarms.todoalarmlen");
+ cal.dtz.now = calnow;
+});
+
+add_task(async function test_calculateAlarmDate() {
+ let item = new CalEvent();
+ item.startDate = cal.createDateTime("20150815T120000");
+ item.endDate = cal.createDateTime("20150815T130000");
+
+ let calculateAlarmDate = cal.alarms.calculateAlarmDate.bind(cal.alarms, item);
+
+ let alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime("20150815T110000");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000");
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT1H");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z");
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT2H");
+ equal(calculateAlarmDate(alarm).icalString, "20150815T110000Z");
+
+ item.startDate.isDate = true;
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("-PT1H");
+ equal(calculateAlarmDate(alarm).icalString, "20150814T230000Z");
+ item.startDate.isDate = false;
+
+ item.endDate.isDate = true;
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT2H");
+ equal(calculateAlarmDate(alarm).icalString, "20150814T220000Z");
+ item.endDate.isDate = false;
+
+ alarm = new CalAlarm();
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ equal(calculateAlarmDate(alarm), null);
+});
diff --git a/comm/calendar/test/unit/test_attachment.js b/comm/calendar/test/unit/test_attachment.js
new file mode 100644
index 0000000000..6e2b935851
--- /dev/null
+++ b/comm/calendar/test/unit/test_attachment.js
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_serialize();
+ test_hashes();
+ test_uriattach();
+ test_binaryattach();
+}
+
+function test_hashes() {
+ let attach = new CalAttachment();
+
+ attach.rawData = "hello";
+ let hash1 = attach.hashId;
+
+ attach.rawData = "world";
+ notEqual(hash1, attach.hashId);
+
+ attach.rawData = "hello";
+ equal(hash1, attach.hashId);
+
+ // Setting raw data should give us a BINARY attachment
+ equal(attach.getParameter("VALUE"), "BINARY");
+
+ attach.uri = Services.io.newURI("http://hello");
+
+ // Setting an uri should delete the value parameter
+ equal(attach.getParameter("VALUE"), null);
+}
+
+function test_uriattach() {
+ let attach = new CalAttachment();
+
+ // Attempt to set a property and check its values
+ let e = new CalEvent();
+ // eslint-disable-next-line no-useless-concat
+ e.icalString = "BEGIN:VEVENT\r\n" + "ATTACH;FMTTYPE=x-moz/test:http://hello\r\n" + "END:VEVENT";
+ let prop = e.icalComponent.getFirstProperty("ATTACH");
+ attach.icalProperty = prop;
+
+ notEqual(attach.getParameter("VALUE"), "BINARY");
+ equal(attach.formatType, "x-moz/test");
+ equal(attach.getParameter("FMTTYPE"), "x-moz/test");
+ equal(attach.uri.spec, Services.io.newURI("http://hello").spec);
+ equal(attach.rawData, "http://hello");
+}
+
+function test_binaryattach() {
+ let attach = new CalAttachment();
+ let e = new CalEvent();
+
+ let attachString =
+ "ATTACH;ENCODING=BASE64;FMTTYPE=x-moz/test2;VALUE=BINARY:aHR0cDovL2hlbGxvMg==\r\n";
+ let icalString = "BEGIN:VEVENT\r\n" + attachString + "END:VEVENT";
+ e.icalString = icalString;
+ let prop = e.icalComponent.getFirstProperty("ATTACH");
+ attach.icalProperty = prop;
+
+ equal(attach.formatType, "x-moz/test2");
+ equal(attach.getParameter("FMTTYPE"), "x-moz/test2");
+ equal(attach.encoding, "BASE64");
+ equal(attach.getParameter("ENCODING"), "BASE64");
+ equal(attach.uri, null);
+ equal(attach.rawData, "aHR0cDovL2hlbGxvMg==");
+ equal(attach.getParameter("VALUE"), "BINARY");
+
+ let propIcalString = attach.icalProperty.icalString;
+ ok(!!propIcalString.match(/ENCODING=BASE64/));
+ ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/));
+ ok(!!propIcalString.match(/VALUE=BINARY/));
+ ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/));
+
+ propIcalString = attach.clone().icalProperty.icalString;
+
+ ok(!!propIcalString.match(/ENCODING=BASE64/));
+ ok(!!propIcalString.match(/FMTTYPE=x-moz\/test2/));
+ ok(!!propIcalString.match(/VALUE=BINARY/));
+ ok(!!propIcalString.replace("\r\n ", "").match(/:aHR0cDovL2hlbGxvMg==/));
+}
+
+function test_serialize() {
+ let attach = new CalAttachment();
+ attach.formatType = "x-moz/test2";
+ attach.uri = Services.io.newURI("data:text/plain,");
+ equal(attach.icalString, "ATTACH;FMTTYPE=x-moz/test2:data:text/plain,\r\n");
+
+ attach = new CalAttachment();
+ attach.encoding = "BASE64";
+ attach.uri = Services.io.newURI("data:text/plain,");
+ equal(attach.icalString, "ATTACH;ENCODING=BASE64:data:text/plain,\r\n");
+
+ throws(() => {
+ attach.icalString = "X-STICKER:smiley";
+ }, /Illegal value/);
+
+ attach = new CalAttachment();
+ attach.uri = Services.io.newURI("data:text/plain,");
+ attach.setParameter("X-PROP", "VAL");
+ equal(attach.icalString, "ATTACH;X-PROP=VAL:data:text/plain,\r\n");
+ attach.setParameter("X-PROP", null);
+ equal(attach.icalString, "ATTACH:data:text/plain,\r\n");
+}
diff --git a/comm/calendar/test/unit/test_attendee.js b/comm/calendar/test/unit/test_attendee.js
new file mode 100644
index 0000000000..55051f720b
--- /dev/null
+++ b/comm/calendar/test/unit/test_attendee.js
@@ -0,0 +1,318 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_values();
+ test_serialize();
+ test_properties();
+ test_doubleParameters(); // Bug 875739
+ test_emptyAttendee();
+}
+
+function test_values() {
+ function findAttendeesInResults(event, expectedAttendees) {
+ // Getting all attendees
+ let allAttendees = event.getAttendees();
+
+ equal(allAttendees.length, expectedAttendees.length);
+
+ // Check if all expected attendees are found
+ for (let i = 0; i < expectedAttendees.length; i++) {
+ ok(allAttendees.includes(expectedAttendees[i]));
+ }
+
+ // Check if all found attendees are expected
+ for (let i = 0; i < allAttendees.length; i++) {
+ ok(expectedAttendees.includes(allAttendees[i]));
+ }
+ }
+ function findById(event, id, a) {
+ let foundAttendee = event.getAttendeeById(id);
+ equal(foundAttendee, a);
+ }
+ function testImmutability(a, properties) {
+ ok(!a.isMutable);
+ // Check if setting a property throws. It should.
+ for (let i = 0; i < properties.length; i++) {
+ let old = a[properties[i]];
+ throws(() => {
+ a[properties[i]] = old + 1;
+ }, /Can not modify immutable data container/);
+
+ equal(a[properties[i]], old);
+ }
+ }
+
+ // Create Attendee
+ let attendee1 = new CalAttendee();
+ // Testing attendee set/get.
+ let properties = ["id", "commonName", "rsvp", "role", "participationStatus", "userType"];
+ let values = ["myid", "mycn", "TRUE", "CHAIR", "DECLINED", "RESOURCE"];
+ // Make sure test is valid
+ equal(properties.length, values.length);
+
+ for (let i = 0; i < properties.length; i++) {
+ attendee1[properties[i]] = values[i];
+ equal(attendee1[properties[i]], values[i]);
+ }
+
+ // Create event
+ let event = new CalEvent();
+
+ // Add attendee to event
+ event.addAttendee(attendee1);
+
+ // Add 2nd attendee to event.
+ let attendee2 = new CalAttendee();
+ attendee2.id = "myid2";
+ event.addAttendee(attendee2);
+
+ // Finding by ID
+ findById(event, "myid", attendee1);
+ findById(event, "myid2", attendee2);
+
+ findAttendeesInResults(event, [attendee1, attendee2]);
+
+ // Making attendee immutable
+ attendee1.makeImmutable();
+ testImmutability(attendee1, properties);
+ // Testing cascaded immutability (event -> attendee)
+ event.makeImmutable();
+ testImmutability(attendee2, properties);
+
+ // Testing cloning
+ let eventClone = event.clone();
+ let clonedatts = eventClone.getAttendees();
+ let atts = event.getAttendees();
+ equal(atts.length, clonedatts.length);
+
+ for (let i = 0; i < clonedatts.length; i++) {
+ // The attributes should not be equal
+ notEqual(atts[i], clonedatts[i]);
+ // But the ids should
+ equal(atts[i].id, clonedatts[i].id);
+ }
+
+ // Make sure organizers are also cloned correctly
+ let attendee3 = new CalAttendee();
+ attendee3.id = "horst";
+ attendee3.isOrganizer = true;
+ let attendee4 = attendee3.clone();
+
+ ok(attendee4.isOrganizer);
+ attendee3.isOrganizer = false;
+ ok(attendee4.isOrganizer);
+}
+
+function test_serialize() {
+ let a = new CalAttendee();
+
+ throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ a.icalProperty;
+ }, /Component not initialized/);
+
+ a.id = "horst";
+ a.commonName = "Horst";
+ a.rsvp = "TRUE";
+
+ a.isOrganizer = false;
+
+ a.role = "CHAIR";
+ a.participationStatus = "DECLINED";
+ a.userType = "RESOURCE";
+
+ a.setProperty("X-NAME", "X-VALUE");
+
+ let prop = a.icalProperty;
+ dump(prop.icalString);
+ equal(prop.value, "horst");
+ equal(prop.propertyName, "ATTENDEE");
+ equal(prop.getParameter("CN"), "Horst");
+ equal(prop.getParameter("RSVP"), "TRUE");
+ equal(prop.getParameter("ROLE"), "CHAIR");
+ equal(prop.getParameter("PARTSTAT"), "DECLINED");
+ equal(prop.getParameter("CUTYPE"), "RESOURCE");
+ equal(prop.getParameter("X-NAME"), "X-VALUE");
+
+ a.isOrganizer = true;
+ prop = a.icalProperty;
+ equal(prop.value, "horst");
+ equal(prop.propertyName, "ORGANIZER");
+ equal(prop.getParameter("CN"), "Horst");
+ equal(prop.getParameter("RSVP"), "TRUE");
+ equal(prop.getParameter("ROLE"), "CHAIR");
+ equal(prop.getParameter("PARTSTAT"), "DECLINED");
+ equal(prop.getParameter("CUTYPE"), "RESOURCE");
+ equal(prop.getParameter("X-NAME"), "X-VALUE");
+}
+
+function test_properties() {
+ let a = new CalAttendee();
+
+ throws(() => {
+ // eslint-disable-next-line no-unused-expressions
+ a.icalProperty;
+ }, /Component not initialized/);
+
+ a.id = "horst";
+ a.commonName = "Horst";
+ a.rsvp = "TRUE";
+
+ a.isOrganizer = false;
+
+ a.role = "CHAIR";
+ a.participationStatus = "DECLINED";
+ a.userType = "RESOURCE";
+
+ // Only X-Props should show up in the enumerator
+ a.setProperty("X-NAME", "X-VALUE");
+ for (let [name, value] of a.properties) {
+ equal(name, "X-NAME");
+ equal(value, "X-VALUE");
+ }
+
+ a.deleteProperty("X-NAME");
+ for (let [name, value] of a.properties) {
+ do_throw("Unexpected property " + name + " = " + value);
+ }
+
+ a.setProperty("X-NAME", "X-VALUE");
+ a.setProperty("X-NAME", null);
+
+ for (let [name, value] of a.properties) {
+ do_throw("Unexpected property after setting null " + name + " = " + value);
+ }
+}
+
+function test_doubleParameters() {
+ function testParameters(aAttendees, aExpected) {
+ for (let attendee of aAttendees) {
+ let prop = attendee.icalProperty;
+ let parNames = [];
+ let parValues = [];
+
+ // Extract the parameters
+ for (
+ let paramName = prop.getFirstParameterName();
+ paramName;
+ paramName = prop.getNextParameterName()
+ ) {
+ parNames.push(paramName);
+ parValues.push(prop.getParameter(paramName));
+ }
+
+ // Check the results
+ let att_n = attendee.id.substr(7, 9);
+ for (let parIndex in parNames) {
+ ok(
+ aExpected[att_n].param.includes(parNames[parIndex]),
+ "Parameter " + parNames[parIndex] + " included in " + att_n
+ );
+ ok(
+ aExpected[att_n].values.includes(parValues[parIndex]),
+ "Value " + parValues[parIndex] + " for parameter " + parNames[parIndex]
+ );
+ }
+ ok(
+ parNames.length == aExpected[att_n].param.length,
+ "Each parameter has been considered for " + att_n
+ );
+ }
+ }
+
+ // Event with attendees and organizer with one of the parameter duplicated.
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Marketcircle Inc.//Daylite 4.0//EN",
+ "BEGIN:VEVENT",
+ "DTSTART:20130529T100000",
+ "DTEND:20130529T110000",
+ "SUMMARY:Summary",
+ "CREATED:20130514T124220Z",
+ "DTSTAMP:20130524T101307Z",
+ "UID:9482DDFA-07B4-44B9-8228-ED4BC17BA278",
+ "SEQUENCE:3",
+ "ORGANIZER;CN=CN_organizer;X-ORACLE-GUID=A5120D71D6193E11E04400144F;",
+ " X-UW-AVAILABLE-APPOINTMENT-ROLE=OWNER;X-UW-AVAILABLE-APPOINTMENT",
+ " -ROLE=OWNER:mailto:organizer@example.com",
+ "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;RSVP=TRUE;",
+ " PARTSTAT=NEEDS-ACTION;X-RECEIVED-DTSTAMP=",
+ " 20130827T124944Z;CN=CN_attendee1:mailto:attendee1@example.com",
+ "ATTENDEE;ROLE=CHAIR;CN=CN_attendee2;CUTYPE=INDIVIDUAL;",
+ " PARTSTAT=ACCEPTED;CN=CN_attendee2:mailto:attendee2@example.com",
+ "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=RESOURCE;",
+ " PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CN=CN_attendee3",
+ " :mailto:attendee3@example.com",
+ 'ATTENDEE;CN="CN_attendee4";PARTSTAT=ACCEPTED;X-RECEIVED-DTSTAMP=',
+ " 20130827T124944Z;X-RECEIVED-SEQUENCE=0;X-RECEIVED-SEQUENCE=0",
+ " :mailto:attendee4@example.com",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expectedOrganizer = {
+ organizer: {
+ param: ["CN", "X-ORACLE-GUID", "X-UW-AVAILABLE-APPOINTMENT-ROLE"],
+ values: ["CN_organizer", "A5120D71D6193E11E04400144F", "OWNER"],
+ },
+ };
+ let expectedAttendee = {
+ attendee1: {
+ param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE", "X-RECEIVED-DTSTAMP"],
+ values: [
+ "CN_attendee1",
+ "TRUE",
+ "REQ-PARTICIPANT",
+ "NEEDS-ACTION",
+ "INDIVIDUAL",
+ "20130827T124944Z",
+ ],
+ },
+ attendee2: {
+ param: ["CN", "ROLE", "PARTSTAT", "CUTYPE"],
+ values: ["CN_attendee2", "CHAIR", "ACCEPTED", "INDIVIDUAL"],
+ },
+ attendee3: {
+ param: ["CN", "RSVP", "ROLE", "PARTSTAT", "CUTYPE"],
+ values: ["CN_attendee3", "TRUE", "REQ-PARTICIPANT", "NEEDS-ACTION", "RESOURCE"],
+ },
+ attendee4: {
+ param: ["CN", "PARTSTAT", "X-RECEIVED-DTSTAMP", "X-RECEIVED-SEQUENCE"],
+ values: ["CN_attendee4", "ACCEPTED", "20130827T124944Z", "0"],
+ },
+ };
+
+ let event = createEventFromIcalString(ics);
+ let organizer = [event.organizer];
+ let attendees = event.getAttendees();
+
+ testParameters(organizer, expectedOrganizer);
+ testParameters(attendees, expectedAttendee);
+}
+function test_emptyAttendee() {
+ // Event with empty attendee.
+ const event = createEventFromIcalString(
+ [
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "DTSTAMP:19700101T000000Z",
+ "DTSTART:19700101T000001Z",
+ "ATTENDEE:",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n")
+ );
+ const attendees = event.getAttendees();
+ equal(attendees.length, 0);
+}
diff --git a/comm/calendar/test/unit/test_auth_utils.js b/comm/calendar/test/unit/test_auth_utils.js
new file mode 100644
index 0000000000..929762561e
--- /dev/null
+++ b/comm/calendar/test/unit/test_auth_utils.js
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const USERNAME = "fred";
+const PASSWORD = "********";
+const ORIGIN = "https://origin";
+const REALM = "realm";
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+function checkLoginCount(total) {
+ Assert.equal(total, Services.logins.countLogins("", "", ""));
+}
+
+/**
+ * Tests the passwordManager{Get,Save,Remove} functions
+ */
+add_task(async function test_password_manager() {
+ await Services.logins.initializationPromise;
+ checkLoginCount(0);
+
+ // Save the password
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM);
+ checkLoginCount(1);
+
+ // Save again, should modify the existing login
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM);
+ checkLoginCount(1);
+
+ // Retrieve the saved password
+ let passout = {};
+ let found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM);
+ Assert.equal(passout.value, PASSWORD);
+ Assert.ok(found);
+ checkLoginCount(1);
+
+ // Retrieving should still happen with signon saving disabled, but saving should not
+ Services.prefs.setBoolPref("signon.rememberSignons", false);
+ passout = {};
+ found = cal.auth.passwordManagerGet(USERNAME, passout, ORIGIN, REALM);
+ Assert.equal(passout.value, PASSWORD);
+ Assert.ok(found);
+
+ Assert.throws(
+ () => cal.auth.passwordManagerSave(USERNAME, PASSWORD, ORIGIN, REALM),
+ /NS_ERROR_NOT_AVAILABLE/
+ );
+ Services.prefs.clearUserPref("signon.rememberSignons");
+ checkLoginCount(1);
+
+ // Remove the password
+ found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM);
+ checkLoginCount(0);
+ Assert.ok(found);
+
+ // Really gone?
+ found = cal.auth.passwordManagerRemove(USERNAME, ORIGIN, REALM);
+ checkLoginCount(0);
+ Assert.ok(!found);
+});
+
+/**
+ * Tests various origins that can be passed to passwordManagerSave
+ */
+add_task(async function test_password_manager_origins() {
+ await Services.logins.initializationPromise;
+ checkLoginCount(0);
+
+ // The scheme of the origin should be normalized to lowercase, this won't add any new passwords
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "OAUTH:xpcshell@example.com", REALM);
+ checkLoginCount(1);
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "oauth:xpcshell@example.com", REALM);
+ checkLoginCount(1);
+
+ // Make sure that the prePath isn't used for oauth, because that is only the scheme
+ let found = cal.auth.passwordManagerGet(USERNAME, {}, "oauth:", REALM);
+ Assert.ok(!found);
+
+ // Save a https url with a path (only prePath should be used)
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "https://example.com/withpath", REALM);
+ found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.com", REALM);
+ Assert.ok(found);
+ checkLoginCount(2);
+
+ // Entering something that is not an URL should assume https
+ cal.auth.passwordManagerSave(USERNAME, PASSWORD, "example.net", REALM);
+ found = cal.auth.passwordManagerGet(USERNAME, {}, "https://example.net", REALM);
+ Assert.ok(found);
+ checkLoginCount(3);
+
+ // Cleanup
+ cal.auth.passwordManagerRemove(USERNAME, "oauth:xpcshell@example.com", REALM);
+ cal.auth.passwordManagerRemove(USERNAME, "https://example.com", REALM);
+ cal.auth.passwordManagerRemove(USERNAME, "https://example.net", REALM);
+ checkLoginCount(0);
+});
diff --git a/comm/calendar/test/unit/test_bug1199942.js b/comm/calendar/test/unit/test_bug1199942.js
new file mode 100644
index 0000000000..7d78cb1244
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1199942.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test the graceful handling of attendee ids for bug 1199942
+ createAttendee_test();
+ serializeEvent_test();
+}
+
+function createAttendee_test() {
+ let data = [
+ { input: "mailto:user1@example.net", expected: "mailto:user1@example.net" },
+ { input: "MAILTO:user2@example.net", expected: "mailto:user2@example.net" },
+ { input: "user3@example.net", expected: "mailto:user3@example.net" },
+ { input: "urn:uuid:user4", expected: "urn:uuid:user4" },
+ ];
+ let event = new CalEvent();
+ for (let test of data) {
+ let attendee = new CalAttendee();
+ attendee.id = test.input;
+ event.addAttendee(attendee);
+ let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.input));
+ equal(readAttendee.id, test.expected);
+ }
+}
+
+function serializeEvent_test() {
+ let ics =
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "CREATED:20150801T213509Z\n" +
+ "LAST-MODIFIED:20150830T164104Z\n" +
+ "DTSTAMP:20150830T164104Z\n" +
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf\n" +
+ "SUMMARY:New Event\n" +
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:MAILTO:user2@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user3@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:user4@example.net\n" +
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:urn:uuid:user5\n" +
+ "DTSTART:20150729T103000Z\n" +
+ "DTEND:20150729T113000Z\n" +
+ "TRANSP:OPAQUE\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR\n";
+
+ let expectedIds = [
+ "mailto:user2@example.net",
+ "mailto:user3@example.net",
+ "mailto:user4@example.net",
+ "urn:uuid:user5",
+ ];
+ let event = createEventFromIcalString(ics);
+ let attendees = event.getAttendees();
+
+ // check whether all attendees get returned with expected id
+ for (let attendee of attendees) {
+ ok(expectedIds.includes(attendee.id));
+ }
+
+ // serialize the event again and check whether the attendees still are in shape
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([event]);
+ let serialized = ics_unfoldline(serializer.serializeToString());
+ for (let id of expectedIds) {
+ ok(serialized.search(id) != -1);
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug1204255.js b/comm/calendar/test/unit/test_bug1204255.js
new file mode 100644
index 0000000000..9277378ec4
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1204255.js
@@ -0,0 +1,146 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test attendee duplicate handling for bug 1204255
+ test_newAttendee();
+ test_fromICS();
+}
+
+function test_newAttendee() {
+ let data = [
+ {
+ input: [
+ { id: "user2@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ },
+ {
+ input: [
+ { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ { id: "user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ ],
+ expected: { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ { id: "user4@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ ],
+ expected: { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user5@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ { id: "mailto:user6@example.net", partstat: "TENTATIVE", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ },
+ {
+ input: [
+ { id: "user7@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ ],
+ expected: { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ },
+ ];
+
+ let event = new CalEvent();
+ for (let test of data) {
+ for (let input of test.input) {
+ let attendee = new CalAttendee();
+ attendee.id = input.id;
+ attendee.participationStatus = input.partstat;
+ attendee.commonName = input.cname;
+ event.addAttendee(attendee);
+ }
+ let readAttendee = event.getAttendeeById(cal.email.prependMailTo(test.expected.id));
+ equal(readAttendee.id, test.expected.id);
+ equal(
+ readAttendee.participationStatus,
+ test.expected.partstat,
+ "partstat matches for " + test.expected.id
+ );
+ equal(
+ readAttendee.commonName,
+ test.expected.cname,
+ "commonName matches for " + test.expected.id
+ );
+ }
+}
+
+function test_fromICS() {
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf",
+ "SUMMARY:New Event",
+ "DTSTART:20150729T103000Z",
+ "DTEND:20150729T113000Z",
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net",
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user2@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user2@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user3@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user3@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user4@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user4@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user5@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=ACCEPTED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user5@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user6@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user6@example.net',
+
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=TENTATIVE;CN="NOT PREFIXED";ROLE=REQ-PARTICIPANT:user7@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED;CN="PREFIXED";ROLE=REQ-PARTICIPANT:mailto:user7@example.net',
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expected = [
+ { id: "mailto:user2@example.net", partstat: "NEEDS-ACTION", cname: "PREFIXED" },
+ { id: "mailto:user3@example.net", partstat: "NEEDS-ACTION", cname: "NOT PREFIXED" },
+ { id: "mailto:user4@example.net", partstat: "ACCEPTED", cname: "PREFIXED" },
+ { id: "mailto:user5@example.net", partstat: "TENTATIVE", cname: "NOT PREFIXED" },
+ { id: "mailto:user6@example.net", partstat: "DECLINED", cname: "NOT PREFIXED" },
+ { id: "mailto:user7@example.net", partstat: "DECLINED", cname: "PREFIXED" },
+ ];
+ let event = createEventFromIcalString(ics);
+ let attendees = event.getAttendees();
+
+ // check whether all attendees get returned as expected
+ equal(attendees.length, expected.length);
+ let count = 0;
+ for (let attendee of attendees) {
+ for (let exp of expected) {
+ if (attendee.id == exp.id) {
+ equal(attendee.participationStatus, exp.partstat, "partstat matches for " + exp.id);
+ equal(attendee.commonName, exp.cname, "commonName matches for " + exp.id);
+ count++;
+ }
+ }
+ }
+ equal(count, expected.length, "all attendees were processed");
+}
diff --git a/comm/calendar/test/unit/test_bug1209399.js b/comm/calendar/test/unit/test_bug1209399.js
new file mode 100644
index 0000000000..6392f25867
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1209399.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ // Test handling for multiple double quotes leading/trailing to attendee CN for bug 1209399
+ test_newAttendee();
+ test_fromICS();
+}
+
+function test_newAttendee() {
+ let data = [
+ {
+ input: { cname: null, id: "mailto:user1@example.net" },
+ expected: { cname: null },
+ },
+ {
+ input: { cname: "Test2", id: "mailto:user2@example.net" },
+ expected: { cname: "Test2" },
+ },
+ {
+ input: { cname: '"Test3"', id: "mailto:user3@example.net" },
+ expected: { cname: "Test3" },
+ },
+ {
+ input: { cname: '""Test4""', id: "mailto:user4@example.net" },
+ expected: { cname: "Test4" },
+ },
+ {
+ input: { cname: '""Test5"', id: "mailto:user5@example.net" },
+ expected: { cname: "Test5" },
+ },
+ {
+ input: { cname: '"Test6""', id: "mailto:user6@example.net" },
+ expected: { cname: "Test6" },
+ },
+ {
+ input: { cname: "", id: "mailto:user7@example.net" },
+ expected: { cname: "" },
+ },
+ {
+ input: { cname: '""', id: "mailto:user8@example.net" },
+ expected: { cname: null },
+ },
+ {
+ input: { cname: '""""', id: "mailto:user9@example.net" },
+ expected: { cname: null },
+ },
+ ];
+
+ let i = 0;
+ let event = new CalEvent();
+ for (let test of data) {
+ i++;
+ let attendee = new CalAttendee();
+ attendee.id = test.input.id;
+ attendee.commonName = test.input.cname;
+
+ event.addAttendee(attendee);
+ let readAttendee = event.getAttendeeById(test.input.id);
+ equal(
+ readAttendee.commonName,
+ test.expected.cname,
+ "Test #" + i + " for commonName matching of " + test.input.id
+ );
+ }
+}
+
+function test_fromICS() {
+ let ics = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VEVENT",
+ "UID:a84c74d1-cfc6-4ddf-9d60-9e4afd8238cf",
+ "SUMMARY:New Event",
+ "DTSTART:20150729T103000Z",
+ "DTEND:20150729T113000Z",
+ "ORGANIZER;RSVP=TRUE;CN=Tester1;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:user1@example.net",
+
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=Test2;ROLE=REQ-PARTICIPANT:mailto:user2@example.net",
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="Test3";ROLE=REQ-PARTICIPANT:mailto:user3@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=""Test4"";ROLE=REQ-PARTICIPANT:mailto:user4@example.net',
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN=;ROLE=REQ-PARTICIPANT:mailto:user5@example.net",
+ "ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:user6@example.net",
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="";ROLE=REQ-PARTICIPANT:mailto:user7@example.net',
+ 'ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION;CN="""";ROLE=REQ-PARTICIPANT:mailto:user8@example.net',
+
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\n");
+
+ let expected = [
+ { id: "mailto:user2@example.net", cname: "Test2" },
+ { id: "mailto:user3@example.net", cname: "Test3" },
+ { id: "mailto:user4@example.net", cname: "" },
+ { id: "mailto:user5@example.net", cname: "" },
+ { id: "mailto:user6@example.net", cname: null },
+ { id: "mailto:user7@example.net", cname: "" },
+ { id: "mailto:user8@example.net", cname: "" },
+ ];
+ let event = createEventFromIcalString(ics);
+
+ equal(event.getAttendees().length, expected.length, "Check test consistency");
+ for (let exp of expected) {
+ let attendee = event.getAttendeeById(exp.id);
+ equal(attendee.commonName, exp.cname, "Test for commonName matching of " + exp.id);
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug1790339.js b/comm/calendar/test/unit/test_bug1790339.js
new file mode 100644
index 0000000000..4d2bed95ce
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug1790339.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ let dbFile = do_get_profile();
+ dbFile.append("test_storage.sqlite");
+
+ let sql = await IOUtils.readUTF8(do_get_file("data/bug1790339.sql").path);
+ let db = Services.storage.openDatabase(dbFile);
+ db.executeSimpleSQL(sql);
+ db.close();
+
+ await new Promise(resolve => {
+ do_calendar_startup(resolve);
+ });
+
+ let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+ calendar.uri = Services.io.newFileURI(dbFile);
+ calendar.id = "00000000-0000-0000-0000-000000000000";
+
+ checkItem(await calendar.getItem("00000000-0000-0000-0000-111111111111"));
+ checkItem(await calendar.getItem("00000000-0000-0000-0000-222222222222"));
+});
+
+function checkItem(item) {
+ info(`Checking item ${item.id}`);
+
+ let attachments = item.getAttachments();
+ Assert.equal(attachments.length, 1);
+ let attach = attachments[0];
+ Assert.equal(
+ attach.uri.spec,
+ "https://ftp.mozilla.org/pub/thunderbird/nightly/latest-comm-central/thunderbird-106.0a1.en-US.linux-x86_64.tar.bz2"
+ );
+
+ let attendees = item.getAttendees();
+ Assert.equal(attendees.length, 1);
+ let attendee = attendees[0];
+ Assert.equal(attendee.id, "mailto:test@example.com");
+ Assert.equal(attendee.role, "REQ-PARTICIPANT");
+ Assert.equal(attendee.participationStatus, "NEEDS-ACTION");
+
+ let recurrenceItems = item.recurrenceInfo.getRecurrenceItems();
+ Assert.equal(recurrenceItems.length, 1);
+ let recurrenceItem = recurrenceItems[0];
+ Assert.equal(recurrenceItem.type, "WEEKLY");
+ Assert.equal(recurrenceItem.interval, 22);
+ Assert.equal(recurrenceItem.isByCount, false);
+ Assert.equal(recurrenceItem.isFinite, true);
+ Assert.deepEqual(recurrenceItem.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]);
+ Assert.equal(recurrenceItem.isNegative, false);
+
+ let relations = item.getRelations();
+ Assert.equal(relations.length, 1);
+ let relation = relations[0];
+ Assert.equal(relation.relType, "SIBLING");
+ Assert.equal(relation.relId, "19960401-080045-4000F192713@example.com");
+
+ let alarms = item.getAlarms();
+ Assert.equal(alarms.length, 1);
+ let alarm = alarms[0];
+ Assert.equal(alarm.action, "DISPLAY");
+ Assert.equal(alarm.offset.inSeconds, -300);
+ Assert.equal(
+ alarm.description,
+ "Make sure you don't miss this very very important event. It's essential that you don't forget."
+ );
+}
diff --git a/comm/calendar/test/unit/test_bug272411.js b/comm/calendar/test/unit/test_bug272411.js
new file mode 100644
index 0000000000..c871b53cf0
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug272411.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let jsd = new Date();
+ let cdt = cal.dtz.jsDateToDateTime(jsd);
+
+ let cdtTime = cal.dtz.dateTimeToJsDate(cdt).getTime() / 1000;
+ let jsdTime = Math.floor(jsd.getTime() / 1000);
+
+ // calIDateTime is only accurate to the second, milliseconds need to be
+ // stripped.
+ equal(cdtTime, jsdTime);
+}
diff --git a/comm/calendar/test/unit/test_bug343792.js b/comm/calendar/test/unit/test_bug343792.js
new file mode 100644
index 0000000000..c212315af3
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug343792.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check that Bug 343792 doesn't regress:
+ // Freeze (hang) on RRULE which has INTERVAL=0
+
+ let icalString =
+ "BEGIN:VCALENDAR\n" +
+ "CALSCALE:GREGORIAN\n" +
+ "PRODID:-//Ximian//NONSGML Evolution Calendar//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VTIMEZONE\n" +
+ "TZID:/softwarestudio.org/Olson_20011030_5/America/Los_Angeles\n" +
+ "X-LIC-LOCATION:America/Los_Angeles\n" +
+ "BEGIN:STANDARD\n" +
+ "TZOFFSETFROM:-0700\n" +
+ "TZOFFSETTO:-0800\n" +
+ "TZNAME:PST\n" +
+ "DTSTART:19701025T020000\n" +
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;INTERVAL=1\n" +
+ "END:STANDARD\n" +
+ "BEGIN:DAYLIGHT\n" +
+ "TZOFFSETFROM:-0800\n" +
+ "TZOFFSETTO:-0700\n" +
+ "TZNAME:PDT\n" +
+ "DTSTART:19700405T020000\n" +
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;INTERVAL=1\n" +
+ "END:DAYLIGHT\n" +
+ "END:VTIMEZONE\n" +
+ "BEGIN:VEVENT\n" +
+ "UID:20060705T145529-1768-1244-1267-46@localhost\n" +
+ "ORGANIZER:MAILTO:No Body\n" +
+ "DTSTAMP:20060705T145529Z\n" +
+ "DTSTART;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" +
+ " 20060515T170000\n" +
+ "DTEND;TZID=/softwarestudio.org/Olson_20011030_5/America/Los_Angeles:\n" +
+ " 20060515T173000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=0\n" +
+ "LOCATION:Maui Building\n" +
+ "TRANSP:OPAQUE\n" +
+ "SEQUENCE:0\n" +
+ "SUMMARY:FW development Status\n" +
+ "PRIORITY:4\n" +
+ "CLASS:PUBLIC\n" +
+ "DESCRIPTION:Daily standup Mtg and/or status update on FW\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR";
+
+ let event = createEventFromIcalString(icalString);
+ let start = createDate(2009, 4, 1);
+ let end = createDate(2009, 4, 30);
+
+ // the following call caused a never ending loop:
+ let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ equal(occurrenceDates.length, 4);
+
+ // the following call caused a never ending loop:
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+ equal(occurrences.length, 4);
+}
diff --git a/comm/calendar/test/unit/test_bug350845.js b/comm/calendar/test/unit/test_bug350845.js
new file mode 100644
index 0000000000..735f4b6eb4
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug350845.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let event = createEventFromIcalString(
+ "BEGIN:VEVENT\n" +
+ "UID:182d2719-fe2a-44c1-9210-0286b16c0538\n" +
+ "X-FOO;X-BAR=BAZ:QUUX\n" +
+ "END:VEVENT"
+ );
+
+ // Test getters for imported event
+ equal(event.getProperty("X-FOO"), "QUUX");
+ ok(event.hasProperty("X-FOO"));
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), "BAZ");
+ ok(event.hasPropertyParameter("X-FOO", "X-BAR"));
+
+ // Test setters
+ throws(() => {
+ event.setPropertyParameter("X-UNKNOWN", "UNKNOWN", "VALUE");
+ }, /Property X-UNKNOWN not set/);
+
+ // More setters
+ event.setPropertyParameter("X-FOO", "X-BAR", "FNORD");
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), "FNORD");
+ notEqual(event.icalString.match(/^X-FOO;X-BAR=FNORD:QUUX$/m), null);
+
+ // Test we can get the parameter names
+ throws(() => {
+ event.getParameterNames("X-UNKNOWN");
+ }, /Property X-UNKNOWN not set/);
+ equal(event.getParameterNames("X-FOO").length, 1);
+ ok(event.getParameterNames("X-FOO").includes("X-BAR"));
+
+ // Deletion of parameters when deleting properties
+ event.deleteProperty("X-FOO");
+ ok(!event.hasProperty("X-FOO"));
+ event.setProperty("X-FOO", "SNORK");
+ equal(event.getProperty("X-FOO"), "SNORK");
+ equal(event.getParameterNames("X-FOO").length, 0);
+ equal(event.getPropertyParameter("X-FOO", "X-BAR"), null);
+}
diff --git a/comm/calendar/test/unit/test_bug356207.js b/comm/calendar/test/unit/test_bug356207.js
new file mode 100644
index 0000000000..1ad23c6acd
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug356207.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check that Bug 356207 doesn't regress:
+ // Freeze (hang) on RRULE which has BYMONTHDAY and BYDAY
+
+ let icalString =
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//Randy L Pearson//NONSGML Outlook2vCal V1.1//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "CREATED:20040829T163323\n" +
+ "UID:00000000EBFAC68C9B92BF119D643623FBD17E1424312000\n" +
+ "SEQUENCE:1\n" +
+ "LAST-MODIFIED:20060615T231158\n" +
+ "DTSTAMP:20040829T163323\n" +
+ "ORGANIZER:Unknown\n" +
+ "DTSTART:20040901T141500\n" +
+ "DESCRIPTION:Contact Mary Tindall for more details.\n" +
+ "CLASS:PUBLIC\n" +
+ "LOCATION:Church\n" +
+ "CATEGORIES:Church Events\n" +
+ "SUMMARY:Friendship Circle\n" +
+ "PRIORITY:1\n" +
+ "DTEND:20040901T141500\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1;BYDAY=WE\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR";
+
+ let event = createEventFromIcalString(icalString);
+ let start = createDate(2009, 0, 1);
+ let end = createDate(2009, 11, 31);
+
+ // the following call caused a never ending loop:
+ let occurrenceDates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ equal(occurrenceDates.length, 2);
+
+ // the following call caused a never ending loop:
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+ equal(occurrences.length, 2);
+}
diff --git a/comm/calendar/test/unit/test_bug485571.js b/comm/calendar/test/unit/test_bug485571.js
new file mode 100644
index 0000000000..855109b7e8
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug485571.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+function run_test() {
+ // Check that the RELATED property is correctly set
+ // after parsing the given VALARM component
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;RELATED=START:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 15 minutes prior to the start of the event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION;RELATED=START:-PT15M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_START
+ );
+
+ // trigger set 5 minutes after the end of an event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;RELATED=END:PT5M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_END
+ );
+
+ // trigger set 5 minutes after the end of an event
+ check_relative(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DURATION;RELATED=END:PT5M\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM",
+ Ci.calIAlarm.ALARM_RELATED_END
+ );
+
+ // trigger set to an absolute date/time
+ check_absolute(
+ "BEGIN:VALARM\n" +
+ "ACTION:DISPLAY\n" +
+ "TRIGGER;VALUE=DATE-TIME:20090430T080000Z\n" +
+ "DESCRIPTION:TEST\n" +
+ "END:VALARM"
+ );
+}
+
+function check_relative(aIcalString, aRelated) {
+ let alarm = new CalAlarm();
+ alarm.icalString = aIcalString;
+ equal(alarm.related, aRelated);
+ equal(alarm.alarmDate, null);
+ notEqual(alarm.offset, null);
+}
+
+function check_absolute(aIcalString) {
+ let alarm = new CalAlarm();
+ alarm.icalString = aIcalString;
+ equal(alarm.related, Ci.calIAlarm.ALARM_RELATED_ABSOLUTE);
+ ok(alarm.alarmDate != null);
+ equal(alarm.offset, null);
+}
diff --git a/comm/calendar/test/unit/test_bug486186.js b/comm/calendar/test/unit/test_bug486186.js
new file mode 100644
index 0000000000..cf25594790
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug486186.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+function run_test() {
+ // ensure that RELATED property is correctly set on the VALARM component
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.description = "test";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_END;
+ alarm.offset = cal.createDuration("-PT15M");
+ if (alarm.icalString.search(/RELATED=END/) == -1) {
+ do_throw("Bug 486186: RELATED property missing in VALARM component");
+ }
+}
diff --git a/comm/calendar/test/unit/test_bug494140.js b/comm/calendar/test/unit/test_bug494140.js
new file mode 100644
index 0000000000..9a5baa6956
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug494140.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * In bug 494140 we found out that creating an exception to a series duplicates
+ * alarms. This unit test makes sure the alarms don't duplicate themselves. The
+ * same goes for relations and attachments.
+ */
+add_task(async () => {
+ let storageCal = getStorageCal();
+
+ let item = createEventFromIcalString(
+ "BEGIN:VEVENT\r\n" +
+ "CREATED:20090603T171401Z\r\n" +
+ "LAST-MODIFIED:20090617T080410Z\r\n" +
+ "DTSTAMP:20090617T080410Z\r\n" +
+ "UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5\r\n" +
+ "SUMMARY:Test\r\n" +
+ "DTSTART:20090603T073000Z\r\n" +
+ "DTEND:20090603T091500Z\r\n" +
+ "RRULE:FREQ=DAILY;COUNT=5\r\n" +
+ "RELATED-TO:RELTYPE=SIBLING:<foo@example.org>\r\n" +
+ "ATTACH:http://www.example.org/\r\n" +
+ "BEGIN:VALARM\r\n" +
+ "ACTION:DISPLAY\r\n" +
+ "TRIGGER;VALUE=DURATION:-PT10M\r\n" +
+ "DESCRIPTION:Mozilla Alarm: Test\r\n" +
+ "END:VALARM\r\n" +
+ "END:VEVENT"
+ );
+
+ // There should be one alarm, one relation and one attachment
+ equal(item.getAlarms().length, 1);
+ equal(item.getRelations().length, 1);
+ equal(item.getAttachments().length, 1);
+
+ // Change the occurrence to another day
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20090604T073000Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20090618T073000Z");
+ item.recurrenceInfo.modifyException(occ, true);
+
+ // There should still be one alarm, one relation and one attachment
+ equal(item.getAlarms().length, 1);
+ equal(item.getRelations().length, 1);
+ equal(item.getAttachments().length, 1);
+
+ // Add the item to the storage calendar and retrieve it again
+ await storageCal.adoptItem(item);
+
+ let retrievedItem = await storageCal.getItem("c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5");
+ // There should still be one alarm, one relation and one attachment
+ equal(retrievedItem.getAlarms().length, 1);
+ equal(retrievedItem.getRelations().length, 1);
+ equal(retrievedItem.getAttachments().length, 1);
+});
diff --git a/comm/calendar/test/unit/test_bug523860.js b/comm/calendar/test/unit/test_bug523860.js
new file mode 100644
index 0000000000..c5455eda3d
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug523860.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+function run_test() {
+ // In bug 523860, we found out that in the spec doublequotes should not be
+ // escaped.
+ let prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ let expected = "A String with \"quotes\" and 'other quotes'";
+
+ prop.value = expected;
+ equal(prop.icalString, "DESCRIPTION:" + expected + "\r\n");
+}
diff --git a/comm/calendar/test/unit/test_bug653924.js b/comm/calendar/test/unit/test_bug653924.js
new file mode 100644
index 0000000000..1573ae54aa
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug653924.js
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ const evt = new CalEvent();
+ const rel = new CalRelation("RELATED-TO:2424d594-0453-49a1-b842-6faee483ca79");
+ evt.addRelation(rel);
+
+ equal(1, evt.icalString.match(/RELATED-TO/g).length);
+ evt.icalString = evt.icalString; // eslint-disable-line no-self-assign
+ equal(1, evt.icalString.match(/RELATED-TO/g).length);
+}
diff --git a/comm/calendar/test/unit/test_bug668222.js b/comm/calendar/test/unit/test_bug668222.js
new file mode 100644
index 0000000000..eb6e9f5d0b
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug668222.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ let attendee = new CalAttendee();
+ attendee.id = "mailto:somebody";
+
+ // Set the property and make sure its there
+ attendee.setProperty("SCHEDULE-AGENT", "CLIENT");
+ equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT");
+
+ // Reserialize the property, this has caused the property to go away
+ // in the past.
+ attendee.icalProperty = attendee.icalProperty; // eslint-disable-line no-self-assign
+ equal(attendee.getProperty("SCHEDULE-AGENT"), "CLIENT");
+
+ // Also make sure there are no promoted properties set. This does not
+ // technically belong to this bug, but I almost caused this error while
+ // writing the patch.
+ ok(!attendee.icalProperty.icalString.includes("RSVP"));
+}
diff --git a/comm/calendar/test/unit/test_bug759324.js b/comm/calendar/test/unit/test_bug759324.js
new file mode 100644
index 0000000000..e2dabcd9f9
--- /dev/null
+++ b/comm/calendar/test/unit/test_bug759324.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+let storage = getStorageCal();
+
+/**
+ * Checks if the capabilities.propagate-sequence feature of the storage calendar
+ * still works
+ */
+add_task(async function testBug759324() {
+ storage.setProperty("capabilities.propagate-sequence", "true");
+
+ let str = [
+ "BEGIN:VEVENT",
+ "UID:recItem",
+ "SEQUENCE:3",
+ "RRULE:FREQ=WEEKLY",
+ "DTSTART:20120101T010101Z",
+ "END:VEVENT",
+ ].join("\r\n");
+
+ let item = createEventFromIcalString(str);
+ let rid = cal.createDateTime("20120101T010101Z");
+ let rec = item.recurrenceInfo.getOccurrenceFor(rid);
+ rec.title = "changed";
+ item.recurrenceInfo.modifyException(rec, true);
+
+ do_test_pending();
+
+ let addedItem = await storage.addItem(item);
+ addedItem.QueryInterface(Ci.calIEvent);
+ let seq = addedItem.getProperty("SEQUENCE");
+ let occ = addedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 3);
+ equal(occ.getProperty("SEQUENCE"), seq);
+
+ let changedItem = addedItem.clone();
+ changedItem.setProperty("SEQUENCE", parseInt(seq, 10) + 1);
+
+ checkModifiedItem(rid, await storage.modifyItem(changedItem, addedItem));
+});
+
+async function checkModifiedItem(rid, changedItem) {
+ changedItem.QueryInterface(Ci.calIEvent);
+ let seq = changedItem.getProperty("SEQUENCE");
+ let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 4);
+ equal(occ.getProperty("SEQUENCE"), seq);
+
+ // Now check with the pref off
+ storage.deleteProperty("capabilities.propagate-sequence");
+
+ let changedItem2 = changedItem.clone();
+ changedItem2.setProperty("SEQUENCE", parseInt(seq, 10) + 1);
+
+ checkNormalItem(rid, await storage.modifyItem(changedItem2, changedItem));
+}
+
+function checkNormalItem(rid, changedItem) {
+ changedItem.QueryInterface(Ci.calIEvent);
+ let seq = changedItem.getProperty("SEQUENCE");
+ let occ = changedItem.recurrenceInfo.getOccurrenceFor(rid);
+
+ equal(seq, 5);
+ equal(occ.getProperty("SEQUENCE"), 4);
+ completeTest();
+}
+
+function completeTest() {
+ do_test_finished();
+}
diff --git a/comm/calendar/test/unit/test_calIteratorUtils.js b/comm/calendar/test/unit/test_calIteratorUtils.js
new file mode 100644
index 0000000000..db5158d6c9
--- /dev/null
+++ b/comm/calendar/test/unit/test_calIteratorUtils.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for cal.iterate.*
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Test streamValues() iterates over all values found in a stream.
+ */
+add_task(async function testStreamValues() {
+ let src = Array(10)
+ .fill(null)
+ .map((_, i) => i + 1);
+ let stream = CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < src.length; i++) {
+ controller.enqueue(src[i]);
+ }
+ controller.close();
+ },
+ });
+
+ let dest = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ dest.push(value);
+ }
+ Assert.ok(
+ src.every((val, idx) => (dest[idx] = val)),
+ "all values were read from the stream"
+ );
+});
diff --git a/comm/calendar/test/unit/test_calStorageHelpers.js b/comm/calendar/test/unit/test_calStorageHelpers.js
new file mode 100644
index 0000000000..b7b3d54f04
--- /dev/null
+++ b/comm/calendar/test/unit/test_calStorageHelpers.js
@@ -0,0 +1,23 @@
+const { newDateTime } = ChromeUtils.import("resource:///modules/calendar/calStorageHelpers.jsm");
+
+add_task(async function testNewDateTimeWithIcalTimezoneDef() {
+ // Define a timezone that is unlikely to match anything in common use
+ const icalTimezoneDef = `BEGIN:VTIMEZONE
+TZID:Totally_Made_Up_Standard_Time
+BEGIN:STANDARD
+DTSTART:19671029T020000
+TZOFFSETFROM:-0427
+TZOFFSETTO:-0527
+END:STANDARD
+END:VTIMEZONE`;
+
+ // 6 October, 2022 at 17:23:08 UTC
+ const dateTime = newDateTime(1665076988000000, icalTimezoneDef);
+
+ Assert.equal(dateTime.year, 2022, "year should be 2022");
+ Assert.equal(dateTime.month, 9, "zero-based month should be October");
+ Assert.equal(dateTime.day, 6, "day should be the 6th");
+ Assert.equal(dateTime.hour, 11, "hour should be 11 AM");
+ Assert.equal(dateTime.minute, 56, "minute should be 56");
+ Assert.equal(dateTime.second, 8, "second should be 8");
+});
diff --git a/comm/calendar/test/unit/test_caldav_requests.js b/comm/calendar/test/unit/test_caldav_requests.js
new file mode 100644
index 0000000000..2f46c09a8d
--- /dev/null
+++ b/comm/calendar/test/unit/test_caldav_requests.js
@@ -0,0 +1,970 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+var {
+ CalDavGenericRequest,
+ CalDavItemRequest,
+ CalDavDeleteItemRequest,
+ CalDavPropfindRequest,
+ CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest,
+ CalDavOutboxRequest,
+ CalDavFreeBusyRequest,
+} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+var { CalDavWebDavSyncHandler } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequestHandlers.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+var { CalDavXmlns } = ChromeUtils.import("resource:///modules/caldav/CalDavUtils.jsm");
+var { Preferences } = ChromeUtils.importESModule("resource://gre/modules/Preferences.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+class LowerMap extends Map {
+ get(key) {
+ return super.get(key.toLowerCase());
+ }
+ set(key, value) {
+ return super.set(key.toLowerCase(), value);
+ }
+}
+
+var gServer;
+
+var MockConflictPrompt = {
+ _origFunc: null,
+ overwrite: false,
+ register() {
+ if (!this._origFunc) {
+ this._origFunc = cal.provider.promptOverwrite;
+ cal.provider.promptOverwrite = (aMode, aItem) => {
+ return this.overwrite;
+ };
+ }
+ },
+
+ unregister() {
+ if (this._origFunc) {
+ cal.provider.promptOverwrite = this._origFunc;
+ this._origFunc = null;
+ }
+ },
+};
+
+class MockAlertsService {
+ QueryInterface = ChromeUtils.generateQI(["nsIAlertsService"]);
+ showAlert() {}
+}
+
+function replaceAlertsService() {
+ let originalAlertsServiceCID = MockRegistrar.register(
+ "@mozilla.org/alerts-service;1",
+ MockAlertsService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(originalAlertsServiceCID);
+ });
+}
+
+var gMockCalendar = {
+ name: "xpcshell",
+ makeUri(insert, base) {
+ return base;
+ },
+ verboseLogging() {
+ return true;
+ },
+ ensureEncodedPath(x) {
+ return x;
+ },
+ ensureDecodedPath(x) {
+ return x;
+ },
+ startBatch() {},
+ endBatch() {},
+ addTargetCalendarItem() {},
+ finalizeUpdatedItems() {},
+ mHrefIndex: [],
+};
+gMockCalendar.superCalendar = gMockCalendar;
+
+class CalDavServer {
+ constructor(calendarId) {
+ this.server = new HttpServer();
+ this.calendarId = calendarId;
+ this.session = new CalDavSession(this.calendarId, "xpcshell");
+ this.serverRequests = {};
+
+ this.server.registerPrefixHandler(
+ "/principals/",
+ this.router.bind(this, this.principals.bind(this))
+ );
+ this.server.registerPrefixHandler(
+ "/calendars/",
+ this.router.bind(this, this.calendars.bind(this))
+ );
+ this.server.registerPrefixHandler(
+ "/requests/",
+ this.router.bind(this, this.requests.bind(this))
+ );
+ }
+
+ start() {
+ this.server.start(-1);
+ registerCleanupFunction(() => this.server.stop(() => {}));
+ }
+
+ reset() {
+ this.serverRequests = {};
+ }
+
+ uri(path) {
+ let base = Services.io.newURI(`http://localhost:${this.server.identity.primaryPort}/`);
+ return Services.io.newURI(path, null, base);
+ }
+
+ router(nextHandler, request, response) {
+ try {
+ let method = request.method;
+ let parameters = new Map(request.queryString.split("&").map(part => part.split("=", 2)));
+ let available = request.bodyInputStream.available();
+ let body =
+ available > 0 ? NetUtil.readInputStreamToString(request.bodyInputStream, available) : null;
+
+ let headers = new LowerMap();
+
+ let headerIterator = function* (enumerator) {
+ while (enumerator.hasMoreElements()) {
+ yield enumerator.getNext().QueryInterface(Ci.nsISupportsString);
+ }
+ };
+
+ for (let hdr of headerIterator(request.headers)) {
+ headers.set(hdr.data, request.getHeader(hdr.data));
+ }
+
+ return nextHandler(request, response, method, headers, parameters, body);
+ } catch (e) {
+ info("Server Error: " + e.fileName + ":" + e.lineNumber + ": " + e + "\n");
+ return null;
+ }
+ }
+
+ resetClient(client) {
+ MockConflictPrompt.unregister();
+ cal.manager.unregisterCalendar(client);
+ }
+
+ waitForLoad(aCalendar) {
+ return new Promise((resolve, reject) => {
+ let observer = cal.createAdapter(Ci.calIObserver, {
+ onLoad() {
+ let uncached = aCalendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject;
+ aCalendar.removeObserver(observer);
+
+ if (Components.isSuccessCode(uncached._lastStatus)) {
+ resolve(aCalendar);
+ } else {
+ reject(uncached._lastMessage);
+ }
+ },
+ });
+ aCalendar.addObserver(observer);
+ });
+ }
+
+ getClient() {
+ let uri = this.uri("/calendars/xpcshell/events");
+ let client = cal.manager.createCalendar("caldav", uri);
+ let uclient = client.wrappedJSObject;
+ client.name = "xpcshell";
+ client.setProperty("cache.enabled", true);
+
+ // Make sure we catch the last error message in case sync fails
+ monkeyPatch(uclient, "replayChangesOn", (protofunc, aListener) => {
+ protofunc({
+ onResult(operation, detail) {
+ uclient._lastStatus = operation.status;
+ uclient._lastMessage = detail;
+ aListener.onResult(operation, detail);
+ },
+ });
+ });
+
+ cal.manager.registerCalendar(client);
+
+ let cachedCalendar = cal.manager.getCalendarById(client.id);
+ return this.waitForLoad(cachedCalendar);
+ }
+
+ principals(request, response, method, headers, parameters, body) {
+ this.serverRequests.principals = { method, headers, parameters, body };
+
+ if (method == "REPORT" && request.path == "/principals/") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus xmlns:D="DAV:" xmlns:B="http://BigCorp.com/ns/">
+ <D:response>
+ <D:href>http://www.example.com/users/jdoe</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:displayname>John Doe</D:displayname>
+ <B:department>Widget Sales</B:department>
+ <B:phone>234-4567</B:phone>
+ <B:office>209</B:office>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <B:salary/>
+ </D:prop>
+ <D:status>HTTP/1.1 403 Forbidden</D:status>
+ </D:propstat>
+ </D:response>
+ <D:response>
+ <D:href>http://www.example.com/users/zsmith</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:displayname>Zygdoebert Smith</D:displayname>
+ <B:department>Gadget Sales</B:department>
+ <B:phone>234-7654</B:phone>
+ <B:office>114</B:office>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <B:salary/>
+ </D:prop>
+ <D:status>HTTP/1.1 403 Forbidden</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ } else if (method == "PROPFIND" && request.path == "/principals/xpcshell/user/") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8"?>
+ <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <D:response>
+ <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <C:calendar-home-set>
+ <D:href>${this.uri("/calendars/xpcshell/user/").spec}</D:href>
+ </C:calendar-home-set>
+ <C:calendar-user-address-set>
+ <D:href>mailto:xpcshell@example.com</D:href>
+ </C:calendar-user-address-set>
+ <C:schedule-inbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/inbox").spec}/</D:href>
+ </C:schedule-inbox-URL>
+ <C:schedule-outbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/outbox").spec}</D:href>
+ </C:schedule-outbox-URL>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ }
+ }
+
+ calendars(request, response, method, headers, parameters, body) {
+ this.serverRequests.calendars = { method, headers, parameters, body };
+
+ if (
+ method == "PROPFIND" &&
+ request.path.startsWith("/calendars/xpcshell/events") &&
+ headers.get("depth") == 0
+ ) {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus ${CalDavXmlns("D", "C", "CS")} xmlns:R="http://www.foo.bar/boxschema/">
+ <D:response>
+ <D:href>${request.path}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:resourcetype>
+ <D:collection/>
+ <C:calendar/>
+ </D:resourcetype>
+ <R:plain-text-prop>hello, world</R:plain-text-prop>
+ <D:principal-collection-set>
+ <D:href>${this.uri("/principals/").spec}</D:href>
+ <D:href>${this.uri("/principals/subthing/").spec}</D:href>
+ </D:principal-collection-set>
+ <D:current-user-principal>
+ <D:href>${this.uri("/principals/xpcshell/user").spec}</D:href>
+ </D:current-user-principal>
+ <D:supported-report-set>
+ <D:supported-report>
+ <D:report>
+ <D:principal-property-search/>
+ </D:report>
+ </D:supported-report>
+ <D:supported-report>
+ <D:report>
+ <C:calendar-multiget/>
+ </D:report>
+ </D:supported-report>
+ <D:supported-report>
+ <D:report>
+ <D:sync-collection/>
+ </D:report>
+ </D:supported-report>
+ </D:supported-report-set>
+ <C:supported-calendar-component-set>
+ <C:comp name="VEVENT"/>
+ <C:comp name="VTODO"/>
+ </C:supported-calendar-component-set>
+ <C:schedule-inbox-URL>
+ <D:href>${this.uri("/calendars/xpcshell/inbox").spec}</D:href>
+ </C:schedule-inbox-URL>
+ <C:schedule-outbox-URL>
+ ${this.uri("/calendars/xpcshell/outbox").spec}
+ </C:schedule-outbox-URL>
+ <CS:getctag>1413647159-1007960</CS:getctag>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ <D:propstat>
+ <D:prop>
+ <R:obscure-thing-not-found/>
+ </D:prop>
+ <D:status>HTTP/1.1 404 Not Found</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ response.setStatusLine(null, 207, "Multistatus");
+ } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <C:schedule-response ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:href>mailto:recipient1@example.com</D:href>
+ <D:request-status>2.0;Success</D:request-status>
+ </D:response>
+ <D:response>
+ <D:href>mailto:recipient2@example.com</D:href>
+ <D:request-status>2.0;Success</D:request-status>
+ </D:response>
+ </C:schedule-response>
+ `);
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "POST" && request.path == "/calendars/xpcshell/outbox2") {
+ response.setHeader("Content-Type", "application/xml");
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <C:schedule-response ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:recipient>
+ <D:href>mailto:recipient1@example.com</D:href>
+ </D:recipient>
+ <D:request-status>2.0;Success</D:request-status>
+ <C:calendar-data>
+ BEGIN:VCALENDAR
+ PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+ VERSION:2.0
+ METHOD:REQUEST
+ BEGIN:VFREEBUSY
+ DTSTART;VALUE=DATE:20180102
+ DTEND;VALUE=DATE:20180126
+ ORGANIZER:mailto:xpcshell@example.com
+ ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mail
+ to:recipient@example.com
+ FREEBUSY;FBTYPE=FREE:20180103T010101Z/20180117T010101Z
+ FREEBUSY;FBTYPE=BUSY:20180118T010101Z/P7D
+ END:VFREEBUSY
+ END:VCALENDAR
+ </C:calendar-data>
+ </D:response>
+ </C:schedule-response>
+ `);
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "OPTIONS" && request.path == "/calendars/xpcshell/") {
+ response.setHeader(
+ "DAV",
+ "1, 2, 3, access-control, extended-mkcol, resource-sharing, calendar-access, calendar-auto-schedule, calendar-query-extended, calendar-availability, calendarserver-sharing, inbox-availability"
+ );
+ response.setStatusLine(null, 200, "OK");
+ } else if (method == "REPORT" && request.path == "/calendars/xpcshell/events/") {
+ response.setHeader("Content-Type", "application/xml");
+ let bodydom = cal.xml.parseString(body);
+ let report = bodydom.documentElement.localName;
+ let eventName = String.fromCharCode(...new TextEncoder().encode("γ‚€γƒ™γƒ³γƒˆ"));
+ if (report == "sync-collection") {
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8" ?>
+ <D:multistatus ${CalDavXmlns("D")}>
+ <D:response>
+ <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:getcontenttype>text/calendar; charset=utf-8; component=VEVENT</D:getcontenttype>
+ <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+ <D:displayname>${eventName}</D:displayname>
+ </D:prop>
+ <D:status>HTTP/1.1 200 OK</D:status>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ } else if (report == "calendar-multiget") {
+ let event = new CalEvent();
+ event.title = "会議";
+ event.startDate = cal.dtz.now();
+ event.endDate = cal.dtz.now();
+ let icalString = String.fromCharCode(...new TextEncoder().encode(event.icalString));
+ response.write(dedent`
+ <?xml version="1.0" encoding="utf-8"?>
+ <D:multistatus ${CalDavXmlns("D", "C")}>
+ <D:response>
+ <D:href>${this.uri("/calendars/xpcshell/events/test.ics").spec}</D:href>
+ <D:propstat>
+ <D:prop>
+ <D:getetag>"2decee6ffb701583398996bfbdacb8eec53edf94"</D:getetag>
+ <C:calendar-data>${icalString}</C:calendar-data>
+ </D:prop>
+ </D:propstat>
+ </D:response>
+ </D:multistatus>
+ `);
+ }
+ response.setStatusLine(null, 207, "Multistatus");
+ } else {
+ console.log("XXX: " + method, request.path, [...headers.entries()]);
+ }
+ }
+
+ requests(request, response, method, headers, parameters, body) {
+ // ["", "requests", "generic"] := /requests/generic
+ let parts = request.path.split("/");
+ let id = parts[2];
+ let status = parseInt(parts[3] || "", 10) || 200;
+
+ if (id == "redirected") {
+ response.setHeader("Location", "/requests/redirected-target", false);
+ status = 302;
+ } else if (id == "dav") {
+ response.setHeader("DAV", "1, calendar-schedule, calendar-auto-schedule");
+ }
+
+ this.serverRequests[id] = { method, headers, parameters, body };
+
+ for (let [hdr, value] of headers.entries()) {
+ response.setHeader(hdr, "response-" + value, false);
+ }
+
+ response.setHeader("Content-Type", "application/xml");
+ response.write(`<response id="${id}">xpc</response>`);
+ response.setStatusLine(null, status, null);
+ }
+}
+
+function run_test() {
+ Preferences.set("calendar.debug.log", true);
+ Preferences.set("calendar.debug.log.verbose", true);
+ cal.console.maxLogLevel = "debug";
+ replaceAlertsService();
+
+ // TODO: make do_calendar_startup to work with this test and replace the startup code here
+ do_get_profile();
+ do_test_pending();
+
+ cal.manager.startup({
+ onResult() {
+ gServer = new CalDavServer("xpcshell@example.com");
+ gServer.start();
+ cal.timezoneService.startup({
+ onResult() {
+ run_next_test();
+ do_test_finished();
+ },
+ });
+ },
+ });
+}
+
+add_task(async function test_caldav_session() {
+ gServer.reset();
+
+ let prepared = 0;
+ let redirected = 0;
+ let completed = 0;
+ let restart = false;
+
+ gServer.session.authAdapters.localhost = {
+ async prepareRequest(aChannel) {
+ prepared++;
+ },
+
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ redirected++;
+ },
+
+ async completeRequest(aResponse) {
+ completed++;
+ if (restart) {
+ restart = false;
+ return CalDavSession.RESTART_REQUEST;
+ }
+ return null;
+ },
+ };
+
+ // First a simple request
+ let uri = gServer.uri("/requests/session");
+ let request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 1);
+ equal(redirected, 0);
+ equal(completed, 1);
+
+ // Now a redirect
+ prepared = redirected = completed = 0;
+
+ uri = gServer.uri("/requests/redirected");
+ request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 1);
+ equal(redirected, 1);
+ equal(completed, 1);
+
+ // Now with restarting the request
+ prepared = redirected = completed = 0;
+ restart = true;
+
+ uri = gServer.uri("/requests/redirected");
+ request = new CalDavGenericRequest(gServer.session, gMockCalendar, "HEAD", uri);
+ await request.commit();
+
+ equal(prepared, 2);
+ equal(redirected, 2);
+ equal(completed, 2);
+});
+
+/**
+ * This test covers both GenericRequest and the base class CalDavRequestBase/CalDavResponseBase
+ */
+add_task(async function test_generic_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/generic");
+ let headers = { "X-Hdr": "exists" };
+ let request = new CalDavGenericRequest(
+ gServer.session,
+ gMockCalendar,
+ "PUT",
+ uri,
+ headers,
+ "<body>xpc</body>",
+ "text/plain"
+ );
+
+ strictEqual(request.uri.spec, uri.spec);
+ strictEqual(request.session.id, gServer.session.id);
+ strictEqual(request.calendar, gMockCalendar);
+ strictEqual(request.uploadData, "<body>xpc</body>");
+ strictEqual(request.contentType, "text/plain");
+ strictEqual(request.response, null);
+ strictEqual(request.getHeader("X-Hdr"), null); // Only works after commit
+
+ let response = await request.commit();
+
+ ok(!!request.response);
+ equal(request.getHeader("X-Hdr"), "exists");
+
+ equal(response.uri.spec, uri.spec);
+ ok(!response.redirected);
+ equal(response.status, 200);
+ equal(response.statusCategory, 2);
+ ok(response.ok);
+ ok(!response.clientError);
+ ok(!response.conflict);
+ ok(!response.notFound);
+ ok(!response.serverError);
+ equal(response.text, '<response id="generic">xpc</response>');
+ equal(response.xml.documentElement.localName, "response");
+ equal(response.getHeader("X-Hdr"), "response-exists");
+
+ let serverResult = gServer.serverRequests.generic;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.headers.get("x-hdr"), "exists");
+ equal(serverResult.headers.get("content-type"), "text/plain");
+ equal(serverResult.body, "<body>xpc</body>");
+});
+
+add_task(async function test_generic_redirected_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/redirected");
+ let headers = {
+ Depth: 1,
+ Originator: "o",
+ Recipient: "r",
+ "If-None-Match": "*",
+ "If-Match": "123",
+ };
+ let request = new CalDavGenericRequest(
+ gServer.session,
+ gMockCalendar,
+ "PUT",
+ uri,
+ headers,
+ "<body>xpc</body>",
+ "text/plain"
+ );
+
+ let response = await request.commit();
+
+ ok(response.redirected);
+ equal(response.status, 200);
+ equal(response.text, '<response id="redirected-target">xpc</response>');
+ equal(response.xml.documentElement.getAttribute("id"), "redirected-target");
+
+ ok(gServer.serverRequests.redirected);
+ ok(gServer.serverRequests["redirected-target"]);
+
+ let results = gServer.serverRequests.redirected;
+ equal(results.headers.get("Depth"), 1);
+ equal(results.headers.get("Originator"), "o");
+ equal(results.headers.get("Recipient"), "r");
+ equal(results.headers.get("If-None-Match"), "*");
+ equal(results.headers.get("If-Match"), "123");
+
+ results = gServer.serverRequests["redirected-target"];
+ equal(results.headers.get("Depth"), 1);
+ equal(results.headers.get("Originator"), "o");
+ equal(results.headers.get("Recipient"), "r");
+ equal(results.headers.get("If-None-Match"), "*");
+ equal(results.headers.get("If-Match"), "123");
+
+ equal(response.lastRedirectStatus, 302);
+});
+
+add_task(async function test_item_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/item/201");
+ let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+ let componentString = `BEGIN:VCALENDAR\r\nPRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN\r\nVERSION:2.0\r\n${icalString}\r\nEND:VCALENDAR\r\n`;
+ let request = new CalDavItemRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ new CalEvent(icalString),
+ "*"
+ );
+ let response = await request.commit();
+
+ equal(response.status, 201);
+ ok(response.ok);
+
+ let serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ equal(serverResult.headers.get("If-None-Match"), "*");
+ ok(!serverResult.headers.has("If-Match"));
+
+ // Now the same with 204 No Content and an etag
+ gServer.reset();
+ uri = gServer.uri("/requests/item/204");
+ request = new CalDavItemRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ new CalEvent(icalString),
+ "123123"
+ );
+ response = await request.commit();
+
+ equal(response.status, 204);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ equal(serverResult.headers.get("If-Match"), "123123");
+ ok(!serverResult.headers.has("If-None-Match"));
+
+ // Now the same with 200 OK and no etag
+ gServer.reset();
+ uri = gServer.uri("/requests/item/200");
+ request = new CalDavItemRequest(gServer.session, gMockCalendar, uri, new CalEvent(icalString));
+ response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.item;
+
+ equal(serverResult.method, "PUT");
+ equal(serverResult.body, componentString);
+ ok(!serverResult.headers.has("If-Match"));
+ ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_delete_item_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/deleteitem");
+ let request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri, "*");
+
+ strictEqual(request.uploadData, null);
+ strictEqual(request.contentType, null);
+
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let serverResult = gServer.serverRequests.deleteitem;
+
+ equal(serverResult.method, "DELETE");
+ equal(serverResult.headers.get("If-Match"), "*");
+ ok(!serverResult.headers.has("If-None-Match"));
+
+ // Now the same with no etag, and a (valid) 404 response
+ gServer.reset();
+ uri = gServer.uri("/requests/deleteitem/404");
+ request = new CalDavDeleteItemRequest(gServer.session, gMockCalendar, uri);
+ response = await request.commit();
+
+ equal(response.status, 404);
+ ok(response.ok);
+
+ serverResult = gServer.serverRequests.deleteitem;
+
+ equal(serverResult.method, "DELETE");
+ ok(!serverResult.headers.has("If-Match"));
+ ok(!serverResult.headers.has("If-None-Match"));
+});
+
+add_task(async function test_propfind_request() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/events");
+ let props = [
+ "D:principal-collection-set",
+ "D:current-user-principal",
+ "D:supported-report-set",
+ "C:supported-calendar-component-set",
+ "C:schedule-inbox-URL",
+ "C:schedule-outbox-URL",
+ "R:obscure-thing-not-found",
+ ];
+ let request = new CalDavPropfindRequest(gServer.session, gMockCalendar, uri, props);
+ let response = await request.commit();
+
+ equal(response.status, 207);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+
+ ok(
+ results.body.match(/<D:prop>\s*<D:principal-collection-set\/>\s*<D:current-user-principal\/>/)
+ );
+
+ equal(Object.keys(response.data).length, 1);
+ ok(!!response.data[uri.filePath]);
+ ok(!!response.firstProps);
+
+ let resprops = response.firstProps;
+
+ deepEqual(resprops["D:principal-collection-set"], [
+ gServer.uri("/principals/").spec,
+ gServer.uri("/principals/subthing/").spec,
+ ]);
+ equal(resprops["D:current-user-principal"], gServer.uri("/principals/xpcshell/user").spec);
+
+ deepEqual(
+ [...resprops["D:supported-report-set"].values()],
+ ["D:principal-property-search", "C:calendar-multiget", "D:sync-collection"]
+ );
+
+ deepEqual([...resprops["C:supported-calendar-component-set"].values()], ["VEVENT", "VTODO"]);
+ equal(resprops["C:schedule-inbox-URL"], gServer.uri("/calendars/xpcshell/inbox").spec);
+ equal(resprops["C:schedule-outbox-URL"], gServer.uri("/calendars/xpcshell/outbox").spec);
+ strictEqual(resprops["R:obscure-thing-not-found"], null);
+ equal(resprops["R:plain-text-prop"], "hello, world");
+});
+
+add_task(async function test_davheader_request() {
+ gServer.reset();
+ let uri = gServer.uri("/requests/dav");
+ let request = new CalDavHeaderRequest(gServer.session, gMockCalendar, uri);
+ let response = await request.commit();
+
+ let serverResult = gServer.serverRequests.dav;
+
+ equal(serverResult.method, "OPTIONS");
+ deepEqual([...response.features], ["calendar-schedule", "calendar-auto-schedule"]);
+ strictEqual(response.version, 1);
+});
+
+add_task(async function test_propsearch_request() {
+ gServer.reset();
+ let uri = gServer.uri("/principals/");
+ let props = ["D:displayname", "B:department", "B:phone", "B:office"];
+ let request = new CalDavPrincipalPropertySearchRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "doE",
+ "D:displayname",
+ props
+ );
+ let response = await request.commit();
+
+ equal(response.status, 207);
+ ok(response.ok);
+
+ equal(response.data["http://www.example.com/users/jdoe"]["D:displayname"], "John Doe");
+
+ ok(gServer.serverRequests.principals.body.includes("<D:match>doE</D:match>"));
+ ok(gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<\/D:prop>/));
+ ok(
+ gServer.serverRequests.principals.body.match(/<D:prop>\s*<D:displayname\/>\s*<B:department\/>/)
+ );
+});
+
+add_task(async function test_outbox_request() {
+ gServer.reset();
+ let icalString = "BEGIN:VEVENT\r\nUID:123\r\nEND:VEVENT";
+ let uri = gServer.uri("/calendars/xpcshell/outbox");
+ let request = new CalDavOutboxRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "xpcshell@example.com",
+ ["recipient1@example.com", "recipient2@example.com"],
+ "REPLY",
+ new CalEvent(icalString)
+ );
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+
+ ok(results.body.includes("METHOD:REPLY"));
+ equal(results.method, "POST");
+ equal(results.headers.get("Originator"), "xpcshell@example.com");
+ equal(results.headers.get("Recipient"), "recipient1@example.com, recipient2@example.com");
+});
+
+add_task(async function test_freebusy_request() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/outbox2");
+ let request = new CalDavFreeBusyRequest(
+ gServer.session,
+ gMockCalendar,
+ uri,
+ "mailto:xpcshell@example.com",
+ "mailto:recipient@example.com",
+ cal.createDateTime("20180101"),
+ cal.createDateTime("20180201")
+ );
+
+ let response = await request.commit();
+
+ equal(response.status, 200);
+ ok(response.ok);
+
+ let results = gServer.serverRequests.calendars;
+ equal(
+ ics_unfoldline(
+ results.body
+ .replace(/\r\n/g, "\n")
+ .replace(/(UID|DTSTAMP):[^\n]+\n/g, "")
+ .trim()
+ ),
+ dedent`
+ BEGIN:VCALENDAR
+ PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+ VERSION:2.0
+ METHOD:REQUEST
+ BEGIN:VFREEBUSY
+ DTSTART;VALUE=DATE:20180101
+ DTEND;VALUE=DATE:20180201
+ ORGANIZER:mailto:xpcshell@example.com
+ ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL:mailto:recipient@example.com
+ END:VFREEBUSY
+ END:VCALENDAR
+ `
+ );
+ equal(results.method, "POST");
+ equal(results.headers.get("Content-Type"), "text/calendar; charset=utf-8");
+ equal(results.headers.get("Originator"), "mailto:xpcshell@example.com");
+ equal(results.headers.get("Recipient"), "mailto:recipient@example.com");
+
+ let first = response.firstRecipient;
+ equal(first.status, "2.0;Success");
+ deepEqual(
+ first.intervals.map(interval => interval.type),
+ ["UNKNOWN", "FREE", "BUSY", "UNKNOWN"]
+ );
+ deepEqual(
+ first.intervals.map(interval => interval.begin.icalString + ":" + interval.end.icalString),
+ [
+ "20180101:20180102",
+ "20180103T010101Z:20180117T010101Z",
+ "20180118T010101Z:20180125T010101Z",
+ "20180126:20180201",
+ ]
+ );
+});
+
+add_task(async function test_caldav_client() {
+ let client = await gServer.getClient();
+ let items = await client.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null);
+
+ equal(items.length, 1);
+ equal(items[0].title, "会議");
+});
+
+/**
+ * Test non-ASCII text in the XML response is parsed correctly in CalDavWebDavSyncHandler.
+ */
+add_task(async function test_caldav_sync() {
+ gServer.reset();
+ let uri = gServer.uri("/calendars/xpcshell/events/");
+ gMockCalendar.session = gServer.session;
+ let webDavSync = new CalDavWebDavSyncHandler(gMockCalendar, uri);
+ await webDavSync.doWebDAVSync();
+ ok(webDavSync.logXML.includes("γ‚€γƒ™γƒ³γƒˆ"), "Non-ASCII text should be parsed correctly");
+});
+
+add_task(function test_can_get_google_adapter() {
+ // Initialize a session with bogus values
+ const session = new CalDavSession("xpcshell@example.com", "xpcshell");
+
+ // We don't have a facility for actually testing our Google CalDAV requests,
+ // but we can at least verify that the adapter looks okay at a glance
+ equal(
+ session.authAdapters["apidata.googleusercontent.com"].authorizationEndpoint,
+ "https://accounts.google.com/o/oauth2/auth"
+ );
+});
diff --git a/comm/calendar/test/unit/test_calmgr.js b/comm/calendar/test/unit/test_calmgr.js
new file mode 100644
index 0000000000..e4d09db0a3
--- /dev/null
+++ b/comm/calendar/test/unit/test_calmgr.js
@@ -0,0 +1,411 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+/**
+ * Tests the calICalendarManager interface
+ */
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+class CalendarManagerObserver {
+ QueryInterface = ChromeUtils.generateQI(["calICalendarManager"]);
+
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this.registered = [];
+ this.unregistering = [];
+ this.deleting = [];
+ }
+
+ check({ unregistering, registered, deleting }) {
+ equal(this.unregistering[0], unregistering);
+ equal(this.registered[0], registered);
+ equal(this.deleting[0], deleting);
+
+ this.reset();
+ }
+
+ onCalendarRegistered(calendar) {
+ this.registered.push(calendar.id);
+ }
+
+ onCalendarUnregistering(calendar) {
+ this.unregistering.push(calendar.id);
+ }
+
+ onCalendarDeleting(calendar) {
+ this.deleting.push(calendar.id);
+ }
+}
+
+add_test(function test_builtin_registration() {
+ function checkCalendarCount(net, rdonly, all) {
+ equal(cal.manager.networkCalendarCount, net);
+ equal(cal.manager.readOnlyCalendarCount, rdonly);
+ equal(cal.manager.calendarCount, all);
+ }
+
+ // Initially there should be no calendars.
+ checkCalendarCount(0, 0, 0);
+
+ // Create a local memory calendar, this shouldn't register any calendars.
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ checkCalendarCount(0, 0, 0);
+
+ // Register an observer to test it.
+ let calmgrObserver = new CalendarManagerObserver();
+
+ let readOnly = false;
+ let calendarObserver = cal.createAdapter(Ci.calIObserver, {
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ equal(aCalendar.id, memory.id);
+ equal(aName, "readOnly");
+ readOnly = aValue;
+ },
+ });
+
+ memory.addObserver(calendarObserver);
+ cal.manager.addObserver(calmgrObserver);
+
+ // Register the calendar and check if its counted and observed.
+ cal.manager.registerCalendar(memory);
+ calmgrObserver.check({ registered: memory.id });
+ checkCalendarCount(0, 0, 1);
+
+ // The calendar should now have an id.
+ notEqual(memory.id, null);
+
+ // And be in the list of calendars.
+ equal(memory, cal.manager.getCalendarById(memory.id));
+ ok(cal.manager.getCalendars().some(x => x.id == memory.id));
+
+ // Make it readonly and check if the observer caught it.
+ memory.setProperty("readOnly", true);
+ equal(readOnly, true);
+
+ // Now unregister it.
+ cal.manager.unregisterCalendar(memory);
+ calmgrObserver.check({ unregistering: memory.id });
+ checkCalendarCount(0, 0, 0);
+
+ // The calendar shouldn't be in the list of ids.
+ equal(cal.manager.getCalendarById(memory.id), null);
+ ok(cal.manager.getCalendars().every(x => x.id != memory.id));
+
+ // And finally delete it.
+ cal.manager.removeCalendar(memory, Ci.calICalendarManager.REMOVE_NO_UNREGISTER);
+ calmgrObserver.check({ deleting: memory.id });
+ checkCalendarCount(0, 0, 0);
+
+ // Now remove the observer again.
+ cal.manager.removeObserver(calmgrObserver);
+ memory.removeObserver(calendarObserver);
+
+ // Check if removing it actually worked.
+ cal.manager.registerCalendar(memory);
+ cal.manager.removeCalendar(memory);
+ memory.setProperty("readOnly", false);
+ calmgrObserver.check({});
+ equal(readOnly, true);
+ checkCalendarCount(0, 0, 0);
+
+ // We are done now, start the next test.
+ run_next_test();
+});
+
+add_task(async function test_dynamic_registration() {
+ class CalendarProvider extends cal.provider.BaseClass {
+ QueryInterface = ChromeUtils.generateQI(["calICalendar"]);
+ type = "blm";
+
+ constructor() {
+ super();
+ this.initProviderBase();
+ }
+
+ getItems(itemFilter, count, rangeStart, rangeEnd, listener) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ }
+
+ function checkCalendar(expectedCount = 1) {
+ let calendars = cal.manager.getCalendars();
+ equal(calendars.length, expectedCount);
+ let calendar = calendars[0];
+
+ if (expectedCount > 0) {
+ notEqual(calendar, null);
+ }
+ return calendar;
+ }
+
+ let calmgrObserver = new CalendarManagerObserver();
+ cal.manager.addObserver(calmgrObserver);
+ equal(cal.manager.calendarCount, 0);
+
+ // No provider registered.
+ let calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://"));
+ equal(calendar, null);
+ ok(!cal.manager.hasCalendarProvider("blm"));
+
+ // Register dynamic provider.
+ cal.manager.registerCalendarProvider("blm", CalendarProvider);
+ calendar = cal.manager.createCalendar("blm", Services.io.newURI("black-lives-matter://"));
+ notEqual(calendar, null);
+ ok(calendar.wrappedJSObject instanceof CalendarProvider);
+ ok(cal.manager.hasCalendarProvider("blm"));
+
+ // Register a calendar using it.
+ cal.manager.registerCalendar(calendar);
+ calendar = checkCalendar();
+
+ let originalId = calendar.id;
+ calmgrObserver.check({ registered: originalId });
+
+ // Unregister the provider from under its feet.
+ cal.manager.unregisterCalendarProvider("blm");
+ calendar = checkCalendar();
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ equal(calendar.type, "blm");
+ equal(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Re-register the provider should reactive it.
+ cal.manager.registerCalendarProvider("blm", CalendarProvider);
+ calendar = checkCalendar();
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ equal(calendar.type, "blm");
+ notEqual(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Make sure calendar is loaded from prefs.
+ cal.manager.unregisterCalendarProvider("blm");
+ calmgrObserver.check({ unregistering: originalId, registered: originalId });
+
+ await new Promise(resolve => cal.manager.shutdown({ onResult: resolve }));
+ cal.manager.wrappedJSObject.mCache = null;
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+ calmgrObserver.check({});
+
+ calendar = checkCalendar();
+ equal(calendar.type, "blm");
+ equal(calendar.getProperty("force-disabled"), true);
+ equal(calendar.id, originalId);
+
+ // Unregister the calendar for cleanup.
+ cal.manager.unregisterCalendar(calendar);
+ checkCalendar(0);
+ calmgrObserver.check({ unregistering: originalId });
+});
+
+add_test(function test_calobserver() {
+ function checkCounters(add, modify, del, alladd, allmodify, alldel) {
+ equal(calcounter.addItem, add);
+ equal(calcounter.modifyItem, modify);
+ equal(calcounter.deleteItem, del);
+ equal(allcounter.addItem, alladd === undefined ? add : alladd);
+ equal(allcounter.modifyItem, allmodify === undefined ? modify : allmodify);
+ equal(allcounter.deleteItem, alldel === undefined ? del : alldel);
+ resetCounters();
+ }
+ function resetCounters() {
+ calcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 };
+ allcounter = { addItem: 0, modifyItem: 0, deleteItem: 0 };
+ }
+
+ // First of all we need a local calendar to work on and some variables
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let memory2 = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let calcounter, allcounter;
+
+ // These observers will end up counting calls which we will use later on
+ let calobs = cal.createAdapter(Ci.calIObserver, {
+ onAddItem: () => calcounter.addItem++,
+ onModifyItem: () => calcounter.modifyItem++,
+ onDeleteItem: () => calcounter.deleteItem++,
+ });
+ let allobs = cal.createAdapter(Ci.calIObserver, {
+ onAddItem: () => allcounter.addItem++,
+ onModifyItem: () => allcounter.modifyItem++,
+ onDeleteItem: () => allcounter.deleteItem++,
+ });
+
+ // Set up counters and observers
+ resetCounters();
+ cal.manager.registerCalendar(memory);
+ cal.manager.registerCalendar(memory2);
+ cal.manager.addCalendarObserver(allobs);
+ memory.addObserver(calobs);
+
+ // Add an item
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.startDate = cal.dtz.now();
+ item.endDate = cal.dtz.now();
+ memory.addItem(item);
+ checkCounters(1, 0, 0);
+
+ // Modify the item
+ let newItem = item.clone();
+ newItem.title = "title";
+ memory.modifyItem(newItem, item);
+ checkCounters(0, 1, 0);
+
+ // Delete the item
+ newItem.generation++; // circumvent generation checks for easier code
+ memory.deleteItem(newItem);
+ checkCounters(0, 0, 1);
+
+ // Now check the same for adding the item to a calendar only observed by the
+ // calendar manager. The calcounters should still be 0, but the calendar
+ // manager counter should have an item added, modified and deleted
+ memory2.addItem(item);
+ memory2.modifyItem(newItem, item);
+ memory2.deleteItem(newItem);
+ checkCounters(0, 0, 0, 1, 1, 1);
+
+ // Remove observers
+ memory.removeObserver(calobs);
+ cal.manager.removeCalendarObserver(allobs);
+
+ // Make sure removing it actually worked
+ memory.addItem(item);
+ memory.modifyItem(newItem, item);
+ memory.deleteItem(newItem);
+ checkCounters(0, 0, 0);
+
+ // We are done now, start the next test
+ run_next_test();
+});
+
+add_test(function test_removeModes() {
+ function checkCounts(modes, shouldDelete, expectCount, extraFlags = 0) {
+ if (cal.manager.calendarCount == baseCalendarCount) {
+ cal.manager.registerCalendar(memory);
+ equal(cal.manager.calendarCount, baseCalendarCount + 1);
+ }
+ deleteCalled = false;
+ removeModes = modes;
+
+ cal.manager.removeCalendar(memory, extraFlags);
+ equal(cal.manager.calendarCount, baseCalendarCount + expectCount);
+ equal(deleteCalled, shouldDelete);
+ }
+ function mockCalendar(memory) {
+ let oldGetProperty = memory.wrappedJSObject.getProperty;
+ memory.wrappedJSObject.getProperty = function (name) {
+ if (name == "capabilities.removeModes") {
+ return removeModes;
+ }
+ return oldGetProperty.apply(this, arguments);
+ };
+
+ let oldDeleteCalendar = memory.wrappedJSObject.deleteCalendar;
+ memory.wrappedJSObject.deleteCalendar = function (calendar, listener) {
+ deleteCalled = true;
+ return oldDeleteCalendar.apply(this, arguments);
+ };
+ }
+
+ // For better readability
+ const SHOULD_DELETE = true,
+ SHOULD_NOT_DELETE = false;
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ let baseCalendarCount = cal.manager.calendarCount;
+ let removeModes = null;
+ let deleteCalled = false;
+
+ mockCalendar(memory);
+
+ checkCounts([], SHOULD_NOT_DELETE, 1);
+ checkCounts(["unsubscribe"], SHOULD_NOT_DELETE, 0);
+ checkCounts(["unsubscribe", "delete"], SHOULD_DELETE, 0);
+ checkCounts(
+ ["unsubscribe", "delete"],
+ SHOULD_NOT_DELETE,
+ 0,
+ Ci.calICalendarManager.REMOVE_NO_DELETE
+ );
+ checkCounts(["delete"], SHOULD_DELETE, 0);
+
+ run_next_test();
+});
+
+add_test(function test_calprefs() {
+ let prop;
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ cal.manager.registerCalendar(memory);
+ let memid = memory.id;
+
+ // First set a few values, one of each relevant type
+ memory.setProperty("stringpref", "abc");
+ memory.setProperty("boolpref", true);
+ memory.setProperty("intpref", 123);
+ memory.setProperty("bigintpref", 1394548721296);
+ memory.setProperty("floatpref", 0.5);
+
+ // Before checking the value, reinitialize the memory calendar with the
+ // same id to make sure the pref value isn't just cached
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+
+ // First test the standard types
+ prop = memory.getProperty("stringpref");
+ equal(typeof prop, "string");
+ equal(prop, "abc");
+
+ prop = memory.getProperty("boolpref");
+ equal(typeof prop, "boolean");
+ equal(prop, true);
+
+ prop = memory.getProperty("intpref");
+ equal(typeof prop, "number");
+ equal(prop, 123);
+
+ // These two are a special case test for bug 979262
+ prop = memory.getProperty("bigintpref");
+ equal(typeof prop, "number");
+ equal(prop, 1394548721296);
+
+ prop = memory.getProperty("floatpref");
+ equal(typeof prop, "number");
+ equal(prop, 0.5);
+
+ // Check if changing pref types works. We need to reset the calendar again
+ // because retrieving the value just cached it again.
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+
+ cal.manager.setCalendarPref_(memory, "boolpref", "kinda true");
+ prop = memory.getProperty("boolpref");
+ equal(typeof prop, "string");
+ equal(prop, "kinda true");
+
+ // Check if unsetting a pref works
+ memory.setProperty("intpref", null);
+ memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-memory-calendar://"));
+ memory.id = memid;
+ prop = memory.getProperty("intpref");
+ ok(prop === null);
+
+ // We are done now, start the next test
+ run_next_test();
+});
diff --git a/comm/calendar/test/unit/test_calreadablestreamfactory.js b/comm/calendar/test/unit/test_calreadablestreamfactory.js
new file mode 100644
index 0000000000..9da71e47ef
--- /dev/null
+++ b/comm/calendar/test/unit/test_calreadablestreamfactory.js
@@ -0,0 +1,195 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the ReadableStreams generated by CalReadableStreamFactory.
+ */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * @type {object} BoundedReadableStreamTestSpec
+ * @property {number} maxTotalItems
+ * @property {number} maxQueuedItems
+ * @property {number} actualTotalItems
+ * @property {number} actualChunkSize
+ * @property {Function} onChunk
+ */
+
+/**
+ * Common test for the BoundedReadableStream.
+ *
+ * @param {BoundedReadableStreamTestSpec} spec
+ */
+async function doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems,
+ actualChunkSize,
+ onChunk,
+}) {
+ let totalChunks = Math.ceil(actualTotalItems / actualChunkSize);
+ let stream = CalReadableStreamFactory.createBoundedReadableStream(maxTotalItems, maxQueuedItems, {
+ start(controller) {
+ let i = 0;
+ for (i; i < totalChunks; i++) {
+ controller.enqueue(
+ Array(actualChunkSize)
+ .fill(null)
+ .map(() => new CalEvent())
+ );
+ }
+ info(
+ `Enqueued ${
+ i * actualChunkSize
+ } items across ${i} chunks at a rate of ${actualChunkSize} items per chunk`
+ );
+ },
+ });
+
+ for await (let chunk of cal.iterate.streamValues(stream)) {
+ Assert.ok(Array.isArray(chunk), "chunk received is an array");
+ Assert.ok(
+ chunk.every(item => item instanceof CalEvent),
+ "all chunk elements are CalEvent instances"
+ );
+ onChunk(chunk);
+ }
+}
+
+/**
+ * Tests the BoundedReadableStream works as expected when the total items enqueued
+ * and the chunk size match the limits set.
+ */
+add_task(async function testBoundedReadableStreamWorksWithinLimits() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let totalChunks = 35 / 5;
+
+ let chunksRead = 0;
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: maxTotalItems,
+ actualChunkSize: maxQueuedItems,
+ onChunk(chunk) {
+ Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`);
+ chunksRead++;
+ },
+ });
+ Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`);
+});
+
+/**
+ * Tests that the stream automatically closes when maxTotalItemsReached is true
+ * even if there are more items to come.
+ */
+add_task(async function testBoundedReadableStreamClosesIfMaxTotalItemsReached() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let items = [];
+
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: 50,
+ actualChunkSize: 7,
+ onChunk(chunk) {
+ items = items.concat(chunk);
+ },
+ });
+ Assert.equal(items.length, maxTotalItems, `received ${maxTotalItems} items from stream`);
+});
+
+/**
+ * Test that chunks enqueued with smaller than the maxQueueSize value are held
+ * until the threshold is reached.
+ */
+add_task(async function testBoundedReadableStreamBuffersChunks() {
+ let maxTotalItems = 35;
+ let maxQueuedItems = 5;
+ let totalChunks = 35 / 5;
+
+ let chunksRead = 0;
+ await doBoundedReadableStreamTest({
+ maxTotalItems,
+ maxQueuedItems,
+ actualTotalItems: 35,
+ actualChunkSize: 1,
+ onChunk(chunk) {
+ Assert.equal(chunk.length, maxQueuedItems, `chunk has ${maxQueuedItems} items`);
+ chunksRead++;
+ },
+ });
+ Assert.equal(chunksRead, totalChunks, `received ${totalChunks} chunks from stream`);
+});
+
+/**
+ * Test the CombinedReadbleStream streams from all of its streams.
+ */
+add_task(async function testCombinedReadableStreamStreamsAll() {
+ let mkStream = () =>
+ CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < 5; i++) {
+ controller.enqueue(new CalEvent());
+ }
+ controller.close();
+ },
+ });
+
+ let stream = CalReadableStreamFactory.createCombinedReadableStream([
+ mkStream(),
+ mkStream(),
+ mkStream(),
+ ]);
+
+ let items = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ Assert.ok(value instanceof CalEvent, "value read from stream is CalEvent instance");
+ items.push(value);
+ }
+ Assert.equal(items.length, 15, "read a total of 15 items from the stream");
+});
+
+/**
+ * Test the MappedReadableStream applies the MapStreamFunction to each value
+ * read from the stream.
+ */
+add_task(async function testMappedReadableStream() {
+ let stream = CalReadableStreamFactory.createMappedReadableStream(
+ CalReadableStreamFactory.createReadableStream({
+ start(controller) {
+ for (let i = 0; i < 10; i++) {
+ controller.enqueue(1);
+ }
+ controller.close();
+ },
+ }),
+ value => value * 0
+ );
+
+ let values = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ Assert.equal(value, 0, "read value inverted to 0");
+ values.push(value);
+ }
+ Assert.equal(values.length, 10, "all 10 values were transformed");
+});
+
+/**
+ * Test the EmptyReadableStream is already closed.
+ */
+add_task(async function testEmptyReadableStream() {
+ let stream = CalReadableStreamFactory.createEmptyReadableStream();
+ let values = [];
+ for await (let value of cal.iterate.streamValues(stream)) {
+ values.push(value);
+ }
+ Assert.equal(values.length, 0, "no values were read from the empty stream");
+});
diff --git a/comm/calendar/test/unit/test_data_bags.js b/comm/calendar/test/unit/test_data_bags.js
new file mode 100644
index 0000000000..dd1f2a6abd
--- /dev/null
+++ b/comm/calendar/test/unit/test_data_bags.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ test_listener_set();
+ test_observer_set();
+ test_operation_group();
+}
+
+function test_listener_set() {
+ let set = new cal.data.ListenerSet(Ci.calIOperationListener);
+ let listener1Id = null;
+ let listener2Id = null;
+
+ let listener1 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ listener1Id = aId;
+ },
+ });
+ let listener2 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ listener2Id = aId;
+ },
+ });
+
+ set.add(listener1);
+ set.add(listener2);
+ set.notify("onOperationComplete", [null, null, null, "test", null]);
+ equal(listener1Id, "test");
+ equal(listener2Id, "test");
+
+ set.delete(listener2);
+ listener1Id = listener2Id = null;
+ set.notify("onOperationComplete", [null, null, null, "test2", null]);
+ equal(listener1Id, "test2");
+ strictEqual(listener2Id, null);
+
+ // Re-adding the listener may lead to an endless loop if the notify
+ // function uses a live list of observers.
+ let called = 0;
+ let listener3 = cal.createAdapter("calIOperationListener", {
+ onOperationComplete(aCalendar, aStatus, aOpType, aId, aDetail) {
+ set.delete(listener3);
+ if (called == 0) {
+ set.add(listener3);
+ }
+ called++;
+ },
+ });
+
+ set.add(listener3);
+ set.notify("onOperationComplete", [null, null, null, "test3", null]);
+ equal(called, 1);
+}
+
+function test_observer_set() {
+ let set = new cal.data.ObserverSet(Ci.calIObserver);
+ let listenerCountBegin1 = 0;
+ let listenerCountBegin2 = 0;
+ let listenerCountEnd1 = 0;
+ let listenerCountEnd2 = 0;
+
+ let listener1 = cal.createAdapter("calIObserver", {
+ onStartBatch() {
+ listenerCountBegin1++;
+ },
+ onEndBatch() {
+ listenerCountEnd1++;
+ },
+ });
+ let listener2 = cal.createAdapter("calIObserver", {
+ onStartBatch() {
+ listenerCountBegin2++;
+ },
+ onEndBatch() {
+ listenerCountEnd2++;
+ },
+ });
+
+ set.add(listener1);
+ equal(listenerCountBegin1, 0);
+ equal(listenerCountEnd1, 0);
+ equal(set.batchCount, 0);
+
+ set.notify("onStartBatch");
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(set.batchCount, 1);
+
+ set.add(listener2);
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 0);
+ equal(set.batchCount, 1);
+
+ set.add(listener1);
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 0);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 0);
+ equal(set.batchCount, 1);
+
+ set.notify("onEndBatch");
+ equal(listenerCountBegin1, 1);
+ equal(listenerCountEnd1, 1);
+ equal(listenerCountBegin2, 1);
+ equal(listenerCountEnd2, 1);
+ equal(set.batchCount, 0);
+}
+
+function test_operation_group() {
+ let calledCancel = false;
+ let calledOperationCancel = null;
+ let group = new cal.data.OperationGroup();
+ ok(group.id.endsWith("-0"));
+ ok(group.isPending);
+ equal(group.status, Cr.NS_OK);
+ ok(group.isEmpty);
+
+ let operation = {
+ id: 123,
+ isPending: true,
+ cancel: status => {
+ calledOperationCancel = status;
+ },
+ };
+
+ group.add(operation);
+ ok(!group.isEmpty);
+
+ group.notifyCompleted(Cr.NS_ERROR_FAILURE);
+ ok(!group.isPending);
+ equal(group.status, Cr.NS_ERROR_FAILURE);
+ strictEqual(calledOperationCancel, null);
+
+ group.remove(operation);
+ ok(group.isEmpty);
+
+ group = new cal.data.OperationGroup(() => {
+ calledCancel = true;
+ });
+ ok(group.id.endsWith("-1"));
+ group.add(operation);
+
+ group.cancel();
+ equal(group.status, Ci.calIErrors.OPERATION_CANCELLED);
+ equal(calledOperationCancel, Ci.calIErrors.OPERATION_CANCELLED);
+ ok(calledCancel);
+}
diff --git a/comm/calendar/test/unit/test_datetime.js b/comm/calendar/test/unit/test_datetime.js
new file mode 100644
index 0000000000..a754e570a8
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetime.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ function getMozTimezone(tzid) {
+ return cal.timezoneService.getTimezone(tzid);
+ }
+
+ let date = cal.createDateTime();
+ date.resetTo(2005, 10, 13, 10, 0, 0, getMozTimezone("/mozilla.org/20050126_1/America/Bogota"));
+
+ equal(date.hour, 10);
+ equal(date.icalString, "20051113T100000");
+
+ let date_floating = date.getInTimezone(cal.dtz.floating);
+ equal(date_floating.hour, 10);
+
+ let date_utc = date.getInTimezone(cal.dtz.UTC);
+ equal(date_utc.hour, 15);
+ equal(date_utc.icalString, "20051113T150000Z");
+
+ date.hour = 25;
+ equal(date.hour, 1);
+ equal(date.day, 14);
+
+ // Test nativeTime on dates
+ // setting .isDate to be true on a date should not change its nativeTime
+ // bug 315954,
+ date.hour = 0;
+ let date_allday = date.clone();
+ date_allday.isDate = true;
+ equal(date.nativeTime, date_allday.nativeTime);
+
+ // Daylight savings test
+ date.resetTo(2006, 2, 26, 1, 0, 0, getMozTimezone("/mozilla.org/20050126_1/Europe/Amsterdam"));
+
+ equal(date.weekday, 0);
+ equal(date.timezoneOffset, 1 * 3600);
+
+ date.day += 1;
+ equal(date.timezoneOffset, 2 * 3600);
+
+ // Bug 398724 - Problems with floating all-day items
+ let event = new CalEvent(
+ "BEGIN:VEVENT\nUID:45674d53-229f-48c6-9f3b-f2b601e7ae4d\nSUMMARY:New Event\nDTSTART;VALUE=DATE:20071003\nDTEND;VALUE=DATE:20071004\nEND:VEVENT"
+ );
+ ok(event.startDate.timezone.isFloating);
+ ok(event.endDate.timezone.isFloating);
+
+ // Bug 392853 - Same times, different timezones, but subtractDate says times are PT0S apart
+ const zeroLength = cal.createDuration();
+ const a = cal.dtz.jsDateToDateTime(new Date());
+ a.timezone = getMozTimezone("/mozilla.org/20071231_1/Europe/Berlin");
+
+ let b = a.clone();
+ b.timezone = getMozTimezone("/mozilla.org/20071231_1/America/New_York");
+
+ let duration = a.subtractDate(b);
+ notEqual(duration.compare(zeroLength), 0);
+ notEqual(a.compare(b), 0);
+
+ // Should lead to zero length duration
+ b = a.getInTimezone(getMozTimezone("/mozilla.org/20071231_1/America/New_York"));
+ duration = a.subtractDate(b);
+ equal(duration.compare(zeroLength), 0);
+ equal(a.compare(b), 0);
+
+ // Check that we can get the same timezone with several aliases
+ equal(getMozTimezone("/mozilla.org/xyz/Asia/Calcutta").tzid, "Asia/Calcutta");
+ equal(getMozTimezone("Asia/Calcutta").tzid, "Asia/Calcutta");
+ equal(getMozTimezone("Asia/Kolkata").tzid, "Asia/Kolkata");
+
+ // A newly created date should be in UTC, as should its clone
+ let utc = cal.createDateTime();
+ equal(utc.timezone.tzid, "UTC");
+ equal(utc.clone().timezone.tzid, "UTC");
+ equal(utc.timezoneOffset, 0);
+
+ // Bug 794477 - setting jsdate across compartments needs to work
+ let someDate = new Date();
+ let createdDate = cal.dtz.jsDateToDateTime(someDate).getInTimezone(cal.dtz.defaultTimezone);
+ someDate.setMilliseconds(0);
+ equal(someDate.getTime(), cal.dtz.dateTimeToJsDate(createdDate).getTime());
+
+ // Comparing a date-time with a date of the same day should be 0
+ equal(cal.createDateTime("20120101T120000").compare(cal.createDateTime("20120101")), 0);
+ equal(cal.createDateTime("20120101").compare(cal.createDateTime("20120101T120000")), 0);
+}
diff --git a/comm/calendar/test/unit/test_datetime_before_1970.js b/comm/calendar/test/unit/test_datetime_before_1970.js
new file mode 100644
index 0000000000..a5e5dd0054
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetime_before_1970.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // Bug 769938 - dates before 1970 are not handled correctly
+ // due to signed vs. unsigned mismatch in PRTime in xpconnect
+
+ let dateTime1950 = cal.createDateTime();
+ dateTime1950.year = 1950;
+ equal(dateTime1950.year, 1950);
+
+ let dateTime1955 = cal.dtz.jsDateToDateTime(new Date(Date.UTC(1955, 6, 15)));
+ equal(dateTime1955.year, 1955);
+
+ let dateTime1965 = cal.createDateTime();
+ dateTime1965.nativeTime = -150000000000000;
+ equal(dateTime1965.year, 1965);
+ equal(dateTime1965.nativeTime, -150000000000000);
+
+ let dateTime1990 = cal.createDateTime();
+ dateTime1990.year = 1990;
+
+ let dateTime2050 = cal.createDateTime();
+ dateTime2050.year = 2050;
+
+ ok(dateTime1950.nativeTime < dateTime1955.nativeTime);
+ ok(dateTime1955.nativeTime < dateTime1965.nativeTime);
+ ok(dateTime1965.nativeTime < dateTime1990.nativeTime);
+ ok(dateTime1990.nativeTime < dateTime2050.nativeTime);
+}
diff --git a/comm/calendar/test/unit/test_datetimeformatter.js b/comm/calendar/test/unit/test_datetimeformatter.js
new file mode 100644
index 0000000000..d297b25d0b
--- /dev/null
+++ b/comm/calendar/test/unit/test_datetimeformatter.js
@@ -0,0 +1,604 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { formatter } = cal.dtz;
+
+const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm");
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// This test assumes the timezone of your system is not set to Pacific/Fakaofo or equivalent.
+
+// Time format is platform dependent, so we use alternative result sets here in 'expected'.
+// The first two meet configurations running for automated tests,
+// the first one is for Windows, the second one for Linux and Mac, unless otherwise noted.
+// If you get a failure for this test, add your pattern here.
+
+add_task(async function formatDate_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ dateformat: 0, // long
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ dateformat: 1, // short
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ Services.prefs.setIntPref("calendar.date.format", test.input.dateformat);
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDate(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateShort_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: ["4/1/2017", "4/1/17"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set long format
+ Services.prefs.setIntPref("calendar.date.format", 0);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDateShort(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateLong_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: ["Saturday, April 01, 2017", "Saturday, April 1, 2017"],
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatDateLong(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateWithoutYear_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: "Apr 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: "Apr 1",
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ equal(formatter.formatDateWithoutYear(date), test.expected, "(test #" + i + ")");
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatDateLongWithoutYear_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "UTC",
+ },
+ expected: "Saturday, April 1",
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "floating",
+ },
+ expected: "Saturday, April 1",
+ },
+ ];
+
+ let dateformat = Services.prefs.getIntPref("calendar.date.format", 0);
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ // we make sure to have set short format
+ Services.prefs.setIntPref("calendar.date.format", 1);
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ equal(formatter.formatDateLongWithoutYear(date), test.expected, "(test #" + i + ")");
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+ Services.prefs.setIntPref("calendar.date.format", dateformat);
+});
+
+add_task(async function formatTime_test() {
+ let data = [
+ {
+ input: {
+ datetime: "20170401T090000",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: ["9:00 AM", "09:00"], // Windows+Mac, Linux.
+ },
+ {
+ input: {
+ datetime: "20170401T090000",
+ timezone: "Pacific/Kiritimati",
+ },
+ expected: ["9:00 AM", "09:00"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "UTC",
+ },
+ expected: ["6:00 PM", "18:00"],
+ },
+ {
+ input: {
+ datetime: "20170401T180000",
+ timezone: "floating",
+ },
+ expected: ["6:00 PM", "18:00"],
+ },
+ {
+ input: {
+ datetime: "20170401",
+ timezone: "Pacific/Fakaofo",
+ },
+ expected: "All Day",
+ },
+ ];
+
+ let tzlocal = Services.prefs.getStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+ Services.prefs.setStringPref("calendar.timezone.local", "Pacific/Fakaofo");
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let zone =
+ test.input.timezone == "floating"
+ ? cal.dtz.floating
+ : cal.timezoneService.getTimezone(test.input.timezone);
+ let date = cal.createDateTime(test.input.datetime).getInTimezone(zone);
+
+ let formatted = formatter.formatTime(date);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+ // let's reset the preferences
+ Services.prefs.setStringPref("calendar.timezone.local", tzlocal);
+});
+
+add_task(function formatTime_test_with_arbitrary_timezone() {
+ // Create a timezone with an arbitrary offset and a time zone ID we can be
+ // reasonably sure Gecko won't recognize so we can be sure that we aren't
+ // relying on the time zone ID to be valid.
+ const tzdef =
+ "BEGIN:VTIMEZONE\n" +
+ "TZID:Nowhere/Middle\n" +
+ "BEGIN:STANDARD\n" +
+ "DTSTART:16010101T000000\n" +
+ "TZOFFSETFROM:-0741\n" +
+ "TZOFFSETTO:-0741\n" +
+ "END:STANDARD\n" +
+ "END:VTIMEZONE";
+
+ const timezone = new CalTimezone(
+ ICAL.Timezone.fromData({
+ tzid: "Nowhere/Middle",
+ component: tzdef,
+ })
+ );
+
+ const expected = ["6:19 AM", "06:19"];
+
+ const dateTime = cal.createDateTime("20220916T140000Z").getInTimezone(timezone);
+ const formatted = formatter.formatTime(dateTime);
+
+ ok(expected.includes(formatted), `expected '${expected}', actual result ${formatted}`);
+});
+
+add_task(async function formatInterval_test() {
+ let data = [
+ //1: task-without-dates
+ {
+ input: {},
+ expected: "no start or due date",
+ },
+ //2: task-without-due-date
+ {
+ input: { start: "20220916T140000Z" },
+ expected: [
+ "start date Friday, September 16, 2022 2:00 PM",
+ "start date Friday, September 16, 2022 14:00",
+ ],
+ },
+ //3: task-without-start-date
+ {
+ input: { end: "20220916T140000Z" },
+ expected: [
+ "due date Friday, September 16, 2022 2:00 PM",
+ "due date Friday, September 16, 2022 14:00",
+ ],
+ },
+ //4: all-day
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T140000Z",
+ allDay: true,
+ },
+ expected: "Friday, September 16, 2022",
+ },
+ //5: all-day-between-years
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20230916T140000Z",
+ allDay: true,
+ },
+ expected: "September 16, 2022 – September 16, 2023",
+ },
+ //6: all-day-in-month
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220920T140000Z",
+ allDay: true,
+ },
+ expected: "September 16 – 20, 2022",
+ },
+ //7: all-day-between-months
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20221020T140000Z",
+ allDay: true,
+ },
+ expected: "September 16 – October 20, 2022",
+ },
+ //8: same-date-time
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T140000Z",
+ },
+ expected: ["Friday, September 16, 2022 2:00 PM", "Friday, September 16, 2022 14:00"],
+ },
+ //9: same-day
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220916T160000Z",
+ },
+ expected: [
+ "Friday, September 16, 2022 2:00 PM – 4:00 PM",
+ "Friday, September 16, 2022 14:00 – 16:00",
+ ],
+ },
+ //10: several-days
+ {
+ input: {
+ start: "20220916T140000Z",
+ end: "20220920T160000Z",
+ },
+ expected: [
+ "Friday, September 16, 2022 2:00 PM – Tuesday, September 20, 2022 4:00 PM",
+ "Friday, September 16, 2022 14:00 – Tuesday, September 20, 2022 16:00",
+ ],
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let startDate = test.input.start ? cal.createDateTime(test.input.start) : null;
+ let endDate = test.input.end ? cal.createDateTime(test.input.end) : null;
+
+ if (test.input.allDay) {
+ startDate.isDate = true;
+ }
+
+ let formatted = formatter.formatInterval(startDate, endDate);
+ ok(
+ test.expected.includes(formatted),
+ "(test #" + i + ": result '" + formatted + "', expected '" + test.expected + "')"
+ );
+ }
+});
diff --git a/comm/calendar/test/unit/test_deleted_items.js b/comm/calendar/test/unit/test_deleted_items.js
new file mode 100644
index 0000000000..d68c927dae
--- /dev/null
+++ b/comm/calendar/test/unit/test_deleted_items.js
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+add_setup(function () {
+ // The deleted items service is started automatically by the start-up
+ // procedure, but that doesn't happen in XPCShell tests. Add an observer
+ // ourselves to simulate the behaviour.
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+ Services.obs.addObserver(delmgr, "profile-after-change");
+
+ do_calendar_startup(run_next_test);
+});
+
+function check_delmgr_call(aFunc) {
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+
+ return new Promise((resolve, reject) => {
+ delmgr.wrappedJSObject.completedNotifier.handleCompletion = aReason => {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ resolve();
+ } else {
+ reject(aReason);
+ }
+ };
+ aFunc();
+ });
+}
+
+add_task(async function test_deleted_items() {
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(Ci.calIDeletedItems);
+
+ // No items have been deleted, retrieving one should return null.
+ equal(delmgr.getDeletedDate("random"), null);
+ equal(delmgr.getDeletedDate("random", "random"), null);
+
+ // Make sure the cache is initially flushed and that this doesn't throw an
+ // error.
+ await check_delmgr_call(() => delmgr.flush());
+
+ let memory = cal.manager.createCalendar("memory", Services.io.newURI("moz-storage-calendar://"));
+ cal.manager.registerCalendar(memory);
+
+ let item = new CalEvent();
+ item.id = "test-item-1";
+ item.startDate = cal.dtz.now();
+ item.endDate = cal.dtz.now();
+
+ // Add the item, it still shouldn't be in the deleted database.
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+ equal(delmgr.getDeletedDate(item.id, memory.id), null);
+
+ // We need to stop time so we have something to compare with.
+ let referenceDate = cal.createDateTime("20120726T112045");
+ referenceDate.timezone = cal.dtz.defaultTimezone;
+ let futureDate = cal.createDateTime("20380101T000000");
+ futureDate.timezone = cal.dtz.defaultTimezone;
+ let useFutureDate = false;
+ let oldNowFunction = cal.dtz.now;
+ cal.dtz.now = function () {
+ return (useFutureDate ? futureDate : referenceDate).clone();
+ };
+
+ // Deleting an item should trigger it being marked for deletion.
+ await check_delmgr_call(() => memory.deleteItem(item));
+
+ // Now check if it was deleted at our reference date.
+ let deltime = delmgr.getDeletedDate(item.id);
+ notEqual(deltime, null);
+ equal(deltime.compare(referenceDate), 0);
+
+ // The same with the calendar.
+ deltime = delmgr.getDeletedDate(item.id, memory.id);
+ notEqual(deltime, null);
+ equal(deltime.compare(referenceDate), 0);
+
+ // Item should not be found in other calendars.
+ equal(delmgr.getDeletedDate(item.id, "random"), null);
+
+ // Check if flushing works, we need to travel time for that.
+ useFutureDate = true;
+ await check_delmgr_call(() => delmgr.flush());
+ equal(delmgr.getDeletedDate(item.id), null);
+ equal(delmgr.getDeletedDate(item.id, memory.id), null);
+
+ // Start over with our past time.
+ useFutureDate = false;
+
+ // Add, delete, add. Item should no longer be deleted.
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+ await check_delmgr_call(() => memory.deleteItem(item));
+ equal(delmgr.getDeletedDate(item.id).compare(referenceDate), 0);
+ await check_delmgr_call(() => memory.addItem(item));
+ equal(delmgr.getDeletedDate(item.id), null);
+
+ // Revert now function, in case more tests are written.
+ cal.dtz.now = oldNowFunction;
+});
diff --git a/comm/calendar/test/unit/test_duration.js b/comm/calendar/test/unit/test_duration.js
new file mode 100644
index 0000000000..cef7bbddf9
--- /dev/null
+++ b/comm/calendar/test/unit/test_duration.js
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let a = cal.createDuration("PT1S");
+ let b = cal.createDuration("PT3S");
+ a.addDuration(b);
+ equal(a.icalString, "PT4S");
+}
diff --git a/comm/calendar/test/unit/test_email_utils.js b/comm/calendar/test/unit/test_email_utils.js
new file mode 100644
index 0000000000..a1a55e3f17
--- /dev/null
+++ b/comm/calendar/test/unit/test_email_utils.js
@@ -0,0 +1,265 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ test_prependMailTo();
+ test_removeMailTo();
+ test_getAttendeeEmail();
+ test_createRecipientList();
+ test_validateRecipientList();
+ test_attendeeMatchesAddresses();
+}
+
+function test_prependMailTo() {
+ let data = [
+ { input: "mailto:first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "MAILTO:first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "first.last@example.net", expected: "mailto:first.last@example.net" },
+ { input: "first.last.example.net", expected: "first.last.example.net" },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.prependMailTo(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_removeMailTo() {
+ let data = [
+ { input: "mailto:first.last@example.net", expected: "first.last@example.net" },
+ { input: "MAILTO:first.last@example.net", expected: "first.last@example.net" },
+ { input: "first.last@example.net", expected: "first.last@example.net" },
+ { input: "first.last.example.net", expected: "first.last.example.net" },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.removeMailTo(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_getAttendeeEmail() {
+ let data = [
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last, First",
+ email: null,
+ useCn: true,
+ },
+ expected: '"Last, First" <first.last@example.net>',
+ },
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last; First",
+ email: null,
+ useCn: true,
+ },
+ expected: '"Last; First" <first.last@example.net>',
+ },
+ {
+ input: { id: "mailto:first.last@example.net", cname: "First Last", email: null, useCn: true },
+ expected: "First Last <first.last@example.net>",
+ },
+ {
+ input: {
+ id: "mailto:first.last@example.net",
+ cname: "Last, First",
+ email: null,
+ useCn: false,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: { id: "mailto:first.last@example.net", cname: null, email: null, useCn: true },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: null,
+ email: "first.last@example.net",
+ useCn: false,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: null,
+ email: "first.last@example.net",
+ useCn: true,
+ },
+ expected: "first.last@example.net",
+ },
+ {
+ input: {
+ id: "urn:uuid:first.last.example.net",
+ cname: "First Last",
+ email: "first.last@example.net",
+ useCn: true,
+ },
+ expected: "First Last <first.last@example.net>",
+ },
+ {
+ input: { id: "urn:uuid:first.last.example.net", cname: null, email: null, useCn: false },
+ expected: "",
+ },
+ ];
+ for (let [i, test] of Object.entries(data)) {
+ let attendee = new CalAttendee();
+ attendee.id = test.input.id;
+ if (test.input.cname) {
+ attendee.commonName = test.input.cname;
+ }
+ if (test.input.email) {
+ attendee.setProperty("EMAIL", test.input.email);
+ }
+ equal(
+ cal.email.getAttendeeEmail(attendee, test.input.useCn),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+}
+
+function test_createRecipientList() {
+ let data = [
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: null },
+ { id: "mailto:second@example.net", cname: null },
+ { id: "mailto:third@example.net", cname: null },
+ ],
+ expected: "first@example.net, second@example.net, third@example.net",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "first example" },
+ { id: "mailto:second@example.net", cname: "second example" },
+ { id: "mailto:third@example.net", cname: "third example" },
+ ],
+ expected:
+ "first example <first@example.net>, second example <second@example.net>, " +
+ "third example <third@example.net>",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "example, first" },
+ { id: "mailto:second@example.net", cname: "example, second" },
+ { id: "mailto:third@example.net", cname: "example, third" },
+ ],
+ expected:
+ '"example, first" <first@example.net>, "example, second" <second@example.net>, ' +
+ '"example, third" <third@example.net>',
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: null },
+ { id: "urn:uuid:second.example.net", cname: null },
+ { id: "mailto:third@example.net", cname: null },
+ ],
+ expected: "first@example.net, third@example.net",
+ },
+ {
+ input: [
+ { id: "mailto:first@example.net", cname: "first" },
+ { id: "urn:uuid:second.example.net", cname: "second" },
+ { id: "mailto:third@example.net", cname: "third" },
+ ],
+ expected: "first <first@example.net>, third <third@example.net>",
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let attendees = [];
+ for (let att of test.input) {
+ let attendee = new CalAttendee();
+ attendee.id = att.id;
+ if (att.cname) {
+ attendee.commonName = att.cname;
+ }
+ attendees.push(attendee);
+ }
+ equal(cal.email.createRecipientList(attendees), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_validateRecipientList() {
+ let data = [
+ {
+ input: "first.last@example.net",
+ expected: "first.last@example.net",
+ },
+ {
+ input: "first last <first.last@example.net>",
+ expected: "first last <first.last@example.net>",
+ },
+ {
+ input: '"last, first" <first.last@example.net>',
+ expected: '"last, first" <first.last@example.net>',
+ },
+ {
+ input: "last, first <first.last@example.net>",
+ expected: '"last, first" <first.last@example.net>',
+ },
+ {
+ input: '"last; first" <first.last@example.net>',
+ expected: '"last; first" <first.last@example.net>',
+ },
+ {
+ input: "first1.last1@example.net,first2.last2@example.net,first3.last2@example.net",
+ expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ },
+ {
+ input: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ expected: "first1.last1@example.net, first2.last2@example.net, first3.last2@example.net",
+ },
+ {
+ input:
+ 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, first' +
+ '3" <first3.last2@example.net>',
+ expected:
+ 'first1.last1@example.net, first2 last2 <first2.last2@example.net>, "last3, fi' +
+ 'rst3" <first3.last2@example.net>',
+ },
+ {
+ input:
+ 'first1.last1@example.net, last2; first2 <first2.last2@example.net>, "last3; first' +
+ '3" <first3.last2@example.net>',
+ expected:
+ 'first1.last1@example.net, "last2; first2" <first2.last2@example.net>, "last' +
+ '3; first3" <first3.last2@example.net>',
+ },
+ {
+ input:
+ "first1 last2 <first1.last1@example.net>, last2, first2 <first2.last2@example.net>" +
+ ', "last3, first3" <first3.last2@example.net>',
+ expected:
+ 'first1 last2 <first1.last1@example.net>, "last2, first2" <first2.last2@examp' +
+ 'le.net>, "last3, first3" <first3.last2@example.net>',
+ },
+ ];
+
+ for (let [i, test] of Object.entries(data)) {
+ equal(cal.email.validateRecipientList(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function test_attendeeMatchesAddresses() {
+ let a = new CalAttendee("ATTENDEE:mailto:horst");
+ ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["peter"]));
+
+ a = new CalAttendee('ATTENDEE;EMAIL="horst":urn:uuid:horst');
+ ok(cal.email.attendeeMatchesAddresses(a, ["HORST", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["HORSTpeter", "peter"]));
+ ok(!cal.email.attendeeMatchesAddresses(a, ["peter"]));
+}
diff --git a/comm/calendar/test/unit/test_extract.js b/comm/calendar/test/unit/test_extract.js
new file mode 100644
index 0000000000..96b1729f5c
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract.js
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This test works with code that is not timezone-aware.
+/* eslint-disable no-restricted-syntax */
+
+var { Extractor } = ChromeUtils.import("resource:///modules/calendar/calExtract.jsm");
+
+var extractor = new Extractor("en-US", 8);
+
+function run_test() {
+ // Sanity check to make sure the base url is still right. If this fails,
+ // don't forget to also fix the url in base/content/calendar-extract.js.
+ ok(extractor.checkBundle("en-US"));
+
+ test_event_start_end();
+ test_event_start_duration();
+ test_event_start_end_whitespace();
+ test_event_without_date();
+ test_event_next_year();
+ test_task_due();
+ test_overrides();
+ test_event_start_dollar_sign();
+}
+
+function test_event_start_end() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_start_duration() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at 2 pm and discuss for 30 minutes.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 14);
+ equal(endGuess.minute, 30);
+}
+
+function test_event_start_end_whitespace() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday meetup";
+ let content = "We'll meet at2pm and discuss until\r\n3pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 3);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_without_date() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Meetup";
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 14);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 1);
+ equal(endGuess.hour, 15);
+ equal(endGuess.minute, 0);
+}
+
+function test_event_next_year() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Open day";
+ let content = "FYI: Next open day is planned for February 5th.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2013);
+ equal(guessed.month, 2);
+ equal(guessed.day, 5);
+ equal(guessed.hour, undefined);
+ equal(guessed.minute, undefined);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
+
+function test_task_due() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Assignment deadline";
+ let content = "This is a reminder that all assignments must be sent in by October 5th!.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart(true);
+ let endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 9);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, 2012);
+ equal(endGuess.month, 10);
+ equal(endGuess.day, 5);
+ equal(endGuess.hour, 0);
+ equal(endGuess.minute, 0);
+}
+
+function test_overrides() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Event invitation";
+ let content = "We'll meet 10:11 worromot";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart(false);
+ let endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 1);
+ equal(guessed.hour, 10);
+ equal(guessed.minute, 11);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+
+ // recognize a custom "tomorrow" and hour.minutes pattern
+ let overrides = {
+ "from.hour.minutes": { add: "#2:#1", remove: "#1:#2" },
+ "from.tomorrow": { add: "worromot" },
+ };
+
+ Services.prefs.setStringPref("calendar.patterns.override", JSON.stringify(overrides));
+
+ extractor.extract(title, content, date, undefined);
+ guessed = extractor.guessStart(false);
+ endGuess = extractor.guessEnd(guessed, true);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 2);
+ equal(guessed.hour, 11);
+ equal(guessed.minute, 10);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
+
+function test_event_start_dollar_sign() {
+ let date = new Date(2012, 9, 1, 9, 0);
+ let title = "Wednesday sale";
+ let content = "Sale starts at 3 pm and prices start at 2$.";
+
+ extractor.extract(title, content, date, undefined);
+ let guessed = extractor.guessStart();
+ let endGuess = extractor.guessEnd(guessed);
+
+ equal(guessed.year, 2012);
+ equal(guessed.month, 10);
+ equal(guessed.day, 3);
+ equal(guessed.hour, 15);
+ equal(guessed.minute, 0);
+
+ equal(endGuess.year, undefined);
+ equal(endGuess.month, undefined);
+ equal(endGuess.day, undefined);
+ equal(endGuess.hour, undefined);
+ equal(endGuess.minute, undefined);
+}
diff --git a/comm/calendar/test/unit/test_extract_parser.js b/comm/calendar/test/unit/test_extract_parser.js
new file mode 100644
index 0000000000..d1b0f48b80
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParseNode, extendParseRule, prepareArguments } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests to ensure extendParseRule() expands parse rules as we desire.
+ */
+add_task(function testExtendParseRule() {
+ let action = () => {};
+
+ let tests = [
+ {
+ name: "parse rules are expanded correctly",
+ input: {
+ name: "text",
+ patterns: ["TEXT"],
+ action,
+ },
+ expected: {
+ name: "text",
+ patterns: ["TEXT"],
+ action,
+ flags: [0],
+ graph: {
+ symbol: null,
+ flags: null,
+ descendants: [
+ {
+ symbol: "TEXT",
+ flags: 0,
+ descendants: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ name: "flags are detected correctly",
+ input: {
+ name: "text",
+ patterns: ["CHAR+", "TEXT?", "characters*"],
+ action,
+ },
+ expected: {
+ name: "text",
+ action,
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ graph: {
+ symbol: null,
+ flags: null,
+ descendants: [
+ {
+ symbol: "CHAR",
+ flags: CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ descendants: [
+ {
+ symbol: "CHAR",
+ },
+ {
+ symbol: "TEXT",
+ flags: CalExtractParseNode.FLAG_OPTIONAL,
+ descendants: [
+ {
+ symbol: "characters",
+ flags: CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ descendants: [
+ {
+ symbol: "characters",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Test extendParseRule(): ${test.name}`);
+ compareExtractResults(extendParseRule(test.input), test.expected);
+ }
+});
+
+/**
+ * Tests prepareArguments() gives the correct arguments.
+ */
+add_task(function testReconcileArguments() {
+ let tests = [
+ {
+ name: "patterns without no flags bits are untouched",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [0, 0, 0],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["TEXT", "is text"],
+ ["characters", "is characters"],
+ ],
+ expected: ["is char", "is text", "is characters"],
+ },
+ {
+ name: "multi patterns are turned into arrays",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["TEXT", "is text"],
+ ["characters", "is characters"],
+ ],
+ expected: [["is char"], "is text", ["is characters"]],
+ },
+ {
+ name: "unmatched optional patterns are null",
+ rule: {
+ name: "text",
+ patterns: ["CHAR", "TEXT", "characters"],
+ flags: [
+ CalExtractParseNode.FLAG_NONEMPTY | CalExtractParseNode.FLAG_MULTIPLE,
+ CalExtractParseNode.FLAG_OPTIONAL,
+ CalExtractParseNode.FLAG_OPTIONAL | CalExtractParseNode.FLAG_MULTIPLE,
+ ],
+ },
+ matched: [
+ ["CHAR", "is char"],
+ ["characters", "is characters"],
+ ],
+ expected: [["is char"], null, ["is characters"]],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Test prepareArguments(): ${test.name}`);
+ compareExtractResults(prepareArguments(test.rule, test.matched), test.expected);
+ }
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_parse.js b/comm/calendar/test/unit/test_extract_parser_parse.js
new file mode 100644
index 0000000000..ac029c4303
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_parse.js
@@ -0,0 +1,1317 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParser } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests parsing an empty string produces an empty lit.
+ */
+add_task(function testParseEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.parse("");
+ Assert.equal(result.length, 0, "parsing empty string produces empty list");
+});
+
+/**
+ * Tests parsing with various non-flag rules works as expected.
+ */
+add_task(function testParseText() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^your/i, "YOUR"],
+ [/^(appointment|meeting|booking)/i, "EVENT"],
+ [/^(at|@)/i, "AT"],
+ [/^on/i, "ON"],
+ [/^\d\d-\d\d-\d\d\d\d/, "DATE"],
+ [/^\d\d\d\d-\d\d-\d\d/, "ISODATE"],
+ [/^(was|is|has been| will be)/i, "BE"],
+ [/^(confirmed|booked|saved|created)/i, "CONFIRM"],
+ [/^[A-Z][A-Za-z0-9]+/, "NOUN"],
+ [/^,/],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "event",
+ patterns: ["text", "yourevent", "location", "BE", "CONFIRM"],
+ action: ([, title, location]) => ({
+ type: "event",
+ title,
+ location,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourevent", "location", "BE", "CONFIRM"],
+ action: ([title, location]) => ({
+ type: "event",
+ title,
+ location,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourevent", "ON", "date", "BE", "CONFIRM"],
+ action: ([title, , date]) => ({
+ type: "event",
+ title,
+ date,
+ }),
+ },
+ {
+ name: "event",
+ patterns: ["yourthing", "ON", "date", "BE", "CONFIRM"],
+ action: ([title, , date]) => ({
+ type: "event",
+ title,
+ date,
+ }),
+ },
+ {
+ name: "date",
+ patterns: ["DATE"],
+ action: ([value]) => ({
+ type: "date",
+ value,
+ }),
+ },
+ {
+ name: "date",
+ patterns: ["ISODATE"],
+ action: ([value]) => ({
+ type: "date",
+ value,
+ }),
+ },
+ {
+ name: "yourevent",
+ patterns: ["yourthing", "EVENT"],
+ action: ([value]) => value,
+ },
+ {
+ name: "yourthing",
+ patterns: ["YOUR", "text"],
+ action: ([, value]) => value,
+ },
+ {
+ name: "location",
+ patterns: ["AT", "text"],
+ action: ([, value]) => ({
+ type: "location",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ {
+ name: "text",
+ patterns: ["NOUN"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ input: "Hello, your banking appointment at RealBank is booked!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "TEXT",
+ text: "banking",
+ sentence: 0,
+ position: 12,
+ },
+ location: {
+ type: "location",
+ value: {
+ type: "NOUN",
+ text: "RealBank",
+ sentence: 0,
+ position: 35,
+ },
+ },
+ },
+ ],
+ },
+ {
+ input: "your banking appointment at RealBank is booked!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "TEXT",
+ text: "banking",
+ sentence: 0,
+ position: 5,
+ },
+ location: {
+ type: "location",
+ value: {
+ type: "NOUN",
+ text: "RealBank",
+ sentence: 0,
+ position: 28,
+ },
+ },
+ },
+ ],
+ },
+ {
+ input: "Your Arraignment on 09-09-2021 is confirmed!",
+ expected: [
+ {
+ type: "event",
+ title: {
+ type: "NOUN",
+ text: "Arraignment",
+ sentence: 0,
+ position: 5,
+ },
+ date: {
+ type: "date",
+ value: {
+ type: "DATE",
+ text: "09-09-2021",
+ sentence: 0,
+ position: 20,
+ },
+ },
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Parsing string "${test.input}"...`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing unknown text produces a null result for the sentence.
+ */
+add_task(function testParseUnknownText() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^No/, "NO"],
+ [/^rules/, "RULES"],
+ [/^for/, "FOR"],
+ [/^this/, "THIS"],
+ [/^Or/, "OR"],
+ [/^even/, "EVEN"],
+ [/^\s+/, "SPACE"],
+ ],
+ [
+ {
+ name: "statement",
+ patterns: ["NO", "SPACE", "RULES", "SPACE", "FOR", "SPACE", "THIS"],
+ action: () => "statement",
+ },
+ {
+ name: "statement",
+ patterns: ["OR", "SPACE", "THIS"],
+ action: () => "statement",
+ },
+ ]
+ );
+
+ let result = parser.parse("No rules for this. Or this. Or even this!");
+ Assert.equal(result.length, 3, "result has 3 sentences");
+ Assert.equal(result[0], "statement", "first sentence parsed properly");
+ Assert.equal(result[1], "statement", "second sentence parsed properly");
+ Assert.equal(result[2], null, "third sentence was not parsed properly");
+});
+
+/**
+ * Tests parsing without any parse rules produces a null result for each
+ * sentence.
+ */
+add_task(function testParseWithoutParseRules() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^\s+/, "SPACE"],
+ ],
+ []
+ );
+ let result = parser.parse("No rules for this. Or this. Or event this!");
+ Assert.equal(result.length, 3, "result has 3 parsed sentences");
+ Assert.ok(
+ result.every(val => val == null),
+ "all parsed results are null"
+ );
+});
+
+/**
+ * Tests parsing using the "+" flag in various scenarios.
+ */
+add_task(function testParseWithPlusFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text+", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text+"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text+", "meet", "time"],
+ action: ([text, , time]) => ({
+ type: "result2",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '+' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "using the '+' flag can capture multiple patterns",
+ input: "We are coming to meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "are",
+ sentence: 0,
+ position: 3,
+ },
+ {
+ type: "TEXT",
+ text: "coming",
+ sentence: 0,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "to",
+ sentence: 0,
+ position: 14,
+ },
+ ],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 25 },
+ },
+ },
+ ],
+ },
+ {
+ name: "using '+' fails if its pattern is unmatched",
+ input: "We meet at 7",
+ expected: [null],
+ },
+ {
+ name: "'+' can be used in the first position",
+ input: "Well do not meet at 7",
+ expected: [
+ {
+ type: "result2",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 20,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "Well",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "do",
+ sentence: 0,
+ position: 5,
+ },
+ {
+ type: "TEXT",
+ text: "not",
+ sentence: 0,
+ position: 8,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "'+' can be used in the last position",
+ input: "Meet at 7 is the plan",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "is",
+ sentence: 0,
+ position: 10,
+ },
+ {
+ type: "TEXT",
+ text: "the",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "plan",
+ sentence: 0,
+ position: 17,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing using the "*" flag in various scenarios.
+ */
+add_task(function testParseWithStarFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text*", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text*"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text*", "subject", "text", "meet", "time"],
+ action: ([text, subject, , time]) => ({
+ type: "result2",
+ text,
+ subject,
+ time,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '*' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "using the '*' flag can capture multiple patterns",
+ input: "We are coming to meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "are",
+ sentence: 0,
+ position: 3,
+ },
+ {
+ type: "TEXT",
+ text: "coming",
+ sentence: 0,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "to",
+ sentence: 0,
+ position: 14,
+ },
+ ],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 25 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' capture is optional",
+ input: "We meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: [],
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 11 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' can be used in the first position",
+ input: "To think we will meet at 7",
+ expected: [
+ {
+ type: "result2",
+ text: [
+ {
+ type: "TEXT",
+ text: "To",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "think",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 9,
+ },
+ },
+ time: {
+ type: "meet",
+ meet: {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 17,
+ },
+ at: {
+ type: "AT",
+ text: "at",
+ sentence: 0,
+ position: 22,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'*' can be used in the last position",
+ input: "Meet at 7 is the plan",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: [
+ {
+ type: "TEXT",
+ text: "is",
+ sentence: 0,
+ position: 10,
+ },
+ {
+ type: "TEXT",
+ text: "the",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "plan",
+ sentence: 0,
+ position: 17,
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Tests parsing using the "?" flag in various scenarios.
+ */
+add_task(function testParseWithOptionalFlags() {
+ let parser = CalExtractParser.createInstance(
+ [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ],
+ [
+ {
+ name: "result",
+ patterns: ["subject", "text?", "meet", "time"],
+ action: ([subject, text, , time]) => ({
+ type: "result0",
+ subject,
+ text,
+ time,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["meet", "time", "text?"],
+ action: ([, time, text]) => ({
+ type: "result1",
+ time,
+ text,
+ }),
+ },
+ {
+ name: "result",
+ patterns: ["text?", "subject", "text", "meet", "time"],
+ action: ([text, subject, , time]) => ({
+ type: "result2",
+ text,
+ subject,
+ time,
+ }),
+ },
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ]
+ );
+
+ let tests = [
+ {
+ name: "using '?' flag can capture one pattern",
+ input: "We will meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 16,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' capture is optional",
+ input: "We meet at 7",
+ expected: [
+ {
+ type: "result0",
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ text: null,
+ time: {
+ type: "time",
+ value: { type: "NUMBER", text: "7", sentence: 0, position: 11 },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' can be used in the first position",
+ input: "Think we will meet at 7",
+ expected: [
+ {
+ type: "result2",
+ text: {
+ type: "TEXT",
+ text: "Think",
+ sentence: 0,
+ position: 0,
+ },
+ subject: {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 6,
+ },
+ },
+ time: {
+ type: "meet",
+ meet: {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 14,
+ },
+ at: {
+ type: "AT",
+ text: "at",
+ sentence: 0,
+ position: 19,
+ },
+ },
+ },
+ ],
+ },
+ {
+ name: "'?' can be used in the last position",
+ input: "Meet at 7 please",
+ expected: [
+ {
+ type: "result1",
+ time: {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ text: {
+ type: "TEXT",
+ text: "please",
+ sentence: 0,
+ position: 10,
+ },
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ info(`Running test: ${test.name}`);
+ let result = parser.parse(test.input);
+ Assert.equal(
+ result.length,
+ test.expected.length,
+ `parsing "${test.input}" resulted in ${test.expected.length} sentences`
+ );
+ info(`Comparing parse results for string "${test.input}"...`);
+ compareExtractResults(result, test.expected, "result");
+ }
+});
+
+/**
+ * Test the flags can be used together in the same rules.
+ */
+add_task(function testParseWithFlags() {
+ let tokens = [
+ [/^we\b/i, "WE"],
+ [/^meet\b/i, "MEET"],
+ [/^at\b/i, "AT"],
+ [/^\d/, "NUMBER"],
+ [/^\S+/, "TEXT"],
+ [/^\s+/],
+ ];
+
+ let patterns = [
+ {
+ name: "subject",
+ patterns: ["WE"],
+ action: ([subject]) => ({
+ type: "subject",
+ subject,
+ }),
+ },
+ {
+ name: "meet",
+ patterns: ["MEET", "AT"],
+ action: ([meet, at]) => ({
+ type: "meet",
+ meet,
+ at,
+ }),
+ },
+ {
+ name: "time",
+ patterns: ["NUMBER"],
+ action: ([value]) => ({
+ type: "time",
+ value,
+ }),
+ },
+ {
+ name: "text",
+ patterns: ["TEXT"],
+ action: ([value]) => value,
+ },
+ ];
+
+ let tests = [
+ {
+ patterns: ["subject?", "text*", "time+"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ {
+ input: "7",
+ expected: [
+ [
+ null,
+ [],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 0 } }],
+ ],
+ ],
+ },
+ {
+ input: "we",
+ expected: [null],
+ },
+ {
+ input: "will 7",
+ expected: [
+ [
+ null,
+ [{ type: "TEXT", text: "will", sentence: 0, position: 0 }],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 5 } }],
+ ],
+ ],
+ },
+ {
+ input: "we 7",
+ expected: [
+ [
+ { type: "subject", subject: { type: "WE", text: "we", sentence: 0, position: 0 } },
+ [],
+ [{ type: "time", value: { type: "NUMBER", text: "7", sentence: 0, position: 3 } }],
+ ],
+ ],
+ },
+ ],
+ },
+ {
+ patterns: ["subject+", "text?", "time*"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ {
+ input: "7",
+ expected: [null],
+ },
+ {
+ input: "will 7",
+ expected: [null],
+ },
+ {
+ input: "we 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ null,
+ [
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 3,
+ },
+ },
+ ],
+ ],
+ ],
+ },
+ ],
+ },
+ {
+ patterns: ["subject*", "text+", "time?"],
+ variants: [
+ {
+ input: "We will 7",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "We",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 8,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ input: "will",
+ expected: [[[], [{ type: "TEXT", text: "will", sentence: 0, position: 0 }], null]],
+ },
+ {
+ input: "will 7",
+ expected: [
+ [
+ [],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 0,
+ },
+ ],
+ {
+ type: "time",
+ value: {
+ type: "NUMBER",
+ text: "7",
+ sentence: 0,
+ position: 5,
+ },
+ },
+ ],
+ ],
+ },
+ {
+ input: "we will",
+ expected: [
+ [
+ [
+ {
+ type: "subject",
+ subject: {
+ type: "WE",
+ text: "we",
+ sentence: 0,
+ position: 0,
+ },
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "will",
+ sentence: 0,
+ position: 3,
+ },
+ ],
+ null,
+ ],
+ ],
+ },
+ ],
+ },
+ ];
+
+ for (let test of tests) {
+ test = tests[2];
+ let rule = {
+ name: "result",
+ patterns: test.patterns,
+ action: args => args,
+ };
+ let parser = CalExtractParser.createInstance(tokens, [rule, ...patterns]);
+
+ for (let input of test.variants) {
+ input = test.variants[3];
+ info(`Testing pattern: ${test.patterns} with input "${input.input}".`);
+ let result = parser.parse(input.input);
+ info(`Comparing parse results for string "${input.input}"...`);
+ compareExtractResults(result, input.expected, "result");
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_service.js b/comm/calendar/test/unit/test_extract_parser_service.js
new file mode 100644
index 0000000000..2118fad16b
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_service.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParserService. These are modified versions of the
+ * text_extract.js tests, for now.
+ */
+
+// This test works with code that is not timezone-aware.
+/* eslint-disable no-restricted-syntax */
+
+var { CalExtractParserService } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParserService.jsm"
+);
+
+let service = new CalExtractParserService();
+
+/**
+ * Test the extraction of a start and end time using HOUR am/pm. Note: The
+ * service currently only selects event information from one sentence so the
+ * event title is not included here for now.
+ */
+add_task(function test_event_start_end() {
+ let now = new Date(2012, 9, 1, 9, 0);
+ let content = "We'll meet at 2 pm and discuss until 3 pm.";
+ let result = service.extract(content, {
+ now,
+ });
+
+ info(`Comparing extracted result for string "${content}"...`);
+ compareExtractResults(
+ result,
+ {
+ type: "event-guess",
+ startTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 0,
+ meridiem: "pm",
+ },
+ endTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 15,
+ minute: 0,
+ meridiem: "pm",
+ },
+ priority: 0,
+ },
+ "result"
+ );
+});
+
+/**
+ * Test the extraction of a start and end time using a meridiem time for start
+ * and a duration for the end.
+ */
+add_task(function test_event_start_duration() {
+ let now = new Date(2012, 9, 1, 9, 0);
+ let content = "We'll meet at 2 pm and discuss for 30 minutes.";
+ let result = service.extract(content, {
+ now,
+ });
+ info(`Comparing extracted result for string "${content}"...`);
+ compareExtractResults(
+ result,
+ {
+ type: "event-guess",
+ startTime: {
+ type: "meridiem-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 0,
+ meridiem: "pm",
+ },
+ endTime: {
+ type: "date-time",
+ year: 2012,
+ month: 10,
+ day: 1,
+ hour: 14,
+ minute: 30,
+ },
+ priority: 0,
+ },
+ "result"
+ );
+});
diff --git a/comm/calendar/test/unit/test_extract_parser_tokenize.js b/comm/calendar/test/unit/test_extract_parser_tokenize.js
new file mode 100644
index 0000000000..a77c72bcfc
--- /dev/null
+++ b/comm/calendar/test/unit/test_extract_parser_tokenize.js
@@ -0,0 +1,367 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalExtractParser module.
+ */
+var { CalExtractParser } = ChromeUtils.import(
+ "resource:///modules/calendar/extract/CalExtractParser.jsm"
+);
+
+/**
+ * Tests tokenizing an empty string gives an empty list.
+ */
+add_task(function testTokenizeEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.tokenize("");
+ Assert.equal(result.length, 0, "tokenize empty string produces empty list");
+});
+
+/**
+ * Tests tokenisation works as expected.
+ */
+add_task(function testTokenizeWithRules() {
+ let parser = new CalExtractParser(
+ [
+ [/^(Monday|Tuesday|Wednesday)/, "DAY"],
+ [/^meet/, "MEET"],
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^[0-9]+/, "NUMBER"],
+ [/^\s+/, "SPACE"],
+ [/^,/, "COMMA"],
+ ],
+ []
+ );
+
+ let text = `Hello there, can we meet on Monday? If not, then Tuesday. We can
+ also meet on Wednesday at 6`;
+
+ let expected = [
+ [
+ {
+ type: "TEXT",
+ text: "Hello",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 5,
+ },
+ {
+ type: "TEXT",
+ text: "there",
+ sentence: 0,
+ position: 6,
+ },
+ {
+ type: "COMMA",
+ text: ",",
+ sentence: 0,
+ position: 11,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 12,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 16,
+ },
+ {
+ type: "TEXT",
+ text: "we",
+ sentence: 0,
+ position: 17,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 19,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 20,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 24,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 0,
+ position: 25,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 0,
+ position: 27,
+ },
+ {
+ type: "DAY",
+ text: "Monday",
+ sentence: 0,
+ position: 28,
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "If",
+ sentence: 1,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 2,
+ },
+ {
+ type: "TEXT",
+ text: "not",
+ sentence: 1,
+ position: 3,
+ },
+ {
+ type: "COMMA",
+ text: ",",
+ sentence: 1,
+ position: 6,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 7,
+ },
+ {
+ type: "TEXT",
+ text: "then",
+ sentence: 1,
+ position: 8,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 1,
+ position: 12,
+ },
+ {
+ type: "DAY",
+ text: "Tuesday",
+ sentence: 1,
+ position: 13,
+ },
+ ],
+ [
+ {
+ type: "TEXT",
+ text: "We",
+ sentence: 2,
+ position: 0,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 2,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 2,
+ position: 3,
+ },
+ {
+ type: "SPACE",
+ text: "\n ",
+ sentence: 2,
+ position: 6,
+ },
+ {
+ type: "TEXT",
+ text: "also",
+ sentence: 2,
+ position: 21,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 25,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 2,
+ position: 26,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 30,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 2,
+ position: 31,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 33,
+ },
+ {
+ type: "DAY",
+ text: "Wednesday",
+ sentence: 2,
+ position: 34,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 43,
+ },
+ {
+ type: "TEXT",
+ text: "at",
+ sentence: 2,
+ position: 44,
+ },
+ {
+ type: "SPACE",
+ text: " ",
+ sentence: 2,
+ position: 46,
+ },
+ {
+ type: "NUMBER",
+ text: "6",
+ sentence: 2,
+ position: 47,
+ },
+ ],
+ ];
+
+ info(`Tokenizing string "${text}"...`);
+ let actual = parser.tokenize(text);
+ Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`);
+ info(`Comparing results of tokenizing "${text}"...`);
+ for (let i = 0; i < expected.length; i++) {
+ compareExtractResults(actual[i], expected[i], "result");
+ }
+});
+
+/**
+ * Tests tokenizing unknown text produces null.
+ */
+add_task(function testTokenizeUnknownText() {
+ let parser = new CalExtractParser([], []);
+ let result = parser.tokenize("text with no rules");
+ Assert.equal(result.length, 1, "tokenizing unknown text produced a result");
+ Assert.equal(result[0], null, "tokenizing unknown text produced a null result");
+});
+
+/**
+ * Tests omitting some token names omits them from the result.
+ */
+add_task(function testTokenRulesNamesOmitted() {
+ let parser = new CalExtractParser([
+ [/^Monday/, "DAY"],
+ [/^meet/, "MEET"],
+ [/^[A-Za-z]+/, "TEXT"],
+ [/^[0-9]+/, "NUMBER"],
+ [/^\s+/],
+ [/^,/],
+ ]);
+
+ let text = `Hello there, can we meet on Monday?`;
+ let expected = [
+ [
+ {
+ type: "TEXT",
+ text: "Hello",
+ sentence: 0,
+ position: 0,
+ },
+ {
+ type: "TEXT",
+ text: "there",
+ sentence: 0,
+ position: 6,
+ },
+ {
+ type: "TEXT",
+ text: "can",
+ sentence: 0,
+ position: 13,
+ },
+ {
+ type: "TEXT",
+ text: "we",
+ sentence: 0,
+ position: 17,
+ },
+ {
+ type: "MEET",
+ text: "meet",
+ sentence: 0,
+ position: 20,
+ },
+ {
+ type: "TEXT",
+ text: "on",
+ sentence: 0,
+ position: 25,
+ },
+ {
+ type: "DAY",
+ text: "Monday",
+ sentence: 0,
+ position: 28,
+ },
+ ],
+ ];
+
+ info(`Tokenizing string "${text}"...`);
+ let actual = parser.tokenize(text);
+ Assert.equal(actual.length, expected.length, `result has ${expected.length} sentences`);
+ info(`Comparing results of tokenizing string "${text}"..`);
+ for (let i = 0; i < expected.length; i++) {
+ compareExtractResults(actual[i], expected[i], "result");
+ }
+});
+
+/**
+ * Tests parsing an empty string produces an empty lit.
+ */
+add_task(function testParseEmptyString() {
+ let parser = new CalExtractParser();
+ let result = parser.parse("");
+ Assert.equal(result.length, 0, "parsing empty string produces empty list");
+});
diff --git a/comm/calendar/test/unit/test_filter.js b/comm/calendar/test/unit/test_filter.js
new file mode 100644
index 0000000000..3b76585bc6
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter.js
@@ -0,0 +1,406 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+/* globals calFilter, CalReadableStreamFactory */
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+
+async function promiseItems(filter, calendar) {
+ return cal.iterate.streamToArray(filter.getItems(calendar));
+}
+
+add_task(() => new Promise(resolve => do_calendar_startup(resolve)));
+
+add_task(async function testDateRangeFilter() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let testItems = {};
+ for (let [title, startDate, endDate] of [
+ ["before", "20210720", "20210721"],
+ ["during", "20210820", "20210821"],
+ ["after", "20210920", "20210921"],
+ ["overlaps_start", "20210720", "20210804"],
+ ["overlaps_end", "20210820", "20210904"],
+ ["overlaps_both", "20210720", "20210904"],
+ ]) {
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = title;
+ event.startDate = cal.createDateTime(startDate);
+ event.endDate = cal.createDateTime(endDate);
+ await calendar.addItem(event);
+ testItems[title] = event;
+ }
+
+ // Create a new filter.
+
+ let filter = new calFilter();
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ // Test dateRangeFilter.
+
+ Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.during), "task passes date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter");
+
+ // Test isItemInFilters.
+
+ Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(testItems.during), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_start), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters");
+
+ // Test getItems.
+
+ let items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 4, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "during", "correct item returned");
+ Assert.equal(items[1].title, "overlaps_start", "correct item returned");
+ Assert.equal(items[2].title, "overlaps_end", "correct item returned");
+ Assert.equal(items[3].title, "overlaps_both", "correct item returned");
+
+ // Change the date of the filter and test it all again.
+
+ filter.startDate = cal.createDateTime("20210825");
+ filter.endDate = cal.createDateTime("20210905");
+
+ // Test dateRangeFilter.
+
+ Assert.ok(!filter.dateRangeFilter(testItems.before), "task doesn't pass date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.during), "task passes date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.after), "task doesn't pass date range filter");
+ Assert.ok(!filter.dateRangeFilter(testItems.overlaps_start), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_end), "task passes date range filter");
+ Assert.ok(filter.dateRangeFilter(testItems.overlaps_both), "task passes date range filter");
+
+ // Test isItemInFilters.
+
+ Assert.ok(!filter.isItemInFilters(testItems.before), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.during), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.after), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(testItems.overlaps_start), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_end), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(testItems.overlaps_both), "task passes all filters");
+
+ // Test getItems.
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 2, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(items[1].title, "overlaps_both", "correct item returned");
+});
+
+add_task(async function testItemTypeFilter() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let event = new CalEvent();
+ event.id = cal.getUUID();
+ event.title = "New event";
+ event.startDate = cal.createDateTime("20210803T205500Z");
+ event.endDate = cal.createDateTime("20210803T210200Z");
+ await calendar.addItem(event);
+
+ let task = new CalTodo();
+ task.id = cal.getUUID();
+ task.title = "New task";
+ task.entryDate = cal.createDateTime("20210806T090000Z");
+ task.dueDate = cal.createDateTime("20210810T140000Z");
+ await calendar.addItem(task);
+
+ // Create a new filter.
+
+ let filter = new calFilter();
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ // Check both item types pass ITEM_FILTER_TYPE_ALL.
+
+ Assert.ok(filter.itemTypeFilter(task), "task passes item type filter");
+ Assert.ok(filter.itemTypeFilter(event), "event passes item type filter");
+
+ Assert.ok(filter.isItemInFilters(task), "task passes all filters");
+ Assert.ok(filter.isItemInFilters(event), "event passes all filters");
+
+ let items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 2, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New event", "correct item returned");
+ Assert.equal(items[1].title, "New task", "correct item returned");
+
+ // Check only tasks pass ITEM_FILTER_TYPE_TODO.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+
+ Assert.ok(filter.itemTypeFilter(task), "task passes item type filter");
+ Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter");
+
+ Assert.ok(filter.isItemInFilters(task), "task passes all filters");
+ Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 1, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New task", "correct item returned");
+
+ // Check only events pass ITEM_FILTER_TYPE_EVENT.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+
+ Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter");
+ Assert.ok(filter.itemTypeFilter(event), "event passes item type filter");
+
+ Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters");
+ Assert.ok(filter.isItemInFilters(event), "event passes all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 1, "getItems returns expected number of items");
+ Assert.equal(items[0].title, "New event", "correct item returned");
+
+ // Check neither tasks or events pass ITEM_FILTER_TYPE_JOURNAL.
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_JOURNAL;
+
+ Assert.ok(!filter.itemTypeFilter(event), "event doesn't pass item type filter");
+ Assert.ok(!filter.itemTypeFilter(task), "task doesn't pass item type filter");
+
+ Assert.ok(!filter.isItemInFilters(task), "task doesn't pass all filters");
+ Assert.ok(!filter.isItemInFilters(event), "event doesn't pass all filters");
+
+ items = await promiseItems(filter, calendar);
+ Assert.equal(items.length, 0, "getItems returns expected number of items");
+});
+
+add_task(async function testItemTypeFilterTaskCompletion() {
+ let calendar = CalendarTestUtils.createCalendar("test");
+
+ let completeTask = new CalTodo();
+ completeTask.id = cal.getUUID();
+ completeTask.title = "Complete Task";
+ completeTask.entryDate = cal.createDateTime("20210806T090000Z");
+ completeTask.dueDate = cal.createDateTime("20210810T140000Z");
+ completeTask.percentComplete = 100;
+ await calendar.addItem(completeTask);
+
+ let incompleteTask = new CalTodo();
+ incompleteTask.id = cal.getUUID();
+ incompleteTask.title = "Incomplete Task";
+ incompleteTask.entryDate = cal.createDateTime("20210806T090000Z");
+ incompleteTask.dueDate = cal.createDateTime("20210810T140000Z");
+ completeTask.completedDate = null;
+ await calendar.addItem(incompleteTask);
+
+ let filter = new calFilter();
+ filter.startDate = cal.createDateTime("20210801");
+ filter.endDate = cal.createDateTime("20210831");
+
+ let checks = [
+ { flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO, expectComplete: true, expectIncomplete: true },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES,
+ expectComplete: true,
+ expectIncomplete: false,
+ },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO,
+ expectComplete: false,
+ expectIncomplete: true,
+ },
+ {
+ flags: Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ expectComplete: true,
+ expectIncomplete: true,
+ },
+ ];
+
+ for (let { flags, expectComplete, expectIncomplete } of checks) {
+ info(`testing with flags = ${flags}`);
+ filter.itemType = flags;
+
+ Assert.equal(
+ filter.itemTypeFilter(completeTask),
+ expectComplete,
+ "complete task matches item type filter"
+ );
+ Assert.equal(
+ filter.itemTypeFilter(incompleteTask),
+ expectIncomplete,
+ "incomplete task matches item type filter"
+ );
+
+ Assert.equal(
+ filter.isItemInFilters(completeTask),
+ expectComplete,
+ "complete task matches all filters"
+ );
+ Assert.equal(
+ filter.isItemInFilters(incompleteTask),
+ expectIncomplete,
+ "incomplete task matches all filters"
+ );
+
+ let expectedTitles = [];
+ if (expectComplete) {
+ expectedTitles.push(completeTask.title);
+ }
+ if (expectIncomplete) {
+ expectedTitles.push(incompleteTask.title);
+ }
+ let items = await promiseItems(filter, calendar);
+ Assert.deepEqual(
+ items.map(i => i.title),
+ expectedTitles,
+ "getItems returns correct items"
+ );
+ }
+});
+
+/**
+ * Tests that calFilter.getItems uses the correct flags when calling
+ * calICalendar.getItems. This is important because calFilter is used both by
+ * setting the itemType filter and with a calFilterProperties object.
+ */
+add_task(async function testGetItemsFilterFlags() {
+ let fakeCalendar = {
+ getItems(filter, count, rangeStart, rangeEndEx) {
+ Assert.equal(filter, expected.filter, "getItems called with the right filter");
+ if (expected.rangeStart) {
+ Assert.equal(
+ rangeStart.compare(expected.rangeStart),
+ 0,
+ "getItems called with the right start date"
+ );
+ }
+ if (expected.rangeEndEx) {
+ Assert.equal(
+ rangeEndEx.compare(expected.rangeEndEx),
+ 0,
+ "getItems called with the right end date"
+ );
+ }
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ },
+ };
+
+ // Test the basic item types.
+ // A request for TODO items requires one of the ITEM_FILTER_COMPLETED flags,
+ // if none are supplied then ITEM_FILTER_COMPLETED_ALL is added.
+ // (These flags have no effect on EVENT items.)
+
+ let filter = new calFilter();
+ let expected = {
+ filter: Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ };
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ expected.filter = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ // Test that we get occurrences if we have an end date.
+
+ filter.startDate = cal.createDateTime("20220201T000000Z");
+ filter.endDate = cal.createDateTime("20220301T000000Z");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ expected.rangeStart = filter.startDate;
+ expected.rangeEndEx = filter.endDate;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT | Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.startDate = null;
+ filter.endDate = null;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ delete expected.rangeStart;
+ delete expected.rangeEndEx;
+ filter.getItems(fakeCalendar);
+
+ // Test that completed tasks are correctly filtered.
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_YES;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ filter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO;
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+
+ // Using `applyFilter` needs a selected date or the test dies trying to find the
+ // `currentView` function, which doesn't exist in an XPCShell test.
+ filter.selectedDate = cal.dtz.now();
+ filter.applyFilter("completed");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_YES |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+ filter.getItems(fakeCalendar);
+
+ filter.applyFilter("open");
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+ filter.getItems(fakeCalendar);
+
+ filter.applyFilter();
+ expected.filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+ filter.getItems(fakeCalendar);
+});
diff --git a/comm/calendar/test/unit/test_filter_mixin.js b/comm/calendar/test/unit/test_filter_mixin.js
new file mode 100644
index 0000000000..0d30afb616
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter_mixin.js
@@ -0,0 +1,1083 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+/* globals CalendarFilteredViewMixin, CalReadableStreamFactory */
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+
+class TestCalFilter extends CalendarFilteredViewMixin(class {}) {
+ addedItems = [];
+ removedItems = [];
+ removedCalendarIds = [];
+
+ clearItems() {
+ info("clearItems");
+ this.addedItems.length = 0;
+ this.removedItems.length = 0;
+ this.removedCalendarIds.length = 0;
+ }
+
+ addItems(items) {
+ info("addItems");
+ this.addedItems.push(...items);
+ }
+
+ removeItems(items) {
+ info("removeItems");
+ this.removedItems.push(...items);
+ }
+
+ removeItemsFromCalendar(calendarId) {
+ info("removeItemsFromCalendar");
+ this.removedCalendarIds.push(calendarId);
+ }
+}
+
+let testItems = {};
+let addedTestItems = {};
+
+add_setup(async function () {
+ await new Promise(resolve => do_calendar_startup(resolve));
+
+ for (let [title, startDate, endDate] of [
+ ["before", "20210720", "20210721"],
+ ["during", "20210820", "20210821"],
+ ["after", "20210920", "20210921"],
+ ["overlaps_start", "20210720", "20210804"],
+ ["overlaps_end", "20210820", "20210904"],
+ ["overlaps_both", "20210720", "20210904"],
+ ]) {
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = title;
+ item.startDate = cal.createDateTime(startDate);
+ item.endDate = cal.createDateTime(endDate);
+ testItems[title] = item;
+ }
+
+ let repeatingItem = new CalEvent();
+ repeatingItem.id = cal.getUUID();
+ repeatingItem.title = "repeating";
+ repeatingItem.startDate = cal.createDateTime("20210818T120000");
+ repeatingItem.endDate = cal.createDateTime("20210818T130000");
+ repeatingItem.recurrenceInfo = new CalRecurrenceInfo(repeatingItem);
+ repeatingItem.recurrenceInfo.appendRecurrenceItem(
+ cal.createRecurrenceRule("RRULE:FREQ=DAILY;INTERVAL=5;COUNT=4")
+ );
+ testItems.repeating = repeatingItem;
+});
+
+add_task(async function testAddItems() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ for (let title of ["before", "after"]) {
+ testWidget.clearItems();
+ addedTestItems[title] = await calendar.addItem(testItems[title]);
+ Assert.equal(testWidget.addedItems.length, 0);
+ }
+
+ for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) {
+ testWidget.clearItems();
+ addedTestItems[title] = await calendar.addItem(testItems[title]);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, title);
+ }
+
+ testWidget.clearItems();
+ addedTestItems.repeating = await calendar.addItem(testItems.repeating);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "repeating");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.addedItems[1].title, "repeating");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testRefresh() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ testWidget.startDate = cal.createDateTime("20210801");
+ testWidget.endDate = cal.createDateTime("20210831");
+ await testWidget.refreshItems();
+
+ Assert.equal(testWidget.addedItems.length, 7, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "during", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_start", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[3].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[4].title, "repeating");
+ Assert.equal(testWidget.addedItems[4].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.addedItems[4].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.addedItems[5].title, "repeating");
+ Assert.equal(testWidget.addedItems[5].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.addedItems[5].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.addedItems[6].title, "repeating");
+ Assert.equal(testWidget.addedItems[6].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[6].endDate.icalString, "20210828T130000");
+
+ testWidget.startDate = cal.createDateTime("20210825");
+ testWidget.endDate = cal.createDateTime("20210905");
+ await testWidget.refreshItems();
+
+ Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+ Assert.equal(testWidget.addedItems[3].title, "repeating");
+ Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000");
+ Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000");
+
+ // Verify that refreshing while the widget is inactive doesn't prevent later
+ // attempts to refresh from succeeding.
+ testWidget.deactivate();
+ testWidget.clearItems();
+ Assert.equal(
+ testWidget.addedItems.length,
+ 0,
+ "there should be no items after deactivation and clearing"
+ );
+
+ await testWidget.refreshItems();
+ Assert.equal(testWidget.addedItems.length, 0, "refreshing while inactive should not add items");
+
+ await testWidget.activate();
+ Assert.equal(testWidget.addedItems.length, 4, "getItems returns expected number of items");
+ Assert.equal(testWidget.addedItems[0].title, "overlaps_end", "correct item returned");
+ Assert.equal(testWidget.addedItems[1].title, "overlaps_both", "correct item returned");
+ Assert.equal(testWidget.addedItems[2].title, "repeating");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210828T130000");
+ Assert.equal(testWidget.addedItems[3].title, "repeating");
+ Assert.equal(testWidget.addedItems[3].startDate.icalString, "20210902T120000");
+ Assert.equal(testWidget.addedItems[3].endDate.icalString, "20210902T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testRemoveItems() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ for (let title of ["before", "after"]) {
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems[title]);
+ Assert.equal(testWidget.removedItems.length, 0);
+ }
+
+ for (let title of ["during", "overlaps_start", "overlaps_end", "overlaps_both"]) {
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems[title]);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, title);
+ }
+
+ testWidget.clearItems();
+ await calendar.deleteItem(addedTestItems.repeating);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "repeating");
+ Assert.equal(testWidget.removedItems[0].startDate.icalString, "20210818T120000");
+ Assert.equal(testWidget.removedItems[0].endDate.icalString, "20210818T130000");
+ Assert.equal(testWidget.removedItems[1].title, "repeating");
+ Assert.equal(testWidget.removedItems[1].startDate.icalString, "20210823T120000");
+ Assert.equal(testWidget.removedItems[1].endDate.icalString, "20210823T130000");
+ Assert.equal(testWidget.removedItems[2].title, "repeating");
+ Assert.equal(testWidget.removedItems[2].startDate.icalString, "20210828T120000");
+ Assert.equal(testWidget.removedItems[2].endDate.icalString, "20210828T130000");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyItem() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "changed");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "changed");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T180000");
+ changedItem.endDate = cal.createDateTime("20210805T190000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemOutOfRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210905T170000");
+ changedItem.endDate = cal.createDateTime("20210905T180000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveItemInToRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210705T170000");
+ item.endDate = cal.createDateTime("20210705T180000");
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T170000");
+ changedItem.endDate = cal.createDateTime("20210805T180000");
+
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 1);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyRecurringItem() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "changed");
+ Assert.equal(testWidget.addedItems[1].title, "changed");
+ Assert.equal(testWidget.addedItems[2].title, "changed");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "changed");
+ Assert.equal(testWidget.removedItems[1].title, "changed");
+ Assert.equal(testWidget.removedItems[2].title, "changed");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T180000");
+ changedItem.endDate = cal.createDateTime("20210805T190000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ // This maybe should call modifyItems, but instead it calls addItems and removeItems.
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T180000");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T180000");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T180000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T190000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T190000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T190000");
+
+ addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemOutOfRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210905T170000");
+ changedItem.endDate = cal.createDateTime("20210905T180000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveRecurringItemInToRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210705T170000");
+ item.endDate = cal.createDateTime("20210705T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.startDate = cal.createDateTime("20210805T170000");
+ changedItem.endDate = cal.createDateTime("20210805T180000");
+
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(testWidget.addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(testWidget.addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(testWidget.addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testModifyOccurrence() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ let changedOccurrence = occurrences[1].clone();
+ changedOccurrence.title = "changed";
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(changedOccurrence, occurrences[1]),
+ occurrences[1]
+ );
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "changed");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testDeleteOccurrence() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.equal(testWidget.addedItems[2].title, "change me");
+
+ let changedItem = item.clone();
+ let occurrences = changedItem.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ changedItem.recurrenceInfo.removeOccurrenceAt(occurrences[1].recurrenceId);
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 2);
+ Assert.equal(testWidget.addedItems[0].title, "change me");
+ Assert.equal(testWidget.addedItems[1].title, "change me");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testMoveOccurrenceWithinRange() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "move me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+ item.recurrenceInfo = new CalRecurrenceInfo(item);
+ item.recurrenceInfo.appendRecurrenceItem(cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=3"));
+
+ testWidget.clearItems();
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ testWidget.startDate,
+ testWidget.endDate,
+ 100
+ );
+ let changedOccurrence = occurrences[1].clone();
+ changedOccurrence.startDate = cal.createDateTime("20210806T173000");
+ changedOccurrence.endDate = cal.createDateTime("20210806T183000");
+
+ let addedItems = testWidget.addedItems.slice();
+ testWidget.clearItems();
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(changedOccurrence, occurrences[1]),
+ occurrences[1]
+ );
+
+ Assert.equal(testWidget.addedItems.length, 3);
+ Assert.equal(testWidget.addedItems[0].title, "move me");
+ Assert.equal(testWidget.addedItems[1].title, "move me");
+ Assert.equal(testWidget.addedItems[2].title, "move me");
+ Assert.equal(testWidget.addedItems[0].startDate.icalString, "20210805T170000");
+ Assert.equal(testWidget.addedItems[1].startDate.icalString, "20210806T173000");
+ Assert.equal(testWidget.addedItems[2].startDate.icalString, "20210807T170000");
+ Assert.equal(testWidget.addedItems[0].endDate.icalString, "20210805T180000");
+ Assert.equal(testWidget.addedItems[1].endDate.icalString, "20210806T183000");
+ Assert.equal(testWidget.addedItems[2].endDate.icalString, "20210807T180000");
+ Assert.ok(testWidget.addedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.addedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.addedItems[2].hasSameIds(addedItems[2]));
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ testWidget.clearItems();
+ await calendar.deleteItem(item);
+
+ Assert.equal(testWidget.removedItems.length, 3);
+ Assert.equal(testWidget.removedItems[0].title, "move me");
+ Assert.equal(testWidget.removedItems[1].title, "move me");
+ Assert.equal(testWidget.removedItems[2].title, "move me");
+ Assert.ok(testWidget.removedItems[0].hasSameIds(addedItems[0]));
+ Assert.ok(testWidget.removedItems[1].hasSameIds(addedItems[1]));
+ Assert.ok(testWidget.removedItems[2].hasSameIds(addedItems[2]));
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeTaskCompletion() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let incompleteTask = new CalTodo();
+ incompleteTask.id = cal.getUUID();
+ incompleteTask.title = "incomplete task";
+ incompleteTask.startDate = cal.createDateTime("20210805T170000");
+ incompleteTask.endDate = cal.createDateTime("20210805T180000");
+
+ // Set the widget to only show incomplete tasks.
+
+ testWidget.itemType =
+ Ci.calICalendar.ITEM_FILTER_TYPE_TODO | Ci.calICalendar.ITEM_FILTER_COMPLETED_NO;
+
+ // Add an incomplete task to the calendar.
+
+ testWidget.clearItems();
+ incompleteTask = await calendar.addItem(incompleteTask);
+
+ Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added");
+ Assert.equal(testWidget.addedItems[0].title, "incomplete task");
+
+ // Complete the task. It should be removed from the widget.
+
+ let completeTask = incompleteTask.clone();
+ completeTask.title = "complete task";
+ completeTask.percentComplete = 100;
+
+ testWidget.clearItems();
+ completeTask = await calendar.modifyItem(completeTask, incompleteTask);
+
+ Assert.equal(testWidget.removedItems.length, 1, "complete item was removed");
+ Assert.equal(testWidget.removedItems[0].title, "incomplete task");
+
+ // Mark the task as incomplete again. It should be added back to the widget.
+
+ let incompleteAgainItem = completeTask.clone();
+ incompleteAgainItem.title = "incomplete again task";
+ incompleteAgainItem.percentComplete = 50;
+
+ testWidget.clearItems();
+ await calendar.modifyItem(incompleteAgainItem, completeTask);
+
+ Assert.equal(testWidget.addedItems.length, 1, "incomplete item was added");
+ Assert.equal(testWidget.addedItems[0].title, "incomplete again task");
+
+ // Clean up.
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testDisableEnableCalendar() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ addedTestItems.during = await calendar.addItem(testItems.during);
+
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test disabling and enabling the calendar.
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", true);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", false);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test hiding and showing the calendar.
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", true);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ // Test disabling and enabling the calendar while it is hidden.
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", true);
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.deepEqual(testWidget.removedCalendarIds, [calendar.id]);
+
+ testWidget.clearItems();
+ calendar.setProperty("disabled", false);
+ await new Promise(resolve => do_timeout(500, resolve));
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", true);
+ await TestUtils.waitForCondition(() => testWidget.addedItems.length == 1);
+ Assert.equal(testWidget.addedItems.length, 1);
+ Assert.equal(testWidget.removedItems.length, 0);
+ Assert.equal(testWidget.removedCalendarIds.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeWhileHidden() {
+ const { calendar, testWidget } = await initializeCalendarAndTestWidget();
+
+ let item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = "change me";
+ item.startDate = cal.createDateTime("20210805T170000");
+ item.endDate = cal.createDateTime("20210805T180000");
+
+ testWidget.clearItems();
+ calendar.setProperty("calendar-main-in-composite", false);
+ item = await calendar.addItem(item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ let changedItem = item.clone();
+ changedItem.title = "changed";
+ await calendar.modifyItem(changedItem, item);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ await calendar.deleteItem(changedItem);
+
+ Assert.equal(testWidget.addedItems.length, 0);
+ Assert.equal(testWidget.removedItems.length, 0);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ testWidget.deactivate();
+});
+
+add_task(async function testChangeWhileRefreshing() {
+ // Create a calendar we can control the output of.
+
+ let pumpCalendar = {
+ type: "pump",
+ uri: Services.io.newURI("pump:test-calendar"),
+ getProperty(name) {
+ switch (name) {
+ case "disabled":
+ return false;
+ case "calendar-main-in-composite":
+ return true;
+ }
+ return null;
+ },
+ addObserver() {},
+
+ getItems(filter, count, rangeStart, rangeEndEx) {
+ return CalReadableStreamFactory.createReadableStream({
+ async start(controller) {
+ pumpCalendar.controller = controller;
+ },
+ });
+ },
+ };
+ cal.manager.registerCalendar(pumpCalendar);
+
+ // Create a new widget and a Promise waiting for it to be ready.
+
+ let widget = new TestCalFilter();
+ widget.id = "test-filter";
+ widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+
+ let ready1 = widget.ready;
+ let ready1Resolved, ready1Rejected;
+ ready1.then(
+ arg => {
+ ready1Resolved = true;
+ },
+ arg => {
+ ready1Rejected = true;
+ }
+ );
+
+ // Ask the calendars for items. Get a waiting Promise before and after doing so.
+ // These should be the same as the earlier Promise.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+ Assert.equal(widget.activate(), ready1, ".activate should return the same Promise");
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ // Return some items from the calendar. They should be sent to addItems.
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // Make the widget inactive. This invalidates the earlier call to `refreshItems`.
+
+ widget.deactivate();
+
+ // Return some more items from the calendar. These should be ignored.
+
+ // Even though the data is now invalid the original Promise should not have been replaced with a
+ // new one.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ pumpCalendar.controller.enqueue([testItems.after]);
+ pumpCalendar.controller.close();
+
+ // We're testing that nothing happens. Give it time to potentially happen.
+ await new Promise(resolve => do_timeout(500, resolve));
+
+ Assert.equal(widget.addedItems.length, 1, "no more items added");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+ Assert.equal(ready1Resolved, undefined, "Promise did not yet resolve");
+ Assert.equal(ready1Rejected, undefined, "Promise did not yet reject");
+
+ // Make the widget active again so we can test some other things.
+
+ widget.clearItems();
+
+ Assert.equal(widget.activate(), ready1, ".activate should return the same Promise");
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // ... then before it finishes, force another refresh. We're still waiting for the original
+ // Promise because no refresh has completed yet.
+ // Return a different item, just to be sure we got the one we expected.
+
+ Assert.equal(widget.refreshItems(true), ready1, ".refreshItems should return the same Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.before]);
+ pumpCalendar.controller.close();
+
+ // Finally we have a completed refresh. The Promise should resolve now.
+
+ await ready1;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.before.title, "added item was expected");
+
+ // The Promise should not be replaced until the dates or item type change, or we force a refresh.
+
+ Assert.equal(widget.ready, ready1, ".ready should return the same Promise");
+ Assert.equal(widget.refreshItems(), ready1, ".refreshItems should return the same Promise");
+
+ // Force refresh again. There should be a new ready Promise, since the old one was resolved and
+ // we forced a refresh.
+
+ let ready2 = widget.refreshItems(true);
+ Assert.notEqual(ready2, ready1, ".refreshItems should return a new Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.after]);
+ pumpCalendar.controller.close();
+
+ await ready2;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.after.title, "added item was expected");
+
+ // Change the item type. There should be a new ready Promise, since the old one was resolved and
+ // the item type changed.
+
+ widget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ let ready3 = widget.ready;
+ Assert.notEqual(ready3, ready2, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready3, ".refreshItems should return the same Promise");
+ Assert.equal(widget.addedItems.length, 0, "items were cleared");
+
+ pumpCalendar.controller.enqueue([testItems.during]);
+ pumpCalendar.controller.close();
+
+ await ready3;
+ await TestUtils.waitForCondition(() => widget.addedItems.length == 1, "first added item");
+ Assert.equal(widget.addedItems[0].title, testItems.during.title, "added item was expected");
+
+ // Change the start date. There should be a new ready Promise, since the old one was resolved and
+ // the start date changed.
+
+ widget.startDate = cal.createDateTime("20220317");
+ let ready4 = widget.ready;
+ Assert.notEqual(ready4, ready3, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready4, ".refreshItems should return the same Promise");
+
+ pumpCalendar.controller.close();
+ await ready4;
+
+ // Change the end date. There should be a new ready Promise, since the old one was resolved and
+ // the end date changed.
+
+ widget.endDate = cal.createDateTime("20220318");
+ let ready5 = widget.ready;
+ Assert.notEqual(ready5, ready4, ".ready should return a new Promise");
+ Assert.equal(widget.refreshItems(), ready5, ".refreshItems should return the same Promise");
+
+ pumpCalendar.controller.close();
+ await ready5;
+});
+
+async function initializeCalendarAndTestWidget() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ const testWidget = new TestCalFilter();
+ testWidget.startDate = cal.createDateTime("20210801");
+ testWidget.endDate = cal.createDateTime("20210831");
+ testWidget.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_ALL;
+ await testWidget.activate();
+
+ return { calendar, testWidget };
+}
diff --git a/comm/calendar/test/unit/test_filter_tree_view.js b/comm/calendar/test/unit/test_filter_tree_view.js
new file mode 100644
index 0000000000..97313849c4
--- /dev/null
+++ b/comm/calendar/test/unit/test_filter_tree_view.js
@@ -0,0 +1,451 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
+
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalRecurrenceInfo } = ChromeUtils.import("resource:///modules/CalRecurrenceInfo.jsm");
+const { CalRecurrenceRule } = ChromeUtils.import("resource:///modules/CalRecurrenceRule.jsm");
+
+const { TreeSelection } = ChromeUtils.importESModule(
+ "chrome://messenger/content/tree-selection.mjs"
+);
+
+Services.scriptloader.loadSubScript("chrome://messenger/content/jsTreeView.js");
+Services.scriptloader.loadSubScript("chrome://calendar/content/widgets/calendar-filter.js");
+/* globals CalendarFilteredTreeView */
+Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/widgets/calendar-filter-tree-view.js"
+);
+
+const testItems = {};
+
+add_setup(async function () {
+ await new Promise(resolve => do_calendar_startup(resolve));
+
+ // Create events useful for testing.
+ for (const [title, startDate, endDate] of [
+ ["one", "20221126T010000", "20221126T013000"],
+ ["two", "20221126T020000", "20221126T073000"],
+ ["three", "20221126T030000", "20221126T033000"],
+ ["four", "20221126T040000", "20221126T043000"],
+ ["five", "20221126T050000", "20221126T053000"],
+ ["six", "20221126T060000", "20221126T063000"],
+ ]) {
+ const item = new CalEvent();
+ item.id = cal.getUUID();
+ item.title = title;
+ item.startDate = cal.createDateTime(startDate);
+ item.endDate = cal.createDateTime(endDate);
+ testItems[title] = item;
+ }
+
+ const recurring = new CalEvent();
+ recurring.id = cal.getUUID();
+ recurring.title = "recurring event";
+ recurring.startDate = cal.createDateTime("20221124T053000");
+ recurring.endDate = cal.createDateTime("20221124T063000");
+
+ const recurRule = cal.createRecurrenceRule();
+ recurRule.type = "DAILY";
+ recurRule.byCount = true;
+ recurRule.count = 5;
+
+ const recurInfo = new CalRecurrenceInfo(recurring);
+ recurInfo.appendRecurrenceItem(recurRule);
+
+ recurring.recurrenceInfo = recurInfo;
+
+ testItems.recurring = recurring;
+});
+
+add_task(async function testAddItemsAndSort() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ assertViewContainsItemsInOrder(view);
+
+ await calendar.addItem(testItems.one);
+ assertViewContainsItemsInOrder(view, "one");
+
+ await calendar.addItem(testItems.three);
+ await calendar.addItem(testItems.four);
+ assertViewContainsItemsInOrder(view, "one", "three", "four");
+
+ // Verify that items are sorted by start time by default.
+ await calendar.addItem(testItems.two);
+ assertViewContainsItemsInOrder(view, "one", "two", "three", "four");
+
+ // Change sort to ascending by title.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "four", "one", "three", "two");
+
+ // Verify that items are sorted appropriately on add.
+ await calendar.addItem(testItems.five);
+ assertViewContainsItemsInOrder(view, "five", "four", "one", "three", "two");
+
+ // Change sort to descending by title.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "two", "three", "one", "four", "five");
+
+ await calendar.addItem(testItems.six);
+ assertViewContainsItemsInOrder(view, "two", "three", "six", "one", "four", "five");
+
+ // Re-sort by start date for testing recurrences.
+ view.cycleHeader({ id: "startDate" });
+
+ // Verify that recurring events which occur more than once in the filter range
+ // show up more than once. Also verify that occurrences outside the filter
+ // range do not display.
+ await calendar.addItem(testItems.recurring);
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testInitializeWithExistingCalenderEvents() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ // Add items to the calendar before we initialize the view.
+ await calendar.addItem(testItems.one);
+ await calendar.addItem(testItems.three);
+ await calendar.addItem(testItems.four);
+
+ const view = new CalendarFilteredTreeView();
+ view.startDate = cal.createDateTime("20221126");
+ view.endDate = cal.createDateTime("20221128");
+ view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+
+ const tree = {
+ _batchUpdated: false,
+ _batchDepth: false,
+
+ beginUpdateBatch() {},
+ endUpdateBatch() {},
+ invalidateRow(index) {},
+ };
+ view.setTree(tree);
+
+ // Wait for the view to fetch items and update.
+ await view.activate();
+
+ // Verify that items added to the calendar before initializing are displayed.
+ assertViewContainsItemsInOrder(view, "one", "three", "four");
+
+ // Verify that adding further items causes them to be displayed as well.
+ await calendar.addItem(testItems.two);
+ assertViewContainsItemsInOrder(view, "one", "two", "three", "four");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testRemoveItems() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Record the calendar items so we can use them to delete.
+ const calendarItems = {};
+ for (const key in testItems) {
+ calendarItems[key] = await calendar.addItem(testItems[key]);
+ }
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ await calendar.deleteItem(calendarItems.two);
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Verify that all occurrences of recurring items are removed.
+ await calendar.deleteItem(calendarItems.recurring);
+ assertViewContainsItemsInOrder(view, "one", "three", "four", "five", "six");
+
+ await calendar.deleteItem(calendarItems.three);
+ await calendar.deleteItem(calendarItems.four);
+ assertViewContainsItemsInOrder(view, "one", "five", "six");
+
+ // Verify that sort order doesn't impact removal.
+ view.cycleHeader({ id: "title" });
+ await calendar.deleteItem(calendarItems.five);
+ assertViewContainsItemsInOrder(view, "one", "six");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testClearItems() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Directly call clear, as there isn't a convenient way to trigger it via the
+ // calendar.
+ view.clearItems();
+
+ assertViewContainsItemsInOrder(view);
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testFilterFunction() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add some items which will match the filter and some which won't.
+ const promises = [];
+ for (const key of ["one", "two", "five", "recurring"]) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ // Add a selection to ensure that selections don't persist when filter changes.
+ view.selection.toggleSelect(0);
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(view, "one", "two", "five", "recurring event", "recurring event");
+ Assert.ok(view.selection.isSelected(0), "item 'one' should be selected");
+
+ // Verify that setting filter function appropriately hides non-matching items.
+ view.setFilterFunction(item => {
+ return item.title.includes("f");
+ });
+ assertViewContainsItemsInOrder(view, "five");
+ Assert.ok(!view.selection.isSelected(0), "item 'five' should not be selected");
+
+ // Verify that matching items display when added.
+ await calendar.addItem(testItems.four);
+ assertViewContainsItemsInOrder(view, "four", "five");
+
+ // Verify that sorting respects filter.
+ view.cycleHeader({ id: "title" });
+ assertViewContainsItemsInOrder(view, "five", "four");
+
+ // Verify that non-matching items don't display when added.
+ await calendar.addItem(testItems.six);
+ assertViewContainsItemsInOrder(view, "five", "four");
+
+ // Verify that clearing the filter shows all items properly sorted.
+ view.clearFilter();
+ assertViewContainsItemsInOrder(
+ view,
+ "five",
+ "four",
+ "one",
+ "recurring event",
+ "recurring event",
+ "six",
+ "two"
+ );
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+add_task(async function testRemoveItemsFromCalendar() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ const secondCalendar = CalendarTestUtils.createCalendar("test", "storage");
+ secondCalendar.setProperty("calendar-main-in-composite", true);
+
+ const promises = [];
+
+ // Add some items to the first calendar.
+ for (const key of ["one", "two", "five", "recurring"]) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+
+ // Add the rest to the second calendar.
+ for (const key of ["three", "four", "six"]) {
+ promises.push(secondCalendar.addItem(testItems[key]));
+ }
+
+ await Promise.all(promises);
+
+ // Verify that both calendars are displayed.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Verify that removing items from a specific calendar removes exactly those
+ // events from the view.
+ view.removeItemsFromCalendar(calendar.id);
+
+ assertViewContainsItemsInOrder(view, "three", "four", "six");
+
+ CalendarTestUtils.removeCalendar(calendar);
+ CalendarTestUtils.removeCalendar(secondCalendar);
+ view.deactivate();
+});
+
+add_task(async function testSortRespectsSelection() {
+ const { calendar, view } = await initializeCalendarAndView();
+
+ // Add all calendar items.
+ const promises = [];
+ for (const key in testItems) {
+ promises.push(calendar.addItem(testItems[key]));
+ }
+ await Promise.all(promises);
+
+ view.selection.toggleSelect(1);
+ view.selection.toggleSelect(5);
+ view.selection.toggleSelect(6);
+
+ view.selection.currentIndex = 1;
+
+ // Sanity check.
+ assertViewContainsItemsInOrder(
+ view,
+ "one",
+ "two",
+ "three",
+ "four",
+ "five",
+ "recurring event",
+ "six",
+ "recurring event"
+ );
+
+ // Sanity check selection; two, recurring event, and six should be selected,
+ // nothing else.
+ Assert.ok(view.selection.isSelected(1), "item 'two' should be selected");
+ Assert.ok(view.selection.isSelected(5), "item 'recurring event' should be selected");
+ Assert.ok(view.selection.isSelected(6), "item 'three' should be selected");
+ Assert.equal(view.selection.currentIndex, 1, "item 'two' should be the current selection");
+ for (const row of [0, 2, 3, 4, 7]) {
+ Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`);
+ }
+
+ // Verify that sorting the tree keeps the same events selected.
+ view.cycleHeader({ id: "title" });
+
+ assertViewContainsItemsInOrder(
+ view,
+ "five",
+ "four",
+ "one",
+ "recurring event",
+ "recurring event",
+ "six",
+ "three",
+ "two"
+ );
+
+ Assert.ok(view.selection.isSelected(7), "item 'two' should remain selected");
+ Assert.ok(view.selection.isSelected(5), "item 'recurring event' should remain selected");
+ Assert.ok(view.selection.isSelected(3), "item 'three' should remain selected");
+ Assert.equal(view.selection.currentIndex, 7, "item 'two' should be the current selection");
+ for (const row of [0, 1, 2, 4, 6]) {
+ Assert.ok(!view.selection.isSelected(row), `row ${row} should not be selected`);
+ }
+
+ CalendarTestUtils.removeCalendar(calendar);
+ view.deactivate();
+});
+
+function assertViewContainsItemsInOrder(view, ...expected) {
+ const actual = [];
+ for (let i = 0; i < view.rowCount; i++) {
+ actual.push(view.getCellText(i, { id: "title" }));
+ }
+
+ // Check array length. We don't use Assert.equal() here in order to provide
+ // better debugging output.
+ if (actual.length != expected.length) {
+ Assert.report(
+ actual.length != expected.length,
+ actual,
+ expected,
+ `${JSON.stringify(actual)} should have the same length as ${JSON.stringify(expected)}`
+ );
+ }
+
+ Assert.deepEqual(actual, expected);
+}
+
+async function initializeCalendarAndView() {
+ const calendar = CalendarTestUtils.createCalendar("test", "storage");
+ calendar.setProperty("calendar-main-in-composite", true);
+
+ const view = new CalendarFilteredTreeView();
+ view.startDate = cal.createDateTime("20221126");
+ view.endDate = cal.createDateTime("20221128");
+ view.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_EVENT;
+ view.activate();
+
+ const tree = {
+ _batchUpdated: false,
+ _batchDepth: false,
+
+ beginUpdateBatch() {},
+ endUpdateBatch() {},
+ invalidateRow(index) {},
+ };
+ view.setTree(tree);
+
+ const selection = new TreeSelection(tree);
+ selection.view = view;
+ view.selection = selection;
+ selection.clearSelection();
+
+ return { calendar, view };
+}
diff --git a/comm/calendar/test/unit/test_freebusy.js b/comm/calendar/test/unit/test_freebusy.js
new file mode 100644
index 0000000000..73ac60fb3d
--- /dev/null
+++ b/comm/calendar/test/unit/test_freebusy.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_freebusy();
+ test_period();
+}
+
+function test_freebusy() {
+ let icsService = Cc["@mozilla.org/calendar/ics-service;1"].getService(Ci.calIICSService);
+
+ // Bug 415987 - FREEBUSY decoding does not support comma-separated entries
+ // (https://bugzilla.mozilla.org/show_bug.cgi?id=415987)
+ let fbVal1 = "20080206T160000Z/PT1H";
+ let fbVal2 = "20080206T180000Z/PT1H";
+ let fbVal3 = "20080206T220000Z/PT1H";
+ let data =
+ "BEGIN:VCALENDAR\n" +
+ "BEGIN:VFREEBUSY\n" +
+ "FREEBUSY;FBTYPE=BUSY:" +
+ fbVal1 +
+ "," +
+ fbVal2 +
+ "," +
+ fbVal3 +
+ "\n" +
+ "END:VFREEBUSY\n" +
+ "END:VCALENDAR\n";
+ let fbComp = icsService.parseICS(data).getFirstSubcomponent("VFREEBUSY");
+ equal(fbComp.getFirstProperty("FREEBUSY").value, fbVal1);
+ equal(fbComp.getNextProperty("FREEBUSY").value, fbVal2);
+ equal(fbComp.getNextProperty("FREEBUSY").value, fbVal3);
+}
+
+function test_period() {
+ let period = Cc["@mozilla.org/calendar/period;1"].createInstance(Ci.calIPeriod);
+
+ period.start = cal.createDateTime("20120101T010101");
+ period.end = cal.createDateTime("20120101T010102");
+
+ equal(period.icalString, "20120101T010101/20120101T010102");
+ equal(period.duration.icalString, "PT1S");
+
+ period.icalString = "20120101T010103/20120101T010104";
+
+ equal(period.start.icalString, "20120101T010103");
+ equal(period.end.icalString, "20120101T010104");
+ equal(period.duration.icalString, "PT1S");
+
+ period.icalString = "20120101T010105/PT1S";
+ equal(period.start.icalString, "20120101T010105");
+ equal(period.end.icalString, "20120101T010106");
+ equal(period.duration.icalString, "PT1S");
+
+ period.makeImmutable();
+ // ical.js doesn't support immutability yet
+ // throws(
+ // () => {
+ // period.start = cal.createDateTime("20120202T020202");
+ // },
+ // /0x80460002/,
+ // "Object is Immutable"
+ // );
+ // throws(
+ // () => {
+ // period.end = cal.createDateTime("20120202T020202");
+ // },
+ // /0x80460002/,
+ // "Object is Immutable"
+ // );
+
+ let copy = period.clone();
+ equal(copy.start.icalString, "20120101T010105");
+ equal(copy.end.icalString, "20120101T010106");
+ equal(copy.duration.icalString, "PT1S");
+
+ copy.start.icalString = "20120101T010106";
+ copy.end = cal.createDateTime("20120101T010107");
+
+ equal(period.start.icalString, "20120101T010105");
+ equal(period.end.icalString, "20120101T010106");
+ equal(period.duration.icalString, "PT1S");
+}
diff --git a/comm/calendar/test/unit/test_freebusy_service.js b/comm/calendar/test/unit/test_freebusy_service.js
new file mode 100644
index 0000000000..e19d51f943
--- /dev/null
+++ b/comm/calendar/test/unit/test_freebusy_service.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var freebusy = Cc["@mozilla.org/calendar/freebusy-service;1"].getService(Ci.calIFreeBusyService);
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_found();
+ test_noproviders();
+ test_failure();
+ test_cancel();
+}
+
+function test_found() {
+ _clearProviders();
+
+ equal(_countProviders(), 0);
+
+ let provider1 = {
+ id: 1,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ aListener.onResult(null, []);
+ },
+ };
+
+ let provider2 = {
+ id: 2,
+ called: false,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ ok(!this.called);
+ this.called = true;
+
+ let interval = new cal.provider.FreeBusyInterval(
+ aCalId,
+ Ci.calIFreeBusyInterval.BUSY,
+ aStart,
+ aEnd
+ );
+ aListener.onResult(null, [interval]);
+ },
+ };
+ provider2.wrappedJSObject = provider2;
+
+ freebusy.addProvider(provider1);
+ equal(_countProviders(), 1);
+ freebusy.addProvider(provider2);
+ equal(_countProviders(), 2);
+ freebusy.removeProvider(provider1);
+ equal(_countProviders(), 1);
+ equal(_getFirstProvider().id, 2);
+
+ let listener = {
+ called: false,
+ onResult(request, result) {
+ equal(result.length, 1);
+ equal(result[0].interval.start.icalString, "20120101T010101");
+ equal(result[0].interval.end.icalString, "20120102T010101");
+ equal(result[0].freeBusyType, Ci.calIFreeBusyInterval.BUSY);
+
+ equal(result.length, 1);
+ ok(provider2.called);
+ do_test_finished();
+ },
+ };
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_noproviders() {
+ _clearProviders();
+
+ let listener = {
+ onResult(request, result) {
+ ok(!this.called);
+ equal(result.length, 0);
+ equal(request.status, 0);
+ do_test_finished();
+ },
+ };
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_failure() {
+ _clearProviders();
+
+ let provider = {
+ called: false,
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ ok(!this.called);
+ this.called = true;
+ aListener.onResult({ status: Cr.NS_ERROR_FAILURE }, "notFound");
+ },
+ };
+
+ let listener = {
+ onResult(request, result) {
+ ok(!this.called);
+ equal(result.length, 0);
+ equal(request.status, 0);
+ ok(provider.called);
+ do_test_finished();
+ },
+ };
+
+ freebusy.addProvider(provider);
+
+ do_test_pending();
+ freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+function test_cancel() {
+ _clearProviders();
+
+ let provider = {
+ QueryInterface: ChromeUtils.generateQI(["calIFreeBusyProvider", "calIOperation"]),
+ getFreeBusyIntervals(aCalId, aStart, aEnd, aTypes, aListener) {
+ Services.tm.currentThread.dispatch(
+ {
+ run() {
+ dump("Cancelling freebusy query...");
+ operation.cancel();
+ },
+ },
+ Ci.nsIEventTarget.DISPATCH_NORMAL
+ );
+
+ // No listener call, we emulate a long running search
+ // Do return the operation though
+ return this;
+ },
+
+ isPending: true,
+ cancelCalled: false,
+ status: Cr.NS_OK,
+ cancel() {
+ this.cancelCalled = true;
+ },
+ };
+
+ let listener = {
+ called: false,
+ onResult(request, result) {
+ equal(result, null);
+
+ // If an exception occurs, the operation is not added to the opgroup
+ ok(!provider.cancelCalled);
+ do_test_finished();
+ },
+ };
+
+ freebusy.addProvider(provider);
+
+ do_test_pending();
+ let operation = freebusy.getFreeBusyIntervals(
+ "email",
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120102T010101"),
+ Ci.calIFreeBusyInterval.BUSY_ALL,
+ listener
+ );
+}
+
+// The following functions are not in the interface description and probably
+// don't need to be. Make assumptions about the implementation instead.
+
+function _clearProviders() {
+ freebusy.wrappedJSObject.mProviders = new Set();
+}
+
+function _countProviders() {
+ return freebusy.wrappedJSObject.mProviders.size;
+}
+
+function _getFirstProvider() {
+ return [...freebusy.wrappedJSObject.mProviders][0].wrappedJSObject;
+}
diff --git a/comm/calendar/test/unit/test_hashedarray.js b/comm/calendar/test/unit/test_hashedarray.js
new file mode 100644
index 0000000000..bd1fae68ad
--- /dev/null
+++ b/comm/calendar/test/unit/test_hashedarray.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calHashedArray.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ test_array_base();
+ test_array_sorted();
+ test_hashAccessor();
+}
+
+/**
+ * Helper function to create an item that has a sensible hash id, with the given
+ * title identification.
+ *
+ * @param ident The title to identify the item.
+ * @returns The created item.
+ */
+function hashedCreateItem(ident) {
+ let item = new CalEvent();
+ item.calendar = { id: "test" };
+ item.id = cal.getUUID();
+ item.title = ident;
+ return item;
+}
+
+/**
+ * Comparator function to sort the items by their title
+ *
+ * @param a Object to compare.
+ * @param b Object to compare with.
+ * @returns 0, -1, or 1 (usual comptor meanings)
+ */
+function titleComptor(a, b) {
+ if (a.title > b.title) {
+ return 1;
+ } else if (a.title < b.title) {
+ return -1;
+ }
+ return 0;
+}
+
+/**
+ * Checks if the hashed array accessor functions work for the status of the
+ * items array.
+ *
+ * @param har The Hashed Array
+ * @param testItems The array of test items
+ * @param itemAccessor The accessor func to retrieve the items
+ * @throws Exception If the arrays are not the same.
+ */
+function checkConsistancy(har, testItems, itemAccessor) {
+ itemAccessor =
+ itemAccessor ||
+ function (item) {
+ return item;
+ };
+ for (let idx in testItems) {
+ let testItem = itemAccessor(testItems[idx]);
+ equal(itemAccessor(har.itemByIndex(idx)).title, testItem.title);
+ equal(itemAccessor(har.itemById(testItem.hashId)).title, testItem.title);
+ equal(har.indexOf(testItems[idx]), idx);
+ }
+}
+
+/**
+ * Man, this function is really hard to keep general enough, I'm almost tempted
+ * to duplicate the code. It checks if the remove and modify operations work for
+ * the given hashed array.
+ *
+ * @param har The Hashed Array
+ * @param testItems The js array with the items
+ * @param postprocessFunc (optional) The function to call after each
+ * operation, but before checking consistency.
+ * @param itemAccessor (optional) The function to access the item for an
+ * array element.
+ * @param itemCreator (optional) Function to create a new item for the
+ * array.
+ */
+function testRemoveModify(har, testItems, postprocessFunc, itemAccessor, itemCreator) {
+ postprocessFunc =
+ postprocessFunc ||
+ function (a, b) {
+ return [a, b];
+ };
+ itemCreator = itemCreator || (title => hashedCreateItem(title));
+ itemAccessor =
+ itemAccessor ||
+ function (item) {
+ return item;
+ };
+
+ // Now, delete the second item and check again
+ har.removeById(itemAccessor(testItems[1]).hashId);
+ testItems.splice(1, 1);
+ [har, testItems] = postprocessFunc(har, testItems);
+
+ checkConsistancy(har, testItems, itemAccessor);
+
+ // Try the same by index
+ har.removeByIndex(2);
+ testItems.splice(2, 1);
+ [har, testItems] = postprocessFunc(har, testItems);
+ checkConsistancy(har, testItems, itemAccessor);
+
+ // Try modifying an item
+ let newInstance = itemCreator("z-changed");
+ itemAccessor(newInstance).id = itemAccessor(testItems[0]).id;
+ testItems[0] = newInstance;
+ har.modifyItem(newInstance);
+ [har, testItems] = postprocessFunc(har, testItems);
+ checkConsistancy(har, testItems, itemAccessor);
+}
+
+/**
+ * Tests the basic cal.HashedArray
+ */
+function test_array_base() {
+ let har, testItems;
+
+ // Test normal additions
+ har = new cal.HashedArray();
+ testItems = ["a", "b", "c", "d"].map(hashedCreateItem);
+
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItems);
+ testRemoveModify(har, testItems);
+
+ // Test adding in batch mode
+ har = new cal.HashedArray();
+ testItems = ["e", "f", "g", "h"].map(hashedCreateItem);
+ har.startBatch();
+ testItems.forEach(har.addItem, har);
+ har.endBatch();
+ checkConsistancy(har, testItems);
+ testRemoveModify(har, testItems);
+}
+
+/**
+ * Tests the sorted cal.SortedHashedArray
+ */
+function test_array_sorted() {
+ let har, testItems, testItemsSorted;
+
+ function sortedPostProcess(harParam, tiParam) {
+ tiParam = tiParam.sort(titleComptor);
+ return [harParam, tiParam];
+ }
+
+ // Test normal additions
+ har = new cal.SortedHashedArray(titleComptor);
+ testItems = ["d", "c", "a", "b"].map(hashedCreateItem);
+ testItemsSorted = testItems.sort(titleComptor);
+
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItemsSorted);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess);
+
+ // Test adding in batch mode
+ har = new cal.SortedHashedArray(titleComptor);
+ testItems = ["e", "f", "g", "h"].map(hashedCreateItem);
+ testItemsSorted = testItems.sort(titleComptor);
+ har.startBatch();
+ testItems.forEach(har.addItem, har);
+ har.endBatch();
+ checkConsistancy(har, testItemsSorted);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess);
+}
+
+/**
+ * Tests cal.SortedHashedArray with a custom hashAccessor.
+ */
+function test_hashAccessor() {
+ let har, testItems, testItemsSorted;
+ let comptor = (a, b) => titleComptor(a.item, b.item);
+
+ har = new cal.SortedHashedArray(comptor);
+ har.hashAccessor = function (obj) {
+ return obj.item.hashId;
+ };
+
+ function itemAccessor(obj) {
+ if (!obj) {
+ do_throw("WTF?");
+ }
+ return obj.item;
+ }
+
+ function itemCreator(title) {
+ return { item: hashedCreateItem(title) };
+ }
+
+ function sortedPostProcess(harParam, tiParam) {
+ tiParam = tiParam.sort(comptor);
+ return [harParam, tiParam];
+ }
+
+ testItems = ["d", "c", "a", "b"].map(itemCreator);
+
+ testItemsSorted = testItems.sort(comptor);
+ testItems.forEach(har.addItem, har);
+ checkConsistancy(har, testItemsSorted, itemAccessor);
+ testRemoveModify(har, testItemsSorted, sortedPostProcess, itemAccessor, itemCreator);
+}
diff --git a/comm/calendar/test/unit/test_ics.js b/comm/calendar/test/unit/test_ics.js
new file mode 100644
index 0000000000..e63361fc35
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics.js
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ test_folding();
+ test_icalProps();
+ test_roundtrip();
+ test_duration();
+ test_serialize();
+}
+
+var test_data = [
+ {
+ expectedDateProps: {
+ month: 10,
+ day: 25,
+ year: 2004,
+ isDate: true,
+ },
+ expectedProps: {
+ title: "Christmas",
+ id: "20041119T052239Z-1000472-1-5c0746bb-Oracle",
+ priority: 0,
+ status: "CONFIRMED",
+ },
+ ics:
+ "BEGIN:VCALENDAR\n" +
+ "PRODID:-//ORACLE//NONSGML CSDK 9.0.5 - CalDAVServlet 9.0.5//EN\n" +
+ "VERSION:2.0\n" +
+ "BEGIN:VEVENT\n" +
+ "UID:20041119T052239Z-1000472-1-5c0746bb-Oracle\n" +
+ "ORGANIZER;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CN=Simon Vaillan\n" +
+ " court:mailto:simon.vaillancourt@oracle.com\n" +
+ "SEQUENCE:0\n" +
+ "DTSTAMP:20041124T010028Z\n" +
+ "CREATED:20041119T052239Z\n" +
+ "X-ORACLE-EVENTINSTANCE-GUID:I1+16778354+1+1+438153759\n" +
+ "X-ORACLE-EVENT-GUID:E1+16778354+1+438153759\n" +
+ "X-ORACLE-EVENTTYPE:DAY EVENT\n" +
+ "TRANSP:TRANSPARENT\n" +
+ "SUMMARY:Christmas\n" +
+ "STATUS:CONFIRMED\n" +
+ "PRIORITY:0\n" +
+ "DTSTART;VALUE=DATE:20041125\n" +
+ "DTEND;VALUE=DATE:20041125\n" +
+ "CLASS:PUBLIC\n" +
+ "ATTENDEE;X-ORACLE-GUID=E92F51FB4A48E91CE0305794071A149C;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=James Stevens;PARTSTAT=NEEDS-ACTION:mailto:james.stevens@o\n" +
+ " racle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791C763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=FALSE;CN=Simon Vaillancourt;PARTSTAT=ACCEPTED:mailto:simon.vaillan\n" +
+ " court@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791D763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Bernard Desruisseaux;PARTSTAT=NEEDS-ACTION:mailto:bernard.\n" +
+ " desruisseaux@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791E763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Mario Bonin;PARTSTAT=NEEDS-ACTION:mailto:mario.bonin@oracl\n" +
+ " e.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E9359406791F763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Jeremy Chone;PARTSTAT=NEEDS-ACTION:mailto:jeremy.chone@ora\n" +
+ " cle.com\n" +
+ "ATTENDEE;X-ORACLE-PERSONAL-COMMENT-ISDIRTY=TRUE;X-ORACLE-GUID=E9359406792\n" +
+ " 0763EE0305794071A39A4;CUTYPE=INDIVIDUAL;RSVP=TRUE;CN=Mike Shaver;PARTSTA\n" +
+ " T=NEEDS-ACTION:mailto:mike.x.shaver@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067921763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=David Ball;PARTSTAT=NEEDS-ACTION:mailto:david.ball@oracle.\n" +
+ " com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067922763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Marten Haring;PARTSTAT=NEEDS-ACTION:mailto:marten.den.hari\n" +
+ " ng@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067923763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Peter Egyed;PARTSTAT=NEEDS-ACTION:mailto:peter.egyed@oracl\n" +
+ " e.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067924763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Francois Perrault;PARTSTAT=NEEDS-ACTION:mailto:francois.pe\n" +
+ " rrault@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067925763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Vladimir Vukicevic;PARTSTAT=NEEDS-ACTION:mailto:vladimir.v\n" +
+ " ukicevic@oracle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067926763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Cyrus Daboo;PARTSTAT=NEEDS-ACTION:mailto:daboo@isamet.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067927763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Lisa Dusseault;PARTSTAT=NEEDS-ACTION:mailto:lisa@osafounda\n" +
+ " tion.org\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067928763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Dan Mosedale;PARTSTAT=NEEDS-ACTION:mailto:dan.mosedale@ora\n" +
+ " cle.com\n" +
+ "ATTENDEE;X-ORACLE-GUID=E93594067929763EE0305794071A39A4;CUTYPE=INDIVIDUAL\n" +
+ " ;RSVP=TRUE;CN=Stuart Parmenter;PARTSTAT=NEEDS-ACTION:mailto:stuart.parme\n" +
+ " nter@oracle.com\n" +
+ "END:VEVENT\n" +
+ "END:VCALENDAR\n",
+ },
+ {
+ expectedProps: { "x-magic": "mymagicstring" },
+ ics:
+ "BEGIN:VEVENT\n" +
+ "UID:1\n" +
+ "DTSTART:20070521T100000Z\n" +
+ "X-MAGIC:mymagicstring\n" +
+ "END:VEVENT",
+ },
+];
+
+function test_roundtrip() {
+ function checkEvent(data, event) {
+ checkRoundtrip(data.expectedProps, event);
+
+ // Checking dates
+ if ("expectedDateProps" in data) {
+ checkProps(data.expectedDateProps, event.startDate);
+ checkProps(data.expectedDateProps, event.endDate);
+ }
+ }
+
+ for (let data of test_data) {
+ // First round, use the icalString setter which uses synchronous parsing
+ dump("Checking" + data.ics + "\n");
+ let event = createEventFromIcalString(data.ics);
+ checkEvent(data, event);
+
+ // Now, try the same thing with asynchronous parsing. We need a copy of
+ // the data variable, otherwise javascript will mix the data between
+ // foreach loop iterations.
+ do_test_pending();
+ let thisdata = data;
+ cal.icsService.parseICSAsync(data.ics, {
+ onParsingComplete(rc, rootComp) {
+ try {
+ ok(Components.isSuccessCode(rc));
+ let event2 = new CalEvent();
+ event2.icalComponent = rootComp;
+ checkEvent(thisdata, event2);
+ do_test_finished();
+ } catch (e) {
+ do_throw(e + "\n");
+ do_test_finished();
+ }
+ },
+ });
+ }
+}
+
+function test_folding() {
+ // check folding
+ const id =
+ "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-id-provoking-folding";
+ let todo = new CalTodo(),
+ todo_ = new CalTodo();
+ todo.id = id;
+ todo_.icalString = todo.icalString;
+ equal(todo.id, todo_.id);
+ equal(todo_.icalComponent.getFirstProperty("UID").value, id);
+}
+
+function test_icalProps() {
+ checkIcalProp("ATTACH", new CalAttachment());
+ checkIcalProp("ATTENDEE", new CalAttendee());
+ checkIcalProp("RELATED-TO", new CalRelation());
+}
+
+/*
+ * Helper functions
+ */
+
+function checkIcalProp(aPropName, aObj) {
+ let prop1 = cal.icsService.createIcalProperty(aPropName);
+ let prop2 = cal.icsService.createIcalProperty(aPropName);
+ prop1.value = "foo";
+ prop2.value = "bar";
+ prop1.setParameter("X-FOO", "BAR");
+
+ if (aObj.setParameter) {
+ aObj.icalProperty = prop1;
+ equal(aObj.getParameter("X-FOO"), "BAR");
+ aObj.icalProperty = prop2;
+ equal(aObj.getParameter("X-FOO"), null);
+ } else if (aObj.setProperty) {
+ aObj.icalProperty = prop1;
+ equal(aObj.getProperty("X-FOO"), "BAR");
+ aObj.icalProperty = prop2;
+ equal(aObj.getProperty("X-FOO"), null);
+ }
+}
+
+function checkProps(expectedProps, obj) {
+ for (let key in expectedProps) {
+ equal(obj[key], expectedProps[key]);
+ }
+}
+
+function checkRoundtrip(expectedProps, obj) {
+ let icsdata = obj.icalString;
+ for (let key in expectedProps) {
+ // Need translation
+ let icskey = key;
+ switch (key) {
+ case "id":
+ icskey = "uid";
+ break;
+ case "title":
+ icskey = "summary";
+ break;
+ }
+ ok(icsdata.includes(icskey.toUpperCase()));
+ ok(icsdata.includes(expectedProps[key]));
+ }
+}
+
+function test_duration() {
+ let e = new CalEvent();
+ e.startDate = cal.createDateTime();
+ e.endDate = null;
+ equal(e.duration.icalString, "PT0S");
+}
+
+function test_serialize() {
+ let e = new CalEvent();
+ let prop = cal.icsService.createIcalComponent("VTODO");
+
+ throws(() => {
+ e.icalComponent = prop;
+ }, /Illegal value/);
+}
diff --git a/comm/calendar/test/unit/test_ics_parser.js b/comm/calendar/test/unit/test_ics_parser.js
new file mode 100644
index 0000000000..cd53823935
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics_parser.js
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_roundtrip();
+ test_async();
+ test_failures();
+ test_fake_parent();
+ test_props_comps();
+ test_timezone();
+}
+
+function test_props_comps() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = [
+ "BEGIN:VCALENDAR",
+ "X-WR-CALNAME:CALNAME",
+ "BEGIN:VJOURNAL",
+ "LOCATION:BEFORE TIME",
+ "END:VJOURNAL",
+ "BEGIN:VEVENT",
+ "UID:123",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+ parser.parseString(str);
+
+ let props = parser.getProperties();
+ equal(props.length, 1);
+ equal(props[0].propertyName, "X-WR-CALNAME");
+ equal(props[0].value, "CALNAME");
+
+ let comps = parser.getComponents();
+ equal(comps.length, 1);
+ equal(comps[0].componentType, "VJOURNAL");
+ equal(comps[0].location, "BEFORE TIME");
+}
+
+function test_failures() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+
+ do_test_pending();
+ parser.parseString("BOGUS", {
+ onParsingComplete(rc, opparser) {
+ dump("Note: The previous error message is expected ^^\n");
+ equal(rc, Cr.NS_ERROR_FAILURE);
+ do_test_finished();
+ },
+ });
+
+ // No real error here, but there is a message...
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = ["BEGIN:VWORLD", "BEGIN:VEVENT", "UID:123", "END:VEVENT", "END:VWORLD"].join("\r\n");
+ dump("Note: The following error message is expected:\n");
+ parser.parseString(str);
+ equal(parser.getComponents().length, 0);
+ equal(parser.getItems().length, 0);
+}
+
+function test_fake_parent() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+
+ let str = [
+ "BEGIN:VCALENDAR",
+ "BEGIN:VEVENT",
+ "UID:123",
+ "RECURRENCE-ID:20120101T010101",
+ "DTSTART:20120101T010102",
+ "LOCATION:HELL",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+
+ parser.parseString(str);
+
+ let items = parser.getItems();
+ equal(items.length, 1);
+ let item = items[0].QueryInterface(Ci.calIEvent);
+
+ equal(item.id, "123");
+ ok(!!item.recurrenceInfo);
+ equal(item.startDate.icalString, "20120101T010101");
+ equal(item.getProperty("X-MOZ-FAKED-MASTER"), "1");
+
+ let rinfo = item.recurrenceInfo;
+
+ equal(rinfo.countRecurrenceItems(), 1);
+ let excs = rinfo.getOccurrences(cal.createDateTime("20120101T010101"), null, 0);
+ equal(excs.length, 1);
+ let exc = excs[0].QueryInterface(Ci.calIEvent);
+ equal(exc.startDate.icalString, "20120101T010102");
+
+ equal(parser.getParentlessItems()[0], exc);
+}
+
+function test_async() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let str = [
+ "BEGIN:VCALENDAR",
+ "BEGIN:VTODO",
+ "UID:1",
+ "DTSTART:20120101T010101",
+ "DUE:20120101T010102",
+ "END:VTODO",
+ "BEGIN:VTODO",
+ "UID:2",
+ "DTSTART:20120101T010103",
+ "DUE:20120101T010104",
+ "END:VTODO",
+ "END:VCALENDAR",
+ ].join("\r\n");
+
+ do_test_pending();
+ parser.parseString(str, {
+ onParsingComplete(rc, opparser) {
+ let items = parser.getItems();
+ equal(items.length, 2);
+ let item = items[0];
+ ok(item.isTodo());
+
+ equal(item.entryDate.icalString, "20120101T010101");
+ equal(item.dueDate.icalString, "20120101T010102");
+
+ item = items[1];
+ ok(item.isTodo());
+
+ equal(item.entryDate.icalString, "20120101T010103");
+ equal(item.dueDate.icalString, "20120101T010104");
+
+ do_test_finished();
+ },
+ });
+}
+
+function test_timezone() {
+ // TODO
+}
+
+function test_roundtrip() {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ let str = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "X-PROP:VAL",
+ "BEGIN:VTODO",
+ "UID:1",
+ "DTSTART:20120101T010101",
+ "DUE:20120101T010102",
+ "END:VTODO",
+ "BEGIN:VJOURNAL",
+ "LOCATION:BEFORE TIME",
+ "END:VJOURNAL",
+ "END:VCALENDAR",
+ "",
+ ].join("\r\n");
+
+ parser.parseString(str);
+
+ let items = parser.getItems();
+ serializer.addItems(items);
+
+ parser.getProperties().forEach(serializer.addProperty, serializer);
+ parser.getComponents().forEach(serializer.addComponent, serializer);
+
+ equal(
+ serializer.serializeToString().split("\r\n").sort().join("\r\n"),
+ str.split("\r\n").sort().join("\r\n")
+ );
+
+ // Test parseFromStream
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let stream = serializer.serializeToInputStream();
+
+ parser.parseFromStream(stream);
+
+ items = parser.getItems();
+ let comps = parser.getComponents();
+ let props = parser.getProperties();
+ equal(items.length, 1);
+ equal(comps.length, 1);
+ equal(props.length, 1);
+
+ let everything = items[0].icalString
+ .split("\r\n")
+ .concat(comps[0].serializeToICS().split("\r\n"));
+ everything.push(props[0].icalString.split("\r\n")[0]);
+ everything.sort();
+
+ equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n"));
+
+ // Test serializeToStream/parseFromStream
+ parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let pipe = Cc["@mozilla.org/pipe;1"].createInstance(Ci.nsIPipe);
+ pipe.init(true, true, 0, 0, null);
+
+ serializer.serializeToStream(pipe.outputStream);
+ parser.parseFromStream(pipe.inputStream);
+
+ items = parser.getItems();
+ comps = parser.getComponents();
+ props = parser.getProperties();
+ equal(items.length, 1);
+ equal(comps.length, 1);
+ equal(props.length, 1);
+
+ everything = items[0].icalString.split("\r\n").concat(comps[0].serializeToICS().split("\r\n"));
+ everything.push(props[0].icalString.split("\r\n")[0]);
+ everything.sort();
+
+ equal(everything.join("\r\n"), str.split("\r\n").concat([""]).sort().join("\r\n"));
+}
diff --git a/comm/calendar/test/unit/test_ics_service.js b/comm/calendar/test/unit/test_ics_service.js
new file mode 100644
index 0000000000..1b69802406
--- /dev/null
+++ b/comm/calendar/test/unit/test_ics_service.js
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_iterator();
+ test_icalcomponent();
+ test_icsservice();
+ test_icalstring();
+ test_param();
+ test_icalproperty();
+}
+
+function test_icalstring() {
+ function checkComp(createFunc, icalString, members, properties) {
+ let thing = createFunc(icalString);
+ equal(ics_unfoldline(thing.icalString), icalString + "\r\n");
+
+ if (members) {
+ for (let k in members) {
+ equal(thing[k], members[k]);
+ }
+ }
+
+ if (properties) {
+ for (let k in properties) {
+ if ("getParameter" in thing) {
+ equal(thing.getParameter(k), properties[k]);
+ } else if ("getProperty" in thing) {
+ equal(thing.getProperty(k), properties[k]);
+ }
+ }
+ }
+ return thing;
+ }
+
+ let attach = checkComp(
+ icalString => new CalAttachment(icalString),
+ "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics",
+ { formatType: "text/calendar", encoding: "BASE64" },
+ { FILENAME: "test.ics" }
+ );
+ equal(attach.uri.spec, "http://example.com/test.ics");
+
+ checkComp(
+ icalString => new CalAttendee(icalString),
+ "ATTENDEE;RSVP=TRUE;CN=Name;PARTSTAT=ACCEPTED;CUTYPE=RESOURCE;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com",
+ {
+ id: "mailto:test@example.com",
+ commonName: "Name",
+ rsvp: "TRUE",
+ isOrganizer: false,
+ role: "REQ-PARTICIPANT",
+ participationStatus: "ACCEPTED",
+ userType: "RESOURCE",
+ },
+ { "X-THING": "BAR" }
+ );
+
+ checkComp(
+ icalString => new CalRelation(icalString),
+ "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE",
+ { relType: "SIBLING", relId: "VALUE" },
+ { FOO: "BAR" }
+ );
+
+ let rrule = checkComp(
+ cal.createRecurrenceRule.bind(cal),
+ "RRULE:FREQ=WEEKLY;COUNT=5;INTERVAL=2;BYDAY=MO",
+ { count: 5, isByCount: true, type: "WEEKLY", interval: 2 }
+ );
+ equal(rrule.getComponent("BYDAY").toString(), [2].toString());
+
+ let rdate = checkComp(cal.createRecurrenceDate.bind(cal), "RDATE:20120101T000000", {
+ isNegative: false,
+ });
+ equal(rdate.date.compare(cal.createDateTime("20120101T000000")), 0);
+
+ /* TODO consider removing period support, ics throws badarg
+ let rdateperiod = checkComp(cal.createRecurrenceDate.bind(cal),
+ "RDATE;VALUE=PERIOD;20120101T000000Z/20120102T000000Z");
+ equal(rdate.date.compare(cal.createDateTime("20120101T000000Z")), 0);
+ */
+
+ let exdate = checkComp(cal.createRecurrenceDate.bind(cal), "EXDATE:20120101T000000", {
+ isNegative: true,
+ });
+ equal(exdate.date.compare(cal.createDateTime("20120101T000000")), 0);
+}
+
+function test_icsservice() {
+ function checkProp(createFunc, icalString, members, parameters) {
+ let thing = createFunc(icalString);
+ equal(ics_unfoldline(thing.icalString), icalString + "\r\n");
+
+ for (let k in members) {
+ equal(thing[k], members[k]);
+ }
+
+ for (let k in parameters) {
+ equal(thing.getParameter(k), parameters[k]);
+ }
+ return thing;
+ }
+
+ // Test ::createIcalPropertyFromString
+ checkProp(
+ cal.icsService.createIcalPropertyFromString.bind(cal.icsService),
+ "ATTACH;ENCODING=BASE64;FMTTYPE=text/calendar;FILENAME=test.ics:http://example.com/test.ics",
+ { value: "http://example.com/test.ics", propertyName: "ATTACH" },
+ { ENCODING: "BASE64", FMTTYPE: "text/calendar", FILENAME: "test.ics" }
+ );
+
+ checkProp(
+ cal.icsService.createIcalPropertyFromString.bind(cal.icsService),
+ "DESCRIPTION:new\\nlines\\nare\\ngreat\\,eh?",
+ {
+ value: "new\nlines\nare\ngreat,eh?",
+ valueAsIcalString: "new\\nlines\\nare\\ngreat\\,eh?",
+ },
+ {}
+ );
+
+ // Test ::createIcalProperty
+ let attach2 = cal.icsService.createIcalProperty("ATTACH");
+ equal(attach2.propertyName, "ATTACH");
+ attach2.value = "http://example.com/";
+ equal(attach2.icalString, "ATTACH:http://example.com/\r\n");
+}
+
+function test_icalproperty() {
+ let comp = cal.icsService.createIcalComponent("VEVENT");
+ let prop = cal.icsService.createIcalProperty("PROP");
+ prop.value = "VAL";
+
+ comp.addProperty(prop);
+ equal(prop.parent.toString(), comp.toString());
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.value = "A\nB";
+ equal(prop.value, "A\nB");
+ equal(prop.valueAsIcalString, "A\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.valueAsIcalString = "A\\nB";
+ equal(prop.value, "A\nB");
+ equal(prop.valueAsIcalString, "A\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("DESCRIPTION");
+ prop.value = "A\\nB";
+ equal(prop.value, "A\\nB");
+ equal(prop.valueAsIcalString, "A\\\\nB");
+ equal(prop.valueAsDatetime, null);
+
+ prop = cal.icsService.createIcalProperty("GEO");
+ prop.value = "43.4913662534171;12.085559129715";
+ equal(prop.value, "43.4913662534171;12.085559129715");
+ equal(prop.valueAsIcalString, "43.4913662534171;12.085559129715");
+}
+
+function test_icalcomponent() {
+ let event = cal.icsService.createIcalComponent("VEVENT");
+ let alarm = cal.icsService.createIcalComponent("VALARM");
+ event.addSubcomponent(alarm);
+
+ // Check that the parent works and does not appear on cloned instances
+ let alarm2 = alarm.clone();
+ equal(alarm.parent.toString(), event.toString());
+ equal(alarm2.parent, null);
+
+ function check_getset(key, value) {
+ dump("Checking " + key + " = " + value + "\n");
+ event[key] = value;
+ let valuestring = value.icalString || value;
+ equal(event[key].icalString || event[key], valuestring);
+ equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1);
+ event[key] = value;
+ equal(event.serializeToICS().match(new RegExp(valuestring, "g")).length, 1);
+ }
+
+ let props = [
+ ["uid", "123"],
+ ["prodid", "//abc/123"],
+ ["version", "2.0"],
+ ["method", "REQUEST"],
+ ["status", "TENTATIVE"],
+ ["summary", "sum"],
+ ["description", "descr"],
+ ["location", "here"],
+ ["categories", "cat"],
+ ["URL", "url"],
+ ["priority", 5],
+ ["startTime", cal.createDateTime("20120101T010101")],
+ ["endTime", cal.createDateTime("20120101T010102")],
+ /* TODO readonly, how to set... ["duration", cal.createDuration("PT2S")], */
+ ["dueTime", cal.createDateTime("20120101T010103")],
+ ["stampTime", cal.createDateTime("20120101T010104")],
+ ["createdTime", cal.createDateTime("20120101T010105")],
+ ["completedTime", cal.createDateTime("20120101T010106")],
+ ["lastModified", cal.createDateTime("20120101T010107")],
+ ["recurrenceId", cal.createDateTime("20120101T010108")],
+ ];
+
+ for (let prop of props) {
+ check_getset(...prop);
+ }
+}
+
+function test_param() {
+ let prop = cal.icsService.createIcalProperty("DTSTART");
+ prop.value = "20120101T010101";
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+ prop.setParameter("VALUE", "TEXT");
+ equal(prop.icalString, "DTSTART;VALUE=TEXT:20120101T010101\r\n");
+ prop.removeParameter("VALUE");
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+
+ prop.setParameter("X-FOO", "BAR");
+ equal(prop.icalString, "DTSTART;X-FOO=BAR:20120101T010101\r\n");
+ prop.removeParameter("X-FOO", "BAR");
+ equal(prop.icalString, "DTSTART:20120101T010101\r\n");
+}
+
+function test_iterator() {
+ // Property iterator
+ let comp = cal.icsService.createIcalComponent("VEVENT");
+ let propNames = ["X-ONE", "X-TWO"];
+ for (let i = 0; i < propNames.length; i++) {
+ let prop = cal.icsService.createIcalProperty(propNames[i]);
+ prop.value = "" + (i + 1);
+ comp.addProperty(prop);
+ }
+
+ for (let prop = comp.getFirstProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) {
+ equal(prop.propertyName, propNames.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+ propNames = ["X-ONE", "X-TWO"];
+ for (let prop = comp.getNextProperty("ANY"); prop; prop = comp.getNextProperty("ANY")) {
+ equal(prop.propertyName, propNames.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+
+ // Property iterator with multiple values
+ // eslint-disable-next-line no-useless-concat
+ comp = cal.icsService.parseICS("BEGIN:VEVENT\r\n" + "CATEGORIES:a,b,c\r\n" + "END:VEVENT");
+ let propValues = ["a", "b", "c"];
+ for (
+ let prop = comp.getFirstProperty("CATEGORIES");
+ prop;
+ prop = comp.getNextProperty("CATEGORIES")
+ ) {
+ equal(prop.propertyName, "CATEGORIES");
+ equal(prop.value, propValues.shift());
+ equal(prop.parent.toString(), comp.toString());
+ }
+
+ // Param iterator
+ let dtstart = cal.icsService.createIcalProperty("DTSTART");
+ let params = ["X-ONE", "X-TWO"];
+ for (let i = 0; i < params.length; i++) {
+ dtstart.setParameter(params[i], "" + (i + 1));
+ }
+
+ for (let prop = dtstart.getFirstParameterName(); prop; prop = dtstart.getNextParameterName()) {
+ equal(prop, params.shift());
+ }
+
+ // Now try again, but start with next. Should act like first
+ params = ["X-ONE", "X-TWO"];
+ for (let param = dtstart.getNextParameterName(); param; param = dtstart.getNextParameterName()) {
+ equal(param, params.shift());
+ }
+}
diff --git a/comm/calendar/test/unit/test_imip.js b/comm/calendar/test/unit/test_imip.js
new file mode 100644
index 0000000000..ba9e5f5c6b
--- /dev/null
+++ b/comm/calendar/test/unit/test_imip.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { CalItipEmailTransport } = ChromeUtils.import("resource:///modules/CalItipEmailTransport.jsm");
+
+function itipItemForTest(title, seq) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ itipItem.init(
+ [
+ "BEGIN:VCALENDAR",
+ "METHOD:REQUEST",
+ "BEGIN:VEVENT",
+ "SUMMARY:" + title,
+ "SEQUENCE:" + (seq || 0),
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n")
+ );
+ return itipItem;
+}
+
+let transport = new CalItipEmailTransport();
+
+add_task(function test_title_in_subject() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false);
+ let items = transport._prepareItems(itipItemForTest("foo"));
+ equal(items.subject, "foo");
+});
+
+add_task(function test_title_in_summary() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true);
+ let items = transport._prepareItems(itipItemForTest("bar"));
+ equal(items.subject, "Invitation: bar");
+});
+
+add_task(function test_updated_title_in_subject() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", false);
+ let items = transport._prepareItems(itipItemForTest("foo", 2));
+ equal(items.subject, "foo");
+});
+
+add_task(function test_updated_title_in_summary() {
+ Services.prefs.setBoolPref("calendar.itip.useInvitationSubjectPrefixes", true);
+ let items = transport._prepareItems(itipItemForTest("bar", 2));
+ equal(items.subject, "Updated: bar");
+});
diff --git a/comm/calendar/test/unit/test_invitationutils.js b/comm/calendar/test/unit/test_invitationutils.js
new file mode 100644
index 0000000000..223ff1a6e6
--- /dev/null
+++ b/comm/calendar/test/unit/test_invitationutils.js
@@ -0,0 +1,1654 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calInvitationUtils.jsm
+
+// Make sure that the Europe/Berlin timezone and long datetime format is set.
+Services.prefs.setIntPref("calendar.date.format", 0);
+Services.prefs.setStringPref("calendar.timezone.local", "Europe/Berlin");
+
+/**
+ * typedef {Object} FullIcsValue
+ *
+ * @property {Object<string, string>} params - Parameters for the ics property,
+ * mapping from the parameter name to its value. Each name should be in camel
+ * case. For example, to set "PARTSTAT=ACCEPTED" on the "attendee" property,
+ * use `{ partstat: "ACCEPTED" }`.
+ * @property {string} value - The property value.
+ */
+
+/**
+ * An accepted property value.
+ * typedef {(FullIcsValue|string)} IcsValue
+ */
+
+/**
+ * Get a ics string for an event.
+ *
+ * @param {Object<string, (IcsValue | IcsValue[])>} [eventProperties] - Object
+ * used to set the event properties, mapping from the ics property name to its
+ * value. The property name should be in camel case, so "propertyName" should
+ * be used for the "PROPERTY-NAME" property. The value can either be a single
+ * IcsValue, or a IcsValue array if you want more than one such property
+ * in the event (e.g. to set several "attendee" properties). If you give an
+ * empty value for the property, then the property will be excluded.
+ * For the "attendee" and "organizer" properties, "mailto:" will be prefixed
+ * to the value (unless it is empty).
+ * For the "dtstart" and "dtend" properties, the "TZID=Europe/Berlin"
+ * parameter will be set by default.
+ * Some properties will have default values set if they are not specified in
+ * the object. Note that to avoid a property with a default value, you must
+ * pass an empty value for the property.
+ *
+ * @returns {string} - The ics string.
+ */
+function getIcs(eventProperties) {
+ // we use an unfolded ics blueprint here to make replacing of properties easier
+ let item = ["BEGIN:VCALENDAR", "PRODID:-//Google Inc//Google Calendar V1.0//EN", "VERSION:2.0"];
+
+ let eventPropertyNames = eventProperties ? Object.keys(eventProperties) : [];
+
+ // Convert camel case object property name to upper case with dashes.
+ let convertPropertyName = n => n.replace(/[A-Z]/, match => `-${match}`).toUpperCase();
+
+ let propertyToString = (name, value) => {
+ let propertyString = convertPropertyName(name);
+ let setTzid = false;
+ if (typeof value == "object") {
+ for (let paramName in value.params) {
+ if (paramName == "tzid") {
+ setTzid = true;
+ }
+ propertyString += `;${convertPropertyName(paramName)}=${value.params[paramName]}`;
+ }
+ value = value.value;
+ }
+ if (!setTzid && (name == "dtstart" || name == "dtend")) {
+ propertyString += ";TZID=Europe/Berlin";
+ }
+ if (name == "organizer" || name == "attendee") {
+ value = `mailto:${value}`;
+ }
+ return `${propertyString}:${value}`;
+ };
+
+ let appendProperty = (name, value) => {
+ if (!value) {
+ // leave out.
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach(val => item.push(propertyToString(name, val)));
+ } else {
+ item.push(propertyToString(name, value));
+ }
+ };
+
+ let appendPropertyWithDefault = (name, defaultValue) => {
+ let value = defaultValue;
+ let index = eventPropertyNames.findIndex(n => n == name);
+ if (index >= 0) {
+ value = eventProperties[name];
+ // Remove the name to show that we have already handled it.
+ eventPropertyNames.splice(index, 1);
+ }
+ appendProperty(name, value);
+ };
+
+ appendPropertyWithDefault("method", "METHOD:REQUEST");
+
+ item = item.concat([
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ ]);
+
+ for (let [name, defaultValue] of [
+ ["created", "20150909T180909Z"],
+ ["lastModified", "20150909T181048Z"],
+ ["dtstamp", "20150909T181048Z"],
+ ["uid", "cb189fdc-ed47-4db6-a8d7-31a08802249d"],
+ ["summary", "Test Event"],
+ [
+ "organizer",
+ {
+ params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" },
+ value: "organizer@example.net",
+ },
+ ],
+ [
+ "attendee",
+ {
+ params: { rsvp: "TRUE", cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ ],
+ ["dtstart", "20150909T210000"],
+ ["dtend", "20150909T220000"],
+ ["sequence", "1"],
+ ["transp", "OPAQUE"],
+ ["location", "Room 1"],
+ ["description", "Let us get together"],
+ ["url", "http://www.example.com"],
+ ["attach", "http://www.example.com"],
+ ]) {
+ appendPropertyWithDefault(name, defaultValue);
+ }
+
+ // Add other properties with no default.
+ for (let name of eventPropertyNames) {
+ appendProperty(name, eventProperties[name]);
+ }
+
+ item.push("END:VEVENT");
+ item.push("END:VCALENDAR");
+
+ return item.join("\r\n");
+}
+
+function getEvent(eventProperties) {
+ let item = getIcs(eventProperties);
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ itipItem.init(item);
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(item);
+ return { event: parser.getItems()[0], itipItem };
+}
+
+add_task(async function getItipHeader_test() {
+ let data = [
+ {
+ name: "Organizer sends invite",
+ input: {
+ method: "REQUEST",
+ attendee: "",
+ },
+ expected: "Organizer has invited you to Test Event",
+ },
+ {
+ name: "Organizer cancels event",
+ input: {
+ method: "CANCEL",
+ attendee: "",
+ },
+ expected: "Organizer has canceled this event: Test Event",
+ },
+ {
+ name: "Organizer declines counter proposal",
+ input: {
+ method: "DECLINECOUNTER",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: 'Organizer has declined your counterproposal for "Test Event".',
+ },
+ {
+ name: "Attendee makes counter proposal",
+ input: {
+ method: "COUNTER",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: 'Attendee1 <attendee1@example.net> has made a counterproposal for "Test Event":',
+ },
+ {
+ name: "Attendee replies with acceptance",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "ACCEPTED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Attendee replies with tentative acceptance",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "TENTATIVE", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Attendee replies with declined",
+ input: {
+ method: "REPLY",
+ attendee: {
+ params: { rsvp: "TRUE", cn: "Attendee1", partstat: "DECLINED", role: "REQ-PARTICIPANT" },
+ value: "attendee1@example.net",
+ },
+ },
+ expected: "Attendee1 <attendee1@example.net> has declined your event invitation.",
+ },
+ {
+ name: "Attendee1 accepts and Attendee2 declines",
+ input: {
+ method: "REPLY",
+ attendee: [
+ {
+ params: {
+ rsvp: "TRUE",
+ cn: "Attendee1",
+ partstat: "ACCEPTED",
+ role: "REQ-PARTICIPANT",
+ },
+ value: "attendee1@example.net",
+ },
+ {
+ params: {
+ rsvp: "TRUE",
+ cn: "Attendee2",
+ partstat: "DECLINED",
+ role: "REQ-PARTICIPANT",
+ },
+ value: "attendee2@example.net",
+ },
+ ],
+ },
+ expected: "Attendee1 <attendee1@example.net> has accepted your event invitation.",
+ },
+ {
+ name: "Unsupported method",
+ input: {
+ method: "UNSUPPORTED",
+ attendee: "",
+ },
+ expected: "Event Invitation",
+ },
+ {
+ name: "No method",
+ input: {
+ method: "",
+ attendee: "",
+ },
+ expected: "Event Invitation",
+ },
+ ];
+ for (let test of data) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ let item = getIcs(test.input);
+ itipItem.init(item);
+ if (test.input.attendee) {
+ let sender = new CalAttendee();
+ sender.icalString = item.match(/^ATTENDEE.*$/m)[0];
+ itipItem.sender = sender.id;
+ }
+ equal(cal.invitation.getItipHeader(itipItem), test.expected, `(test ${test.name})`);
+ }
+});
+
+function assertHiddenRow(node, hidden, testName) {
+ let row = node.closest("tr");
+ ok(row, `Row above ${node.id} should exist (test ${testName})`);
+ if (hidden) {
+ equal(
+ node.textContent,
+ "",
+ `Node ${node.id} should be empty below a hidden row (test ${testName})`
+ );
+ ok(row.hidden, `Row above ${node.id} should be hidden (test ${testName})`);
+ } else {
+ ok(!row.hidden, `Row above ${node.id} should not be hidden (test ${testName})`);
+ }
+}
+
+add_task(async function createInvitationOverlay_test() {
+ let data = [
+ {
+ name: "No description",
+ input: { description: "" },
+ expected: { node: "imipHtml-description-content", hidden: true },
+ },
+ {
+ name: "Description with https link",
+ input: { description: "Go to https://www.example.net if you can." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Go to <a class="moz-txt-link-freetext" href="https://www.example.net">' +
+ "https://www.example.net</a> if you can.",
+ },
+ },
+ {
+ name: "Description plain link",
+ input: { description: "Go to www.example.net if you can." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Go to <a class="moz-txt-link-abbreviated" href="http://www.example.net">' +
+ "www.example.net</a> if you can.",
+ },
+ },
+ {
+ name: "Description with +/-",
+ input: { description: "Let's see if +/- still can be displayed." },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Let's see if +/- still can be displayed.",
+ },
+ },
+ {
+ name: "Description with mailto",
+ input: { description: "Or write to mailto:faq@example.net instead." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Or write to <a class="moz-txt-link-freetext" ' +
+ 'href="mailto:faq@example.net">mailto:faq@example.net</a> instead.',
+ },
+ },
+ {
+ name: "Description with email",
+ input: { description: "Or write to faq@example.net instead." },
+ expected: {
+ node: "imipHtml-description-content",
+ content:
+ 'Or write to <a class="moz-txt-link-abbreviated" ' +
+ 'href="mailto:faq@example.net">faq@example.net</a> instead.',
+ },
+ },
+ {
+ name: "Description with emoticon",
+ input: { description: "It's up to you ;-)" },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "It's up to you ;-)",
+ },
+ },
+ {
+ name: "Removed script injection from description",
+ input: {
+ description:
+ 'Let\'s see how evil we can be: <script language="JavaScript">' +
+ 'document.getElementById("imipHtml-description-content")' +
+ '.write("Script embedded!")</script>',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Let's see how evil we can be: ",
+ },
+ },
+ {
+ name: "Removed img src injection from description",
+ input: {
+ description:
+ 'Or we can try: <img src="document.getElementById("imipHtml-' +
+ 'description-descr").innerText" >',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: "Or we can try: ",
+ },
+ },
+ {
+ name: "Description with special characters",
+ input: {
+ description:
+ 'Check <a href="http://example.com">example.com</a>&nbsp;&nbsp;&mdash; only 3 &euro;',
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: 'Check <a href="http://example.com">example.com</a>&nbsp;&nbsp;β€” only 3 €',
+ },
+ },
+ {
+ name: "URL",
+ input: { url: "http://www.example.org/event.ics" },
+ expected: {
+ node: "imipHtml-url-content",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/event.ics">' +
+ "http://www.example.org/event.ics</a>",
+ },
+ },
+ {
+ name: "URL attachment",
+ input: { attach: "http://www.example.org" },
+ expected: {
+ node: "imipHtml-attachments-content",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/">' +
+ "http://www.example.org/</a>",
+ },
+ },
+ {
+ name: "Non-URL attachment is ignored",
+ input: {
+ attach: {
+ params: { fmttype: "text/plain", encoding: "BASE64", value: "BINARY" },
+ value: "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4",
+ },
+ },
+ expected: { node: "imipHtml-attachments-content", hidden: true },
+ },
+ {
+ name: "Several attachments",
+ input: {
+ attach: [
+ "http://www.example.org/first/",
+ "http://www.example.org/second",
+ "file:///N:/folder/third.file",
+ ],
+ },
+ expected: {
+ node: "imipHtml-attachments-content",
+ content:
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/first/">' +
+ "http://www.example.org/first/</a><br>" +
+ '<a class="moz-txt-link-freetext" href="http://www.example.org/second">' +
+ "http://www.example.org/second</a><br>" +
+ '<a class="moz-txt-link-freetext">file:///N:/folder/third.file</a>',
+ },
+ },
+ {
+ name: "Attendees",
+ input: {
+ attendee: [
+ {
+ params: {
+ rsvp: "TRUE",
+ partstat: "NEEDS-ACTION",
+ role: "OPT-PARTICIPANT",
+ cutype: "INDIVIDUAL",
+ cn: '"Attendee 1"',
+ },
+ value: "attendee1@example.net",
+ },
+ {
+ params: {
+ rsvp: "TRUE",
+ partstat: "ACCEPTED",
+ role: "NON-PARTICIPANT",
+ cutype: "GROUP",
+ },
+ value: "attendee2@example.net",
+ },
+ {
+ params: {
+ rsvp: "TRUE",
+ partstat: "TENTATIVE",
+ role: "REQ-PARTICIPANT",
+ cutype: "RESOURCE",
+ },
+ value: "attendee3@example.net",
+ },
+ {
+ params: {
+ rsvp: "TRUE",
+ partstat: "DECLINED",
+ role: "OPT-PARTICIPANT",
+ delegatedFrom: '"mailto:attendee5@example.net"',
+ cutype: "ROOM",
+ },
+ value: "attendee4@example.net",
+ },
+ {
+ params: {
+ rsvp: "TRUE",
+ partstat: "DELEGATED",
+ role: "OPT-PARTICIPANT",
+ delegatedTo: '"mailto:attendee4@example.net"',
+ cutype: "UNKNOWN",
+ },
+ value: "attendee5@example.net",
+ },
+ {
+ params: { rsvp: "TRUE" },
+ value: "attendee6@example.net",
+ },
+ "attendee7@example.net",
+ ],
+ },
+ expected: {
+ node: "imipHtml-attendees-cell",
+ attendeesList: [
+ {
+ name: "Attendee 1 <attendee1@example.net>",
+ title:
+ "Attendee 1 <attendee1@example.net> is an optional " +
+ "participant. Attendee 1 still needs to reply.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ {
+ name: "attendee2@example.net",
+ title:
+ "attendee2@example.net (group) is a non-participant. " +
+ "attendee2@example.net has confirmed attendance.",
+ icon: {
+ attendeerole: "NON-PARTICIPANT",
+ usertype: "GROUP",
+ partstat: "ACCEPTED",
+ },
+ },
+ {
+ name: "attendee3@example.net",
+ title:
+ "attendee3@example.net (resource) is a required " +
+ "participant. attendee3@example.net has confirmed attendance " +
+ "tentatively.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "RESOURCE",
+ partstat: "TENTATIVE",
+ },
+ },
+ {
+ name: "attendee4@example.net (delegated from attendee5@example.net)",
+ title:
+ "attendee4@example.net (room) is an optional participant. " +
+ "attendee4@example.net has declined attendance.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "ROOM",
+ partstat: "DECLINED",
+ },
+ },
+ {
+ name: "attendee5@example.net",
+ title:
+ "attendee5@example.net is an optional participant. " +
+ "attendee5@example.net has delegated attendance to " +
+ "attendee4@example.net.",
+ icon: {
+ attendeerole: "OPT-PARTICIPANT",
+ usertype: "UNKNOWN",
+ partstat: "DELEGATED",
+ },
+ },
+ {
+ name: "attendee6@example.net",
+ title:
+ "attendee6@example.net is a required participant. " +
+ "attendee6@example.net still needs to reply.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ {
+ name: "attendee7@example.net",
+ title:
+ "attendee7@example.net is a required participant. " +
+ "attendee7@example.net still needs to reply.",
+ icon: {
+ attendeerole: "REQ-PARTICIPANT",
+ usertype: "INDIVIDUAL",
+ partstat: "NEEDS-ACTION",
+ },
+ },
+ ],
+ },
+ },
+ {
+ name: "Organizer",
+ input: {
+ organizer: {
+ params: {
+ partstat: "ACCEPTED",
+ role: "CHAIR",
+ cutype: "INDIVIDUAL",
+ cn: '"The Organizer"',
+ },
+ value: "organizer@example.net",
+ },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: {
+ name: "The Organizer <organizer@example.net>",
+ title:
+ "The Organizer <organizer@example.net> chairs the event. " +
+ "The Organizer has confirmed attendance.",
+ icon: {
+ attendeerole: "CHAIR",
+ usertype: "INDIVIDUAL",
+ partstat: "ACCEPTED",
+ },
+ },
+ },
+ },
+ ];
+
+ function assertAttendee(attendee, name, title, icon, testName) {
+ equal(attendee.textContent, name, `Attendee names (test ${testName})`);
+ equal(attendee.getAttribute("title"), title, `Title for ${name} (test ${testName})`);
+ let attendeeIcon = attendee.querySelector(".itip-icon");
+ ok(attendeeIcon, `icon for ${name} should exist (test ${testName})`);
+ for (let attr in icon) {
+ equal(
+ attendeeIcon.getAttribute(attr),
+ icon[attr],
+ `${attr} for icon for ${name} (test ${testName})`
+ );
+ }
+ }
+
+ for (let test of data) {
+ info(`testing ${test.name}`);
+ let { event, itipItem } = getEvent(test.input);
+ let dom = cal.invitation.createInvitationOverlay(event, itipItem);
+ let node = dom.getElementById(test.expected.node);
+ ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`);
+ if (test.expected.hidden) {
+ assertHiddenRow(node, true, test.name);
+ continue;
+ }
+ assertHiddenRow(node, false, test.name);
+
+ if ("attendeesList" in test.expected) {
+ let attendeeNodes = node.querySelectorAll(".attendee-label");
+ // Assert same order.
+ let i;
+ for (i = 0; i < test.expected.attendeesList.length; i++) {
+ let { name, title, icon } = test.expected.attendeesList[i];
+ ok(
+ attendeeNodes.length > i,
+ `Enough attendees for expected attendee #${i} ${name} (test ${test.name})`
+ );
+ assertAttendee(attendeeNodes[i], name, title, icon, test.name);
+ }
+ equal(attendeeNodes.length, i, `Same number of attendees (test ${test.name})`);
+ } else if ("organizer" in test.expected) {
+ let { name, title, icon } = test.expected.organizer;
+ let organizerNode = node.querySelector(".attendee-label");
+ ok(organizerNode, `Organizer node should exist (test ${test.name})`);
+ assertAttendee(organizerNode, name, title, icon, test.name);
+ } else {
+ equal(node.innerHTML, test.expected.content, `innerHTML (test ${test.name})`);
+ }
+ }
+});
+
+add_task(async function updateInvitationOverlay_test() {
+ let data = [
+ {
+ name: "No description before or after",
+ input: { previous: { description: "" }, current: { description: "" } },
+ expected: { node: "imipHtml-description-content", hidden: true },
+ },
+ {
+ name: "Same description before and after",
+ input: {
+ previous: { description: "This is the description" },
+ current: { description: "This is the description" },
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: [{ type: "same", text: "This is the description" }],
+ },
+ },
+ {
+ name: "Added description",
+ input: {
+ previous: { description: "" },
+ current: { description: "Added this description" },
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: [{ type: "added", text: "Added this description" }],
+ },
+ },
+ {
+ name: "Removed description",
+ input: {
+ previous: { description: "Removed this description" },
+ current: { description: "" },
+ },
+ expected: {
+ node: "imipHtml-description-content",
+ content: [{ type: "removed", text: "Removed this description" }],
+ },
+ },
+ {
+ name: "Location",
+ input: {
+ previous: { location: "This place" },
+ current: { location: "Another location" },
+ },
+ expected: {
+ node: "imipHtml-location-content",
+ content: [
+ { type: "added", text: "Another location" },
+ { type: "removed", text: "This place" },
+ ],
+ },
+ },
+ {
+ name: "Summary",
+ input: {
+ previous: { summary: "My invitation" },
+ current: { summary: "My new invitation" },
+ },
+ expected: {
+ node: "imipHtml-summary-content",
+ content: [
+ { type: "added", text: "My new invitation" },
+ { type: "removed", text: "My invitation" },
+ ],
+ },
+ },
+ {
+ name: "When",
+ input: {
+ previous: {
+ dtstart: "20150909T130000",
+ dtend: "20150909T140000",
+ },
+ current: {
+ dtstart: "20150909T140000",
+ dtend: "20150909T150000",
+ },
+ },
+ expected: {
+ node: "imipHtml-when-content",
+ content: [
+ // Time format is platform dependent, so we use alternative result
+ // sets here.
+ // If you get a failure for this test, add your pattern here.
+ {
+ type: "added",
+ text: /^Wednesday, (September 0?9,|0?9 September) 2015 (2:00 PM – 3:00 PM|14:00 – 15:00)$/,
+ },
+ {
+ type: "removed",
+ text: /^Wednesday, (September 0?9,|0?9 September) 2015 (1:00 PM – 2:00 PM|13:00 – 14:00)$/,
+ },
+ ],
+ },
+ },
+ {
+ name: "Organizer same",
+ input: {
+ previous: { organizer: "organizer1@example.net" },
+ current: { organizer: "organizer1@example.net" },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: [{ type: "same", text: "organizer1@example.net" }],
+ },
+ },
+ {
+ name: "Organizer modified",
+ input: {
+ // Modify ROLE from CHAIR to REQ-PARTICIPANT.
+ previous: { organizer: { params: { role: "CHAIR" }, value: "organizer1@example.net" } },
+ current: {
+ organizer: { params: { role: "REQ-PARTICIPANT" }, value: "organizer1@example.net" },
+ },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: [{ type: "modified", text: "organizer1@example.net" }],
+ },
+ },
+ {
+ name: "Organizer added",
+ input: {
+ previous: { organizer: "" },
+ current: { organizer: "organizer2@example.net" },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: [{ type: "added", text: "organizer2@example.net" }],
+ },
+ },
+ {
+ name: "Organizer removed",
+ input: {
+ previous: { organizer: "organizer2@example.net" },
+ current: { organizer: "" },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: [{ type: "removed", text: "organizer2@example.net" }],
+ },
+ },
+ {
+ name: "Organizer changed",
+ input: {
+ previous: { organizer: "organizer1@example.net" },
+ current: { organizer: "organizer2@example.net" },
+ },
+ expected: {
+ node: "imipHtml-organizer-cell",
+ organizer: [
+ { type: "added", text: "organizer2@example.net" },
+ { type: "removed", text: "organizer1@example.net" },
+ ],
+ },
+ },
+ {
+ name: "Attendees: modify one, remove one, add one",
+ input: {
+ previous: {
+ attendee: [
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee1@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee2@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee3@example.net",
+ },
+ ],
+ },
+ current: {
+ attendee: [
+ {
+ // Modify PARTSTAT from NEEDS-ACTION.
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "ACCEPTED" },
+ value: "attendee2@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee3@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee4@example.net",
+ },
+ ],
+ },
+ },
+ expected: {
+ node: "imipHtml-attendees-cell",
+ attendeesList: [
+ { type: "removed", text: "attendee1@example.net" },
+ { type: "modified", text: "attendee2@example.net" },
+ { type: "same", text: "attendee3@example.net" },
+ { type: "added", text: "attendee4@example.net" },
+ ],
+ },
+ },
+ {
+ name: "Attendees: modify one, remove three, add two",
+ input: {
+ previous: {
+ attendee: [
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee-remove1@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "GROUP", partstat: "NEEDS-ACTION" },
+ value: "attendee1@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee-remove2@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee-remove3@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee3@example.net",
+ },
+ ],
+ },
+ current: {
+ attendee: [
+ {
+ // Modify CUTYPE from GROUP.
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee1@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee-add1@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee-add2@example.net",
+ },
+ {
+ params: { rsvp: "TRUE", cutype: "INDIVIDUAL", partstat: "NEEDS-ACTION" },
+ value: "attendee3@example.net",
+ },
+ ],
+ },
+ },
+ expected: {
+ node: "imipHtml-attendees-cell",
+ attendeesList: [
+ { type: "removed", text: "attendee-remove1@example.net" },
+ { type: "modified", text: "attendee1@example.net" },
+ // Added shown first, then removed, and in between the common
+ // attendees.
+ { type: "added", text: "attendee-add1@example.net" },
+ { type: "added", text: "attendee-add2@example.net" },
+ { type: "removed", text: "attendee-remove2@example.net" },
+ { type: "removed", text: "attendee-remove3@example.net" },
+ { type: "same", text: "attendee3@example.net" },
+ ],
+ },
+ },
+ ];
+
+ function assertElement(node, text, type, testName) {
+ let found = node.textContent;
+ if (text instanceof RegExp) {
+ ok(text.test(found), `Text content "${found}" matches regex (test ${testName})`);
+ } else {
+ equal(text, found, `Text content matches (test ${testName})`);
+ }
+ switch (type) {
+ case "added":
+ equal(node.tagName, "INS", `Text "${text}" is inserted (test ${testName})`);
+ ok(node.classList.contains("added"), `Text "${text}" is added (test ${testName})`);
+ break;
+ case "removed":
+ equal(node.tagName, "DEL", `Text "${text}" is deleted (test ${testName})`);
+ ok(node.classList.contains("removed"), `Text "${text}" is removed (test ${testName})`);
+ break;
+ case "modified":
+ ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`);
+ ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`);
+ ok(node.classList.contains("modified"), `Text "${text}" is modified (test ${testName})`);
+ break;
+ case "same":
+ // NOTE: node may be a Text node.
+ ok(node.tagName !== "DEL", `Text "${text}" is not deleted (test ${testName})`);
+ ok(node.tagName !== "INS", `Text "${text}" is not inserted (test ${testName})`);
+ if (node.classList) {
+ ok(!node.classList.contains("added"), `Text "${text}" is not added (test ${testName})`);
+ ok(
+ !node.classList.contains("removed"),
+ `Text "${text}" is not removed (test ${testName})`
+ );
+ ok(
+ !node.classList.contains("modified"),
+ `Text "${text}" is not modified (test ${testName})`
+ );
+ }
+ break;
+ default:
+ ok(false, `Unknown type ${type} for text "${text}" (test ${testName})`);
+ break;
+ }
+ }
+
+ for (let test of data) {
+ info(`testing ${test.name}`);
+ let { event, itipItem } = getEvent(test.input.current);
+ let dom = cal.invitation.createInvitationOverlay(event, itipItem);
+ let { event: oldEvent } = getEvent(test.input.previous);
+ cal.invitation.updateInvitationOverlay(dom, event, itipItem, oldEvent);
+
+ let node = dom.getElementById(test.expected.node);
+ ok(node, `Element with id ${test.expected.node} should exist (test ${test.name})`);
+ if (test.expected.hidden) {
+ assertHiddenRow(node, true, test.name);
+ continue;
+ }
+ assertHiddenRow(node, false, test.name);
+
+ let insertBreaks = false;
+ let nodeList;
+ let expectList;
+
+ if ("attendeesList" in test.expected) {
+ // Insertions, deletions and modifications are all within separate
+ // list-items.
+ nodeList = node.querySelectorAll(":scope > .attendee-list > .attendee-list-item > *");
+ expectList = test.expected.attendeesList;
+ } else if ("organizer" in test.expected) {
+ nodeList = node.childNodes;
+ expectList = test.expected.organizer;
+ } else {
+ nodeList = node.childNodes;
+ expectList = test.expected.content;
+ insertBreaks = true;
+ }
+
+ // Assert in same order.
+ let first = true;
+ let nodeIndex = 0;
+ for (let { text, type } of expectList) {
+ if (first) {
+ first = false;
+ } else if (insertBreaks) {
+ ok(
+ nodeList.length > nodeIndex,
+ `Enough child nodes for expected break node at index ${nodeIndex} (test ${test.name})`
+ );
+ equal(
+ nodeList[nodeIndex].tagName,
+ "BR",
+ `Break node at index ${nodeIndex} (test ${test.name})`
+ );
+ nodeIndex++;
+ }
+
+ ok(
+ nodeList.length > nodeIndex,
+ `Enough child nodes for expected node at index ${nodeIndex} "${text}" (test ${test.name})`
+ );
+ assertElement(nodeList[nodeIndex], text, type, test.name);
+ nodeIndex++;
+ }
+ equal(nodeList.length, nodeIndex, `Covered all nodes (test ${test.name})`);
+ }
+});
+
+add_task(async function getHeaderSection_test() {
+ let data = [
+ {
+ // test #1
+ input: {
+ toList: "recipient@example.net",
+ subject: "Invitation: test subject",
+ identity: {
+ fullName: "Invitation sender",
+ email: "sender@example.net",
+ replyTo: "no-reply@example.net",
+ organization: "Example Net",
+ cc: "cc@example.net",
+ bcc: "bcc@example.net",
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: no-reply@example.net\r\n" +
+ "From: Invitation sender <sender@example.net>\r\n" +
+ "Organization: Example Net\r\n" +
+ "To: recipient@example.net\r\n" +
+ "Subject: Invitation: test subject\r\n" +
+ "Cc: cc@example.net\r\n" +
+ "Bcc: bcc@example.net\r\n",
+ },
+ {
+ // test #2
+ input: {
+ toList: 'rec1@example.net, Recipient 2 <rec2@example.net>, "Rec, 3" <rec3@example.net>',
+ subject: "Invitation: test subject",
+ identity: {
+ fullName: '"invitation, sender"',
+ email: "sender@example.net",
+ replyTo: "no-reply@example.net",
+ organization: "Example Net",
+ cc: 'cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>',
+ bcc: 'bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3" <bcc3@example.net>',
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: no-reply@example.net\r\n" +
+ 'From: "invitation, sender" <sender@example.net>\r\n' +
+ "Organization: Example Net\r\n" +
+ 'To: rec1@example.net, Recipient 2 <rec2@example.net>,\r\n "Rec, 3" <rec3@example.net>\r\n' +
+ "Subject: Invitation: test subject\r\n" +
+ 'Cc: cc1@example.net, Cc 2 <cc2@example.net>, "Cc, 3" <cc3@example.net>\r\n' +
+ 'Bcc: bcc1@example.net, BCc 2 <bcc2@example.net>, "Bcc, 3"\r\n <bcc3@example.net>\r\n',
+ },
+ {
+ // test #3
+ input: {
+ toList: "recipient@example.net",
+ subject: "Invitation: test subject",
+ identity: { email: "sender@example.net" },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "From: sender@example.net\r\n" +
+ "To: recipient@example.net\r\n" +
+ "Subject: Invitation: test subject\r\n",
+ },
+ {
+ // test #4
+ input: {
+ toList: "Max MΓΌller <mueller@example.net>",
+ subject: "Invitation: Diacritis check (üÀé)",
+ identity: {
+ fullName: "RenΓ©",
+ email: "sender@example.net",
+ replyTo: "Max & RenΓ© <no-reply@example.net>",
+ organization: "Max & RenΓ©",
+ cc: "RenΓ© <cc@example.net>",
+ bcc: "RenΓ© <bcc@example.net>",
+ },
+ },
+ expected:
+ "MIME-version: 1.0\r\n" +
+ "Return-path: =?UTF-8?B?TWF4ICYgUmVuw6k=?= <no-reply@example.net>\r\n" +
+ "From: =?UTF-8?B?UmVuw6k=?= <sender@example.net>\r\n" +
+ "Organization: =?UTF-8?B?TWF4ICYgUmVuw6k=?=\r\n" +
+ "To: =?UTF-8?Q?Max_M=C3=BCller?= <mueller@example.net>\r\n" +
+ "Subject: =?UTF-8?B?SW52aXRhdGlvbjogRGlhY3JpdGlzIGNoZWNrICjDvMOk?=\r\n =?UTF-8?B" +
+ "?w6kp?=\r\n" +
+ "Cc: =?UTF-8?B?UmVuw6k=?= <cc@example.net>\r\n" +
+ "Bcc: =?UTF-8?B?UmVuw6k=?= <bcc@example.net>\r\n",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ info(`Running test #${i}`);
+ let identity = MailServices.accounts.createIdentity();
+ identity.email = test.input.identity.email || null;
+ identity.fullName = test.input.identity.fullName || null;
+ identity.replyTo = test.input.identity.replyTo || null;
+ identity.organization = test.input.identity.organization || null;
+ identity.doCc = test.input.identity.doCc || test.input.identity.cc;
+ identity.doCcList = test.input.identity.cc || null;
+ identity.doBcc = test.input.identity.doBcc || test.input.identity.bcc;
+ identity.doBccList = test.input.identity.bcc || null;
+
+ let composeUtils = Cc["@mozilla.org/messengercompose/computils;1"].createInstance(
+ Ci.nsIMsgCompUtils
+ );
+ let messageId = composeUtils.msgGenerateMessageId(identity, null);
+
+ let header = cal.invitation.getHeaderSection(
+ messageId,
+ identity,
+ test.input.toList,
+ test.input.subject
+ );
+ // we test Date and Message-ID headers separately to avoid false positives
+ ok(!!header.match(/Date:.+(?:\n|\r\n|\r)/), "(test #" + i + "): date");
+ ok(!!header.match(/Message-ID:.+(?:\n|\r\n|\r)/), "(test #" + i + "): message-id");
+ equal(
+ header.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""),
+ test.expected.replace(/Date:.+(?:\n|\r\n|\r)/, "").replace(/Message-ID:.+(?:\n|\r\n|\r)/, ""),
+ "(test #" + i + "): all headers"
+ );
+ }
+});
+
+add_task(async function convertFromUnicode_test() {
+ let data = [
+ {
+ // test #1
+ input: "mΓΌller",
+ expected: "müller",
+ },
+ {
+ // test #2
+ input: "muller",
+ expected: "muller",
+ },
+ {
+ // test #3
+ input: "mΓΌller\nmΓΌller",
+ expected: "müller\nmüller",
+ },
+ {
+ // test #4
+ input: "mΓΌller\r\nmΓΌller",
+ expected: "müller\r\nmüller",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(cal.invitation.convertFromUnicode(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(async function encodeUTF8_test() {
+ let data = [
+ {
+ // test #1
+ input: "mΓΌller",
+ expected: "müller",
+ },
+ {
+ // test #2
+ input: "muller",
+ expected: "muller",
+ },
+ {
+ // test #3
+ input: "mΓΌller\nmΓΌller",
+ expected: "müller\r\nmüller",
+ },
+ {
+ // test #4
+ input: "mΓΌller\r\nmΓΌller",
+ expected: "müller\r\nmüller",
+ },
+ {
+ // test #5
+ input: "",
+ expected: "",
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(cal.invitation.encodeUTF8(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(async function encodeMimeHeader_test() {
+ let data = [
+ {
+ // test #1
+ input: {
+ header: "Max MΓΌller <m.mueller@example.net>",
+ isEmail: true,
+ },
+ expected: "=?UTF-8?Q?Max_M=C3=BCller?= <m.mueller@example.net>",
+ },
+ {
+ // test #2
+ input: {
+ header: "Max Mueller <m.mueller@example.net>",
+ isEmail: true,
+ },
+ expected: "Max Mueller <m.mueller@example.net>",
+ },
+ {
+ // test #3
+ input: {
+ header: "MΓΌller & MΓΌller",
+ isEmail: false,
+ },
+ expected: "=?UTF-8?B?TcO8bGxlciAmIE3DvGxsZXI=?=",
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ equal(
+ cal.invitation.encodeMimeHeader(test.input.header, test.input.isEmail),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+});
+
+add_task(async function getRfc5322FormattedDate_test() {
+ let data = {
+ input: [
+ {
+ // test #1
+ date: null,
+ timezone: "America/New_York",
+ },
+ {
+ // test #2
+ date: "Sat, 24 Jan 2015 09:24:49 +0100",
+ timezone: "America/New_York",
+ },
+ {
+ // test #3
+ date: "Sat, 24 Jan 2015 09:24:49 GMT+0100",
+ timezone: "America/New_York",
+ },
+ {
+ // test #4
+ date: "Sat, 24 Jan 2015 09:24:49 GMT",
+ timezone: "America/New_York",
+ },
+ {
+ // test #5
+ date: "Sat, 24 Jan 2015 09:24:49",
+ timezone: "America/New_York",
+ },
+ {
+ // test #6
+ date: "Sat, 24 Jan 2015 09:24:49",
+ timezone: null,
+ },
+ {
+ // test #7
+ date: "Sat, 24 Jan 2015 09:24:49",
+ timezone: "UTC",
+ },
+ {
+ // test #8
+ date: "Sat, 24 Jan 2015 09:24:49",
+ timezone: "floating",
+ },
+ ],
+ expected: /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}$/,
+ };
+
+ let i = 0;
+ let timezone = Services.prefs.getStringPref("calendar.timezone.local", null);
+ for (let test of data.input) {
+ i++;
+ if (test.timezone) {
+ Services.prefs.setStringPref("calendar.timezone.local", test.timezone);
+ } else {
+ Services.prefs.clearUserPref("calendar.timezone.local");
+ }
+ let date = test.date ? new Date(test.date) : null;
+ let re = new RegExp(data.expected);
+ ok(re.test(cal.invitation.getRfc5322FormattedDate(date)), "(test #" + i + ")");
+ }
+ Services.prefs.setStringPref("calendar.timezone.local", timezone);
+});
+
+add_task(async function parseCounter_test() {
+ // We are disabling this rule for a more consistent display of this data
+ /* eslint-disable object-curly-newline */
+ let data = [
+ {
+ name: "Basic test to check all currently supported properties",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ dtstart: "20150910T210000",
+ dtend: "20150910T220000",
+ location: "Room 2",
+ summary: "Test Event 2",
+ attendee: {
+ params: { cn: "Attendee", partstat: "DECLINED", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ dtstamp: "20150909T182048Z",
+ comment: "Sorry, I cannot make it that time.",
+ },
+ },
+ expected: {
+ // Time format is platform dependent, so we use alternative result sets here.
+ // The first two are configurations running for automated tests.
+ // If you get a failure for this test, add your pattern here.
+ result: { descr: "", type: "OK" },
+ differences: {
+ summary: {
+ proposed: "Test Event 2",
+ original: "Test Event",
+ },
+ location: {
+ proposed: "Room 2",
+ original: "Room 1",
+ },
+ dtstart: {
+ proposed:
+ /^Thursday, (September 10,|10 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/,
+ original:
+ /^Wednesday, (September 0?9,|0?9 September) 2015 (9:00 PM|21:00) Europe\/Berlin$/,
+ },
+ dtend: {
+ proposed:
+ /^Thursday, (September 10,|10 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/,
+ original:
+ /^Wednesday, (September 0?9,|0?9 September) 2015 (10:00 PM|22:00) Europe\/Berlin$/,
+ },
+ comment: {
+ proposed: "Sorry, I cannot make it that time.",
+ original: null,
+ },
+ },
+ },
+ },
+ {
+ name: "Test with an unsupported property has been changed",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ attach: "http://www.example2.com",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: { descr: "", type: "OK" },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change not based on the latest update of the invitation",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ dtstamp: "20150909T171048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal not based on the latest event update.",
+ type: "NOTLATESTUPDATE",
+ },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change based on a meanwhile reschuled invitation",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ sequence: "0",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal to an already rescheduled event.",
+ type: "OUTDATED",
+ },
+ differences: { location: { proposed: "Room 2", original: "Room 1" } },
+ },
+ },
+ {
+ name: "Proposed change for an later sequence of the event",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ sequence: "2",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Invalid sequence number in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Proposal to a different event",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ uid: "cb189fdc-0000-0000-0000-31a08802249d",
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ location: "Room 2",
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Proposal with a different organizer",
+ input: {
+ proposed: {
+ method: "COUNTER",
+ organizer: {
+ params: { rsvp: "TRUE", cn: "Organizer", partstat: "ACCEPTED", role: "CHAIR" },
+ value: "organizer2@example.net",
+ },
+ attendee: {
+ params: { cn: "Attendee", partstat: "NEEDS-ACTION", role: "REQ-PARTICIPANT" },
+ value: "attendee@example.net",
+ },
+ dtstamp: "20150909T182048Z",
+ },
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR",
+ },
+ differences: {},
+ },
+ },
+ {
+ name: "Counterproposal without any difference",
+ input: {
+ proposed: { method: "COUNTER" },
+ },
+ expected: {
+ result: {
+ descr: "No difference in counterproposal detected.",
+ type: "NODIFF",
+ },
+ differences: {},
+ },
+ },
+ ];
+ /* eslint-enable object-curly-newline */
+
+ let getItem = function (aProperties) {
+ let item = getIcs(aProperties);
+ return createEventFromIcalString(item);
+ };
+
+ let formatDt = function (aDateTime) {
+ if (!aDateTime) {
+ return null;
+ }
+ let datetime = cal.dtz.formatter.formatDateTime(aDateTime);
+ return datetime + " " + aDateTime.timezone.displayName;
+ };
+
+ for (let test of data) {
+ info(`testing ${test.name}`);
+ let existingItem = getItem();
+ let proposedItem = getItem(test.input.proposed);
+ let parsed = cal.invitation.parseCounter(proposedItem, existingItem);
+
+ equal(parsed.result.type, test.expected.result.type, `(test ${test.name}: result.type)`);
+ equal(parsed.result.descr, test.expected.result.descr, `(test ${test.name}: result.descr)`);
+ let parsedProps = [];
+ let additionalProps = [];
+ let missingProps = [];
+ parsed.differences.forEach(aDiff => {
+ let prop = aDiff.property.toLowerCase();
+ if (prop in test.expected.differences) {
+ let { proposed, original } = test.expected.differences[prop];
+ let foundProposed = aDiff.proposed;
+ let foundOriginal = aDiff.original;
+ if (["dtstart", "dtend"].includes(prop)) {
+ foundProposed = formatDt(foundProposed);
+ foundOriginal = formatDt(foundOriginal);
+ ok(foundProposed, `(test ${test.name}: have proposed time value for ${prop})`);
+ ok(foundOriginal, `(test ${test.name}: have original time value for ${prop})`);
+ }
+
+ if (proposed instanceof RegExp) {
+ ok(
+ proposed.test(foundProposed),
+ `(test ${test.name}: proposed "${foundProposed}" for ${prop} matches expected regex)`
+ );
+ } else {
+ equal(
+ foundProposed,
+ proposed,
+ `(test ${test.name}: proposed for ${prop} matches expected)`
+ );
+ }
+
+ if (original instanceof RegExp) {
+ ok(
+ original.test(foundOriginal),
+ `(test ${test.name}: original "${foundOriginal}" for ${prop} matches expected regex)`
+ );
+ } else {
+ equal(
+ foundOriginal,
+ original,
+ `(test ${test.name}: original for ${prop} matches expected)`
+ );
+ }
+
+ parsedProps.push(prop);
+ } else {
+ additionalProps.push(prop);
+ }
+ });
+ for (let prop in test.expected.differences) {
+ if (!parsedProps.includes(prop)) {
+ missingProps.push(prop);
+ }
+ }
+ ok(
+ additionalProps.length == 0,
+ `(test ${test.name}: should be no additional properties: ${additionalProps})`
+ );
+ ok(
+ missingProps.length == 0,
+ `(test ${test.name}: should be no missing properties: ${missingProps})`
+ );
+ }
+});
diff --git a/comm/calendar/test/unit/test_items.js b/comm/calendar/test/unit/test_items.js
new file mode 100644
index 0000000000..fb7fa38ec5
--- /dev/null
+++ b/comm/calendar/test/unit/test_items.js
@@ -0,0 +1,465 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_aclmanager();
+ test_calendar();
+ test_immutable();
+ test_attendee();
+ test_attachment();
+ test_lastack();
+ test_categories();
+ test_alarm();
+ test_isEvent();
+ test_isTodo();
+ test_recurring_event_properties();
+ test_recurring_todo_properties();
+ test_recurring_event_exception_properties();
+ test_recurring_todo_exception_properties();
+}
+
+function test_aclmanager() {
+ let mockCalendar = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendar"]),
+
+ get superCalendar() {
+ return this;
+ },
+ get aclManager() {
+ return this;
+ },
+
+ getItemEntry(item) {
+ if (item.id == "withentry") {
+ return itemEntry;
+ }
+ return null;
+ },
+ };
+
+ let itemEntry = {
+ QueryInterface: ChromeUtils.generateQI(["calIItemACLEntry"]),
+ userCanModify: true,
+ userCanRespond: false,
+ userCanViewAll: true,
+ userCanViewDateAndTime: false,
+ };
+
+ let event = new CalEvent();
+ event.id = "withentry";
+ event.calendar = mockCalendar;
+
+ equal(event.aclEntry.userCanModify, itemEntry.userCanModify);
+ equal(event.aclEntry.userCanRespond, itemEntry.userCanRespond);
+ equal(event.aclEntry.userCanViewAll, itemEntry.userCanViewAll);
+ equal(event.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime);
+
+ let parentEntry = new CalEvent();
+ parentEntry.id = "parententry";
+ parentEntry.calendar = mockCalendar;
+ parentEntry.parentItem = event;
+
+ equal(parentEntry.aclEntry.userCanModify, itemEntry.userCanModify);
+ equal(parentEntry.aclEntry.userCanRespond, itemEntry.userCanRespond);
+ equal(parentEntry.aclEntry.userCanViewAll, itemEntry.userCanViewAll);
+ equal(parentEntry.aclEntry.userCanViewDateAndTime, itemEntry.userCanViewDateAndTime);
+
+ event = new CalEvent();
+ event.id = "noentry";
+ event.calendar = mockCalendar;
+ equal(event.aclEntry, null);
+}
+
+function test_calendar() {
+ let event = new CalEvent();
+ let parentEntry = new CalEvent();
+
+ let mockCalendar = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendar"]),
+ id: "one",
+ };
+
+ parentEntry.calendar = mockCalendar;
+ event.parentItem = parentEntry;
+
+ notEqual(event.calendar, null);
+ equal(event.calendar.id, "one");
+}
+
+function test_attachment() {
+ let e = new CalEvent();
+
+ let a = new CalAttachment();
+ a.rawData = "horst";
+
+ let b = new CalAttachment();
+ b.rawData = "bruno";
+
+ e.addAttachment(a);
+ equal(e.getAttachments().length, 1);
+
+ e.addAttachment(b);
+ equal(e.getAttachments().length, 2);
+
+ e.removeAttachment(a);
+ equal(e.getAttachments().length, 1);
+
+ e.removeAllAttachments();
+ equal(e.getAttachments().length, 0);
+}
+
+function test_attendee() {
+ let e = new CalEvent();
+ equal(e.getAttendeeById("unknown"), null);
+ equal(e.getAttendees().length, 0);
+
+ let a = new CalAttendee();
+ a.id = "mailto:horst";
+
+ let b = new CalAttendee();
+ b.id = "mailto:bruno";
+
+ e.addAttendee(a);
+ equal(e.getAttendees().length, 1);
+ equal(e.getAttendeeById("mailto:horst"), a);
+
+ e.addAttendee(b);
+ equal(e.getAttendees().length, 2);
+
+ let comp = e.icalComponent;
+ let aprop = comp.getFirstProperty("ATTENDEE");
+ equal(aprop.value, "mailto:horst");
+ aprop = comp.getNextProperty("ATTENDEE");
+ equal(aprop.value, "mailto:bruno");
+ equal(comp.getNextProperty("ATTENDEE"), null);
+
+ e.removeAttendee(a);
+ equal(e.getAttendees().length, 1);
+ equal(e.getAttendeeById("mailto:horst"), null);
+
+ e.removeAllAttendees();
+ equal(e.getAttendees().length, 0);
+}
+
+function test_categories() {
+ let e = new CalEvent();
+
+ equal(e.getCategories().length, 0);
+
+ let cat = ["a", "b", "c"];
+ e.setCategories(cat);
+
+ cat[0] = "err";
+ equal(e.getCategories().join(","), "a,b,c");
+
+ let comp = e.icalComponent;
+ let getter = comp.getFirstProperty.bind(comp);
+
+ cat[0] = "a";
+ while (cat.length) {
+ equal(cat.shift(), getter("CATEGORIES").value);
+ getter = comp.getNextProperty.bind(comp);
+ }
+}
+
+function test_alarm() {
+ let e = new CalEvent();
+ let alarm = new CalAlarm();
+
+ alarm.action = "DISPLAY";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ alarm.alarmDate = cal.createDateTime();
+
+ e.addAlarm(alarm);
+ let ecomp = e.icalComponent;
+ let vcomp = ecomp.getFirstSubcomponent("VALARM");
+ equal(vcomp.serializeToICS(), alarm.icalString);
+
+ let alarm2 = alarm.clone();
+
+ e.addAlarm(alarm2);
+
+ equal(e.getAlarms().length, 2);
+ e.deleteAlarm(alarm);
+ equal(e.getAlarms().length, 1);
+ equal(e.getAlarms()[0], alarm2);
+
+ e.clearAlarms();
+ equal(e.getAlarms().length, 0);
+}
+
+function test_immutable() {
+ let event = new CalEvent();
+
+ let date = cal.createDateTime();
+ date.timezone = cal.timezoneService.getTimezone("Europe/Berlin");
+ event.alarmLastAck = date;
+
+ let org = new CalAttendee();
+ org.id = "one";
+ event.organizer = org;
+
+ let alarm = new CalAlarm();
+ alarm.action = "DISPLAY";
+ alarm.description = "foo";
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = cal.createDuration("PT1S");
+ event.addAlarm(alarm);
+
+ event.setProperty("X-NAME", "X-VALUE");
+ event.setPropertyParameter("X-NAME", "X-PARAM", "X-PARAMVAL");
+
+ event.setCategories(["a", "b", "c"]);
+
+ equal(event.alarmLastAck.timezone.tzid, cal.dtz.UTC.tzid);
+
+ event.makeImmutable();
+
+ // call again, should not throw
+ event.makeImmutable();
+
+ ok(!event.alarmLastAck.isMutable);
+ ok(!org.isMutable);
+ ok(!alarm.isMutable);
+
+ throws(() => {
+ event.alarmLastAck = cal.createDateTime();
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.calendar = null;
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.parentItem = null;
+ }, /Can not modify immutable data container/);
+ throws(() => {
+ event.setCategories(["d", "e", "f"]);
+ }, /Can not modify immutable data container/);
+
+ let event2 = event.clone();
+ event2.organizer.id = "two";
+
+ equal(org.id, "one");
+ equal(event2.organizer.id, "two");
+
+ equal(event2.getProperty("X-NAME"), "X-VALUE");
+ equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), "X-PARAMVAL");
+
+ event2.setPropertyParameter("X-NAME", "X-PARAM", null);
+ equal(event2.getPropertyParameter("X-NAME", "X-PARAM"), null);
+
+ // TODO more clone checks
+}
+
+function test_lastack() {
+ let e = new CalEvent();
+
+ e.alarmLastAck = cal.createDateTime("20120101T010101");
+
+ // Our items don't support this yet
+ // equal(e.getProperty("X-MOZ-LASTACK"), "20120101T010101");
+
+ let comp = e.icalComponent;
+ let prop = comp.getFirstProperty("X-MOZ-LASTACK");
+
+ equal(prop.value, "20120101T010101Z");
+
+ prop.value = "20120101T010102Z";
+
+ e.icalComponent = comp;
+
+ equal(e.alarmLastAck.icalString, "20120101T010102Z");
+}
+
+/**
+ * Test isEvent() returns the correct value for events and todos.
+ */
+function test_isEvent() {
+ let event = new CalEvent();
+ let todo = new CalTodo();
+
+ Assert.ok(event.isEvent(), "isEvent() returns true for events");
+ Assert.ok(!todo.isEvent(), "isEvent() returns false for todos");
+}
+
+/**
+ * Test isTodo() returns the correct value for events and todos.
+ */
+function test_isTodo() {
+ let todo = new CalTodo();
+ let event = new CalEvent();
+
+ Assert.ok(todo.isTodo(), "isTodo() returns true for todos");
+ Assert.ok(!event.isTodo(), "isTodo() returns false for events");
+}
+
+/**
+ * Function for testing that the "properties" property of each supplied
+ * calItemBase occurrence includes those inherited from the parent.
+ *
+ * @param {calItemBase[]} items - A list of item occurrences to test.
+ * @param {calItemBase} parent - The item to use as the parent.
+ * @param {object} [overrides] - A set of key value pairs than can be passed
+ * to indicate what to expect for some properties.
+ */
+function doPropertiesTest(items, parent, overrides = {}) {
+ let skippedProps = ["DTSTART", "DTEND"];
+ let toString = value =>
+ value && value instanceof Ci.calIDateTime ? value.icalString : value && value.toString();
+
+ for (let item of items) {
+ info(`Testing occurrence with recurrenceId="${item.recurrenceId.icalString}...`);
+
+ let parentProperties = new Map(parent.properties);
+ let itemProperties = new Map(item.properties);
+ for (let [name, value] of parentProperties.entries()) {
+ if (!skippedProps.includes(name)) {
+ if (overrides[name]) {
+ Assert.equal(
+ toString(itemProperties.get(name)),
+ toString(overrides[name]),
+ `"${name}" value is value expected by overrides`
+ );
+ } else {
+ Assert.equal(
+ toString(itemProperties.get(name)),
+ toString(value),
+ `"${name}" value is same as parent`
+ );
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Test the "properties" property of a recurring CalEvent inherits parent
+ * properties properly.
+ */
+function test_recurring_event_properties() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ DTSTAMP:20210716T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Parent Event
+ CATEGORIES:Business
+ LOCATION: Mochitest
+ DTSTART:20210716T000000Z
+ DTEND:20210716T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20210719T110000Z
+ DESCRIPTION:This is the main event.
+ END:VEVENT
+ `);
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ doPropertiesTest(occurrences, event.parentItem);
+}
+
+/**
+ * Test the "properties" property of a recurring CalEvent exception inherits
+ * parent properties properly.
+ */
+function test_recurring_event_exception_properties() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ DTSTAMP:20210716T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Parent Event
+ CATEGORIES:Business
+ LOCATION: Mochitest
+ DTSTART:20210716T000000Z
+ DTEND:20210716T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20210719T110000Z
+ DESCRIPTION:This is the main event.
+ END:VEVENT
+ `);
+ let occurrences = event.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ let target = occurrences[0].clone();
+ let newDescription = "This is an exception.";
+ target.setProperty("DESCRIPTION", newDescription);
+ event.parentItem.recurrenceInfo.modifyException(target);
+ target = event.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId);
+ Assert.ok(target);
+ doPropertiesTest([target], event.parentItem, { DESCRIPTION: newDescription });
+}
+
+/**
+ * Test the "properties" property of a recurring CalTodo inherits parent
+ * properties properly.
+ */
+function test_recurring_todo_properties() {
+ let task = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ DTSTAMP:20210716T225440Z
+ UID:673e125d-fe6b-465d-8a38-9c9373ca9705
+ SUMMARY:Main Task
+ RRULE:FREQ=DAILY;UNTIL=20210719T230000Z
+ DTSTART;TZID=America/Port_of_Spain:20210716T190000
+ PERCENT-COMPLETE:0
+ LOCATION:Mochitest
+ DESCRIPTION:This is the main task.
+ END:VTODO
+ `);
+ let occurrences = task.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ doPropertiesTest(occurrences, task.parentItem);
+}
+
+/**
+ * Test the "properties" property of a recurring CalTodo exception inherits
+ * parent properties properly.
+ */
+function test_recurring_todo_exception_properties() {
+ let task = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ DTSTAMP:20210716T225440Z
+ UID:673e125d-fe6b-465d-8a38-9c9373ca9705
+ SUMMARY:Main Task
+ RRULE:FREQ=DAILY;UNTIL=20210719T230000Z
+ DTSTART;TZID=America/Port_of_Spain:20210716T190000
+ PERCENT-COMPLETE:0
+ LOCATION:Mochitest
+ DESCRIPTION:This is the main task.
+ END:VTODO
+ `);
+ let occurrences = task.recurrenceInfo.getOccurrences(
+ cal.createDateTime("20210701"),
+ cal.createDateTime("20210731"),
+ Infinity
+ );
+ let target = occurrences[0].clone();
+ let newDescription = "This is an exception.";
+ target.setProperty("DESCRIPTION", newDescription);
+ task.parentItem.recurrenceInfo.modifyException(target);
+ target = task.parentItem.recurrenceInfo.getExceptionFor(target.recurrenceId);
+ Assert.ok(target);
+ doPropertiesTest([target], task.parentItem, { DESCRIPTION: newDescription });
+}
diff --git a/comm/calendar/test/unit/test_itip_message_sender.js b/comm/calendar/test/unit/test_itip_message_sender.js
new file mode 100644
index 0000000000..77a110a875
--- /dev/null
+++ b/comm/calendar/test/unit/test_itip_message_sender.js
@@ -0,0 +1,358 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalAttendee } = ChromeUtils.import("resource:///modules/CalAttendee.jsm");
+var { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+var { CalItipMessageSender } = ChromeUtils.import("resource:///modules/CalItipMessageSender.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+const identityEmail = "user@example.com";
+const eventOrganizerEmail = "eventorganizer@example.com";
+
+/**
+ * Creates a calendar event mimicking an event to which we have received an
+ * invitation.
+ *
+ * @param {string} organizerEmail - The email address of the event organizer.
+ * @param {string} attendeeEmail - The email address of an attendee who has
+ * accepted the invitation.
+ * @returns {calIItemBase} - The new calendar event.
+ */
+function createIncomingEvent(organizerEmail, attendeeEmail) {
+ const organizerId = cal.email.prependMailTo(organizerEmail);
+ const attendeeId = cal.email.prependMailTo(attendeeEmail);
+
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=${organizerEmail}:${organizerId}
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=${attendeeEmail};:${attendeeId}
+ X-MOZ-RECEIVED-SEQUENCE:0
+ X-MOZ-RECEIVED-DTSTAMP:20210501T000000Z
+ X-MOZ-GENERATION:0
+ END:VEVENT
+ `;
+
+ return new CalEvent(icalString);
+}
+
+let calendar;
+
+/**
+ * Ensure the calendar manager is available, initialize the calendar and
+ * identity we use for testing.
+ */
+add_setup(async function () {
+ do_get_profile();
+
+ await new Promise(resolve => do_load_calmgr(resolve));
+ calendar = CalendarTestUtils.createCalendar("Test", "memory");
+
+ const identity = MailServices.accounts.createIdentity();
+ identity.email = identityEmail;
+
+ const account = MailServices.accounts.createAccount();
+ account.incomingServer = MailServices.accounts.createIncomingServer(
+ `${account.key}user`,
+ "localhost",
+ "none"
+ );
+ account.addIdentity(identity);
+
+ registerCleanupFunction(() => {
+ MailServices.accounts.removeIncomingServer(account.incomingServer, false);
+ MailServices.accounts.removeAccount(account);
+ });
+
+ calendar.setProperty("imip.identity.key", identity.key);
+ calendar.setProperty("organizerId", cal.email.prependMailTo(identityEmail));
+});
+
+add_task(async function testAddAttendeesToOwnEvent() {
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ X-MOZ-SEND-INVITATIONS:TRUE
+ END:VEVENT
+ `;
+
+ const item = new CalEvent(icalString);
+ const savedItem = await calendar.addItem(item);
+
+ // Modify the event to include an attendee not in the original, as well as the
+ // organizer. As of the writing of this test, this is the expected behavior
+ // for adding an attendee to an event which previously had none.
+ const newAttendeeEmail = "foo@example.com";
+ const newAttendee = new CalAttendee();
+ newAttendee.id = newAttendeeEmail;
+
+ const organizer = new CalAttendee();
+ organizer.isOrganizer = true;
+ organizer.id = identityEmail;
+
+ const organizerAsAttendee = new CalAttendee();
+ organizerAsAttendee.id = identityEmail;
+
+ const targetItem = savedItem.clone();
+ targetItem.addAttendee(newAttendee);
+ targetItem.addAttendee(organizer);
+ targetItem.addAttendee(organizerAsAttendee);
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that a sender with an original item and for which the current user is
+ // both an attendee and the organizer will generate a REQUEST, but not send a
+ // message to the organizer.
+ const sender = new CalItipMessageSender(savedItem, null);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "recipient should be the non-organizer attendee"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+
+ // Now also cancel the event. No mail should be sent to self.
+ const targetItem2 = modifiedItem.clone();
+
+ targetItem2.setProperty("STATUS", "CANCELLED");
+ targetItem2.setProperty("SEQUENCE", "2");
+ const modifiedItem2 = await calendar.addItem(targetItem2);
+ const sender2 = new CalItipMessageSender(modifiedItem2, null);
+
+ const result2 = sender2.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem2);
+ Assert.equal(result2, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender2.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg2] = sender2.pendingMessages;
+ Assert.equal(msg2.method, "CANCEL", "deletion message method should be 'CANCEL'");
+ Assert.equal(msg2.recipients.length, 1, "deletion message should have one recipient");
+
+ const [recipient2] = msg2.recipients;
+ Assert.equal(
+ recipient2.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "for deletion message, recipient should be the non-organizer attendee"
+ );
+});
+
+add_task(async function testAddAdditionalAttendee() {
+ const icalString = CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=${identityEmail}:${cal.email.prependMailTo(identityEmail)}
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=other@example.com;:mailto:other@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=${identityEmail};:${cal.email.prependMailTo(identityEmail)}
+ X-MOZ-SEND-INVITATIONS:TRUE
+ END:VEVENT
+ `;
+
+ const item = new CalEvent(icalString);
+ const savedItem = await calendar.addItem(item);
+
+ // Modify the event to include an attendee not in the original.
+ const newAttendeeEmail = "bar@example.com";
+ const newAttendee = new CalAttendee();
+ newAttendee.id = newAttendeeEmail;
+
+ const organizer = new CalAttendee();
+ organizer.isOrganizer = true;
+ organizer.id = identityEmail;
+
+ const organizerAsAttendee = new CalAttendee();
+ organizerAsAttendee.id = identityEmail;
+
+ const targetItem = savedItem.clone();
+ targetItem.addAttendee(newAttendee);
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that adding an attendee won't cause messages to be sent to the
+ // existing attendees.
+ const sender = new CalItipMessageSender(savedItem, null);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REQUEST", "message method should be 'REQUEST'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(newAttendeeEmail),
+ "recipient should be the new attendee"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+});
+
+add_task(async function testInvitationReceived() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ // Test that a sender with no original item and for which the current user is
+ // an attendee but not the organizer (representing a new incoming invitation)
+ // generates a single pending REPLY message on ADD.
+ const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
+ const sender = new CalItipMessageSender(null, currentUserAsAttendee);
+
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.ADD, savedItem);
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "ACCEPTED",
+ "current user's participation status should be 'ACCEPTED'"
+ );
+
+ await calendar.deleteItem(savedItem);
+});
+
+add_task(async function testParticipationStatusUpdated() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ // Modify the event to update the user's participation status.
+ const targetItem = savedItem.clone();
+ const currentUserAsAttendee = targetItem.getAttendeeById(attendeeId);
+ currentUserAsAttendee.participationStatus = "TENTATIVE";
+ const modifiedItem = await calendar.modifyItem(targetItem, savedItem);
+
+ // Test that a sender for which the current user is an attendee but not the
+ // organizer will generate a pending REPLY message on MODIFY.
+ const sender = new CalItipMessageSender(savedItem, currentUserAsAttendee);
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem);
+
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "TENTATIVE",
+ "current user's participation status should be 'TENTATIVE'"
+ );
+
+ await calendar.deleteItem(modifiedItem);
+});
+
+add_task(async function testEventDeleted() {
+ const item = createIncomingEvent(eventOrganizerEmail, identityEmail);
+ const savedItem = await calendar.addItem(item);
+
+ const attendeeId = cal.email.prependMailTo(identityEmail);
+
+ await calendar.deleteItem(savedItem);
+ const currentUserAsAttendee = savedItem.getAttendeeById(attendeeId);
+
+ // Test that a sender with no original item and for which the current user is
+ // an attendee but not the organizer (representing the user deleting an event
+ // from their calendar) generates a single REPLY message to the organizer on
+ // DELETE.
+ const sender = new CalItipMessageSender(null, currentUserAsAttendee);
+ const result = sender.buildOutgoingMessages(Ci.calIOperationListener.DELETE, savedItem);
+
+ Assert.equal(result, 1, "return value should indicate there are pending messages");
+ Assert.equal(sender.pendingMessageCount, 1, "there should be one pending message");
+
+ const [msg] = sender.pendingMessages;
+ Assert.equal(msg.method, "REPLY", "message method should be 'REPLY'");
+ Assert.equal(msg.recipients.length, 1, "message should have one recipient");
+
+ const [recipient] = msg.recipients;
+ Assert.equal(
+ recipient.id,
+ cal.email.prependMailTo(eventOrganizerEmail),
+ "recipient should be the event organizer"
+ );
+
+ const attendeeList = msg.item.getAttendees();
+ Assert.equal(attendeeList.length, 1, "there should be one attendee listed in the message");
+
+ const [attendee] = attendeeList;
+ Assert.equal(attendee.id, attendeeId, "listed attendee should be the current user");
+ Assert.equal(
+ attendee.participationStatus,
+ "DECLINED",
+ "current user's participation status should be 'DECLINED'"
+ );
+});
diff --git a/comm/calendar/test/unit/test_itip_utils.js b/comm/calendar/test/unit/test_itip_utils.js
new file mode 100644
index 0000000000..5c4678ab1d
--- /dev/null
+++ b/comm/calendar/test/unit/test_itip_utils.js
@@ -0,0 +1,831 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+var { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalItipEmailTransport: "resource:///modules/CalItipEmailTransport.jsm",
+});
+
+// tests for calItipUtils.jsm
+
+do_get_profile();
+
+/*
+ * Helper function to get an ics for testing sequence and stamp comparison
+ *
+ * @param {String} aAttendee - A serialized ATTENDEE property
+ * @param {String} aSequence - A serialized SEQUENCE property
+ * @param {String} aDtStamp - A serialized DTSTAMP property
+ * @param {String} aXMozReceivedSequence - A serialized X-MOZ-RECEIVED-SEQUENCE property
+ * @param {String} aXMozReceivedDtStamp - A serialized X-MOZ-RECEIVED-STAMP property
+ */
+function getSeqStampTestIcs(aProperties) {
+ // we make sure to have a dtstamp property to get a valid ics
+ let dtStamp = "20150909T181048Z";
+ let additionalProperties = "";
+ aProperties.forEach(aProp => {
+ if (aProp.startsWith("DTSTAMP:")) {
+ dtStamp = aProp;
+ } else {
+ additionalProperties += "\r\n" + aProp;
+ }
+ });
+
+ return [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "METHOD:REQUEST",
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ "CREATED:20150909T180909Z",
+ "LAST-MODIFIED:20150909T181048Z",
+ dtStamp,
+ "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d",
+ "SUMMARY:Test Event",
+ "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net",
+ "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" +
+ "e@example.net" +
+ additionalProperties,
+ "DTSTART;TZID=Europe/Berlin:20150909T210000",
+ "DTEND;TZID=Europe/Berlin:20150909T220000",
+ "TRANSP:OPAQUE",
+ "LOCATION:Room 1",
+ "DESCRIPTION:Let us get together",
+ "URL:http://www.example.com",
+ "ATTACH:http://www.example.com",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ ].join("\r\n");
+}
+
+function getSeqStampTestItems(aTest) {
+ let items = [];
+ for (let input of aTest.input) {
+ if (input.item) {
+ // in this case, we need to return an event
+ let attendee = "";
+ if ("attendee" in input.item && input.item.attendee != {}) {
+ let att = new CalAttendee();
+ att.id = input.item.attendee.id || "mailto:otherattendee@example.net";
+ if ("receivedSeq" in input.item.attendee && input.item.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.item.attendee.receivedSeq);
+ }
+ if ("receivedStamp" in input.item.attendee && input.item.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.item.attendee.receivedStamp);
+ }
+ }
+ let sequence = "";
+ if ("sequence" in input.item && input.item.sequence.length) {
+ sequence = "SEQUENCE:" + input.item.sequence;
+ }
+ let dtStamp = "DTSTAMP:20150909T181048Z";
+ if ("dtStamp" in input.item && input.item.dtStamp) {
+ dtStamp = "DTSTAMP:" + input.item.dtStamp;
+ }
+ let xMozReceivedSeq = "";
+ if ("xMozReceivedSeq" in input.item && input.item.xMozReceivedSeq.length) {
+ xMozReceivedSeq = "X-MOZ-RECEIVED-SEQUENCE:" + input.item.xMozReceivedSeq;
+ }
+ let xMozReceivedStamp = "";
+ if ("xMozReceivedStamp" in input.item && input.item.xMozReceivedStamp.length) {
+ xMozReceivedStamp = "X-MOZ-RECEIVED-DTSTAMP:" + input.item.xMozReceivedStamp;
+ }
+ let xMsAptSeq = "";
+ if ("xMsAptSeq" in input.item && input.item.xMsAptSeq.length) {
+ xMsAptSeq = "X-MICROSOFT-CDO-APPT-SEQUENCE:" + input.item.xMsAptSeq;
+ }
+ let testItem = new CalEvent();
+ testItem.icalString = getSeqStampTestIcs([
+ attendee,
+ sequence,
+ dtStamp,
+ xMozReceivedSeq,
+ xMozReceivedStamp,
+ xMsAptSeq,
+ ]);
+ items.push(testItem);
+ } else {
+ // in this case, we need to return an attendee
+ let att = new CalAttendee();
+ att.id = input.attendee.id || "mailto:otherattendee@example.net";
+ if (input.attendee.receivedSeq && input.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.attendee.receivedSeq);
+ }
+ if (input.attendee.receivedStamp && input.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.attendee.receivedStamp);
+ }
+ items.push(att);
+ }
+ }
+ return items;
+}
+
+add_task(function test_getMessageSender() {
+ let data = [
+ {
+ input: null,
+ expected: null,
+ },
+ {
+ input: {},
+ expected: null,
+ },
+ {
+ input: { author: "Sender 1 <sender1@example.net>" },
+ expected: "sender1@example.net",
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ equal(cal.itip.getMessageSender(test.input), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getSequence() {
+ // assigning an empty string results in not having the property in the ics here
+ let data = [
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "0", xMozReceivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "0" } }],
+ expected: 0,
+ },
+ {
+ input: [{ item: { sequence: "1", xMozReceivedSeq: "" } }],
+ expected: 1,
+ },
+ {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "1" } }],
+ expected: 1,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "" } }],
+ expected: 0,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "0" } }],
+ expected: 0,
+ },
+ {
+ input: [{ attendee: { receivedSeq: "1" } }],
+ expected: 1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.getSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getStamp() {
+ // assigning an empty string results in not having the property in the ics here. However, there
+ // must be always an dtStamp for item - if it's missing it will be set by the test code to make
+ // sure we get a valid ics
+ let data = [
+ {
+ // !dtStamp && !xMozReceivedStamp => test default value
+ input: [{ item: { dtStamp: "", xMozReceivedStamp: "" } }],
+ expected: "20150909T181048Z",
+ },
+ {
+ // dtStamp && !xMozReceivedStamp => dtStamp
+ input: [{ item: { dtStamp: "20150910T181048Z", xMozReceivedStamp: "" } }],
+ expected: "20150910T181048Z",
+ },
+ {
+ // dtStamp && xMozReceivedStamp => xMozReceivedStamp
+ input: [{ item: { dtStamp: "20150909T181048Z", xMozReceivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z",
+ },
+ {
+ input: [{ attendee: { receivedStamp: "" } }],
+ expected: null,
+ },
+ {
+ input: [{ attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z",
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let result = cal.itip.getStamp(getSeqStampTestItems(test)[0]);
+ if (result) {
+ result = result.icalString;
+ }
+ equal(result, test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compareSequence() {
+ // it is sufficient to test here with sequence for items - full test coverage for
+ // x-moz-received-sequence is already provided by test_compareSequence
+ let data = [
+ {
+ // item1.seq == item2.seq
+ input: [{ item: { sequence: "2" } }, { item: { sequence: "2" } }],
+ expected: 0,
+ },
+ {
+ // item1.seq > item2.seq
+ input: [{ item: { sequence: "3" } }, { item: { sequence: "2" } }],
+ expected: 1,
+ },
+ {
+ // item1.seq < item2.seq
+ input: [{ item: { sequence: "2" } }, { item: { sequence: "3" } }],
+ expected: -1,
+ },
+ {
+ // attendee1.seq == attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 0,
+ },
+ {
+ // attendee1.seq > attendee2.seq
+ input: [{ attendee: { receivedSeq: "3" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 1,
+ },
+ {
+ // attendee1.seq < attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } }, { attendee: { receivedSeq: "3" } }],
+ expected: -1,
+ },
+ {
+ // item.seq == attendee.seq
+ input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 0,
+ },
+ {
+ // item.seq > attendee.seq
+ input: [{ item: { sequence: "3" } }, { attendee: { receivedSeq: "2" } }],
+ expected: 1,
+ },
+ {
+ // item.seq < attendee.seq
+ input: [{ item: { sequence: "2" } }, { attendee: { receivedSeq: "3" } }],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compareStamp() {
+ // it is sufficient to test here with dtstamp for items - full test coverage for
+ // x-moz-received-stamp is already provided by test_compareStamp
+ let data = [
+ {
+ // item1.stamp == item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 0,
+ },
+ {
+ // item1.stamp > item2.stamp
+ input: [{ item: { dtStamp: "20150911T181048Z" } }, { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 1,
+ },
+ {
+ // item1.stamp < item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } }, { item: { dtStamp: "20150911T181048Z" } }],
+ expected: -1,
+ },
+ {
+ // attendee1.stamp == attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // attendee1.stamp > attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // attendee1.stamp < attendee2.stamp
+ input: [
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item.stamp == attendee.stamp
+ input: [
+ { item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // item.stamp > attendee.stamp
+ input: [
+ { item: { dtStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item.stamp < attendee.stamp
+ input: [
+ { item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareStamp(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_compare() {
+ // it is sufficient to test here with items only - full test coverage for attendees or
+ // item/attendee is already provided by test_compareSequence and test_compareStamp
+ let data = [
+ {
+ // item1.seq == item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 0,
+ },
+ {
+ // item1.seq == item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq == item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq > item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: 1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp == item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp > item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ ],
+ expected: -1,
+ },
+ {
+ // item1.seq < item2.seq && item1.stamp < item2.stamp
+ input: [
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+ ],
+ expected: -1,
+ },
+ ];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compare(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+});
+
+add_task(function test_getAttendeesBySender() {
+ let data = [
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user1@example.net",
+ },
+ expected: ["mailto:user1@example.net"],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user3@example.net",
+ },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: "mailto:user3@example.net" },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "user3@example.net",
+ },
+ expected: ["mailto:user1@example.net"],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: "mailto:user1@example.net" },
+ ],
+ sender: "user1@example.net",
+ },
+ expected: ["mailto:user1@example.net", "mailto:user2@example.net"],
+ },
+ {
+ input: { attendees: [], sender: "user1@example.net" },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: "",
+ },
+ expected: [],
+ },
+ {
+ input: {
+ attendees: [
+ { id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null },
+ ],
+ sender: null,
+ },
+ expected: [],
+ },
+ ];
+
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let attendees = [];
+ for (let att of test.input.attendees) {
+ let attendee = new CalAttendee();
+ attendee.id = att.id;
+ if (att.sentBy) {
+ attendee.setProperty("SENT-BY", att.sentBy);
+ }
+ attendees.push(attendee);
+ }
+ let detected = [];
+ cal.itip.getAttendeesBySender(attendees, test.input.sender).forEach(att => {
+ detected.push(att.id);
+ });
+ ok(
+ detected.every(aId => test.expected.includes(aId)),
+ "(test #" + i + " ok1)"
+ );
+ ok(
+ test.expected.every(aId => detected.includes(aId)),
+ "(test #" + i + " ok2)"
+ );
+ }
+});
+
+add_task(function test_resolveDelegation() {
+ let data = [
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' +
+ "tendee1@example.net",
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";CN="Attendee 1":mailto:at' +
+ "tendee1@example.net",
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net";CN="Attendee 2":mailto:atte' +
+ "ndee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "Attendee 2 <attendee2@example.net>",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "attendee2@example.net",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' +
+ "ndee1@example.net",
+ attendees: [
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net";CN="Attendee 1":mailto:atte' +
+ "ndee1@example.net",
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net";CN="Attendee 2":mailto:at' +
+ "tendee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "Attendee 2 <attendee2@example.net>",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee2@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "attendee2@example.net",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee: "ATTENDEE:mailto:attendee1@example.net",
+ attendees: [
+ "ATTENDEE:mailto:attendee1@example.net",
+ "ATTENDEE:mailto:attendee2@example.net",
+ ],
+ },
+ expected: {
+ delegatees: "",
+ delegators: "",
+ },
+ },
+ {
+ input: {
+ attendee:
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' +
+ 'ndee3@example.net":mailto:attendee1@example.net',
+ attendees: [
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee2@example.net";DELEGATED-TO="mailto:atte' +
+ 'ndee3@example.net":mailto:attendee1@example.net',
+ 'ATTENDEE;DELEGATED-TO="mailto:attendee1@example.net":mailto:attendee2@example.net',
+ 'ATTENDEE;DELEGATED-FROM="mailto:attendee1@example.net":mailto:attendee3@example.net',
+ ],
+ },
+ expected: {
+ delegatees: "attendee3@example.net",
+ delegators: "attendee2@example.net",
+ },
+ },
+ ];
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let attendees = [];
+ for (let att of test.input.attendees) {
+ let attendee = new CalAttendee();
+ attendee.icalString = att;
+ attendees.push(attendee);
+ }
+ let attendee = new CalAttendee();
+ attendee.icalString = test.input.attendee;
+ let result = cal.itip.resolveDelegation(attendee, attendees);
+ equal(result.delegatees, test.expected.delegatees, "(test #" + i + " - delegatees)");
+ equal(result.delegators, test.expected.delegators, "(test #" + i + " - delegators)");
+ }
+});
+
+/**
+ * Tests the various ways to use the getInvitedAttendee function.
+ */
+add_task(async function test_getInvitedAttendee() {
+ class MockCalendar {
+ supportsScheduling = true;
+
+ constructor(invitedAttendee) {
+ this.invitedAttendee = invitedAttendee;
+ }
+
+ getSchedulingSupport() {
+ return this;
+ }
+
+ getInvitedAttendee() {
+ return this.invitedAttendee;
+ }
+ }
+
+ let invitedAttendee = new CalAttendee();
+ invitedAttendee.id = "mailto:invited@example.com";
+
+ let calendar = new MockCalendar(invitedAttendee);
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ ORGANIZER;CN=events@example.com:mailto:events@example.com
+ ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;
+ RSVP=TRUE;CN=invited@example.com;:mailto:invited@example.com
+ END:VEVENT
+ `);
+
+ // No calendar configured or provided.
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy when item has no calendar and none provided"
+ );
+
+ // No calendar configured but one provided.
+ Assert.ok(
+ cal.itip.getInvitedAttendee(event, calendar) == invitedAttendee,
+ "returns the result from the provided calendar when item has none configured"
+ );
+
+ // Calendar configured, none provided.
+ event.calendar = calendar;
+ Assert.ok(
+ cal.itip.getInvitedAttendee(event) == invitedAttendee,
+ "returns the result of the item's calendar when calendar not provided"
+ );
+
+ // Calendar configured, one provided.
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event, new MockCalendar()),
+ "returns the result of the provided calendar even if item's calendar is configured"
+ );
+
+ // Calendar does not implement nsISchedulingSupport.
+ calendar.supportsScheduling = false;
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy if the calendar does not indicate nsISchedulingSupport"
+ );
+
+ // X-MOZ-INVITED-ATTENDEE set on event.
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:invited@example.com");
+
+ let attendee = cal.itip.getInvitedAttendee(event);
+ Assert.ok(
+ attendee && attendee.id == "mailto:invited@example.com",
+ "returns the attendee matching X-MOZ-INVITED-ATTENDEE if set"
+ );
+
+ // X-MOZ-INVITED-ATTENDEE set to non-existent attendee
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:nobody@example.com");
+ Assert.ok(
+ !cal.itip.getInvitedAttendee(event),
+ "returns falsy for non-existent X-MOZ-INVITED-ATTENDEE"
+ );
+});
+
+/**
+ * Tests the getImipTransport function returns the correct calIItipTransport.
+ */
+add_task(function test_getImipTransport() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20210105T000000Z
+ DTSTAMP:20210501T000000Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Test Invitation
+ DTSTART:20210105T000000Z
+ DTEND:20210105T100000Z
+ STATUS:CONFIRMED
+ SUMMARY:Test Event
+ END:VEVENT
+ `);
+
+ // Without X-MOZ-INVITED-ATTENDEE property.
+ let account1 = MailServices.accounts.createAccount();
+ let identity1 = MailServices.accounts.createIdentity();
+ identity1.email = "id1@example.com";
+ account1.addIdentity(identity1);
+
+ let calendarTransport = new CalItipEmailTransport(account1, identity1);
+ event.calendar = {
+ getProperty(key) {
+ switch (key) {
+ case "itip.transport":
+ return calendarTransport;
+ case "imip.idenity":
+ return identity1;
+ default:
+ return null;
+ }
+ },
+ };
+
+ Assert.ok(
+ cal.itip.getImipTransport(event) == calendarTransport,
+ "returns the calendar's transport when no X-MOZ-INVITED-ATTENDEE property"
+ );
+
+ // With X-MOZ-INVITED-ATTENDEE property.
+ let account2 = MailServices.accounts.createAccount();
+ let identity2 = MailServices.accounts.createIdentity();
+ identity2.email = "id2@example.com";
+ account2.addIdentity(identity2);
+ account2.incomingServer = MailServices.accounts.createIncomingServer(
+ "id2",
+ "example.com",
+ "imap"
+ );
+
+ event.setProperty("X-MOZ-INVITED-ATTENDEE", "mailto:id2@example.com");
+
+ let customTransport = cal.itip.getImipTransport(event);
+ Assert.ok(customTransport);
+
+ Assert.ok(
+ customTransport.mDefaultAccount == account2,
+ "returns a transport using an account for the X-MOZ-INVITED-ATTENDEE identity when set"
+ );
+
+ Assert.ok(
+ customTransport.mDefaultIdentity == identity2,
+ "returns a transport using the identity of the X-MOZ-INVITED-ATTENDEE property when set"
+ );
+});
diff --git a/comm/calendar/test/unit/test_l10n_utils.js b/comm/calendar/test/unit/test_l10n_utils.js
new file mode 100644
index 0000000000..98d93042fd
--- /dev/null
+++ b/comm/calendar/test/unit/test_l10n_utils.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calL10NUtils.jsm
+/* Incomplete - still missing test coverage for:
+ * getAnyString
+ * getString
+ * getCalString
+ * getLtnString
+ * getDateFmtString
+ * formatMonth
+ */
+
+add_task(async function calendarInfo_test() {
+ let data = [
+ {
+ input: { locale: "en-US" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: "EN-US" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: "et" },
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ {
+ input: { locale: null }, // this also would trigger caching tests
+ expected: {
+ properties: ["firstDayOfWeek", "minDays", "weekend", "calendar", "locale"],
+ },
+ },
+ ];
+ let useOSLocaleFormat = Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales", false);
+ let osprefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(Ci.mozIOSPreferences);
+ let appLocale = Services.locale.appLocalesAsBCP47[0];
+ let rsLocale = osprefs.regionalPrefsLocales[0];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+ let info = cal.l10n.calendarInfo(test.input.locale);
+ equal(
+ Object.keys(info).length,
+ test.expected.properties.length,
+ "expected number of attributes (test #" + i + ")"
+ );
+ for (let prop of test.expected.properties) {
+ ok(prop in info, prop + " exists (test #" + i + ")");
+ }
+
+ if (!test.input.locale && appLocale != rsLocale) {
+ // if aLocale is null we test with the current date and time formatting setting
+ // let's test the caching mechanism - this test section is pointless if app and
+ // OS locale are the same like probably on automation
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", !useOSLocaleFormat);
+ let info2 = cal.l10n.calendarInfo();
+ equal(
+ Object.keys(info).length,
+ test.expected.properties.length,
+ "caching test - equal number of properties (test #" + i + ")"
+ );
+ for (let prop of Object.keys(info)) {
+ ok(prop in info2, "caching test - " + prop + " exists in both objects (test #" + i + ")");
+ equal(
+ info2[prop],
+ info[prop],
+ "caching test - value for " + prop + " is equal in both objects (test #" + i + ")"
+ );
+ }
+ // we reset the cache and test again - it's suffient here to find one changed property,
+ // so we use locale since that must change always in that scenario
+ // info2 = cal.l10n.calendarInfo(null, true);
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", useOSLocaleFormat);
+ // This is currently disabled since the code actually doesn't reset the cache anyway.
+ // When re-enabling, be aware that macOS returns just "en" for rsLocale while other
+ // OS provide "en-US".
+ /*
+ notEqual(
+ info2.locale,
+ info.locale,
+ "caching retest - value for locale is different in both objects (test #" + i + ")"
+ );
+ */
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_lenient_parsing.js b/comm/calendar/test/unit/test_lenient_parsing.js
new file mode 100644
index 0000000000..7d20584996
--- /dev/null
+++ b/comm/calendar/test/unit/test_lenient_parsing.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests that ICAL.design.strict is set to false in both the main thread and
+ * the ICS parsing worker. If either or both is set to true, this will fail.
+ */
+
+add_task(async function () {
+ const item = await new Promise((resolve, reject) => {
+ Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser).parseString(
+ dedent`
+ BEGIN:VCALENDAR
+ BEGIN:VEVENT
+ SUMMARY:An event!
+ DTSTART:20240331
+ DTEND:20240331
+ END:VEVENT
+ END:VCALENDAR
+ `,
+ {
+ QueryInterface: ChromeUtils.generateQI(["calIIcsParsingListener"]),
+ onParsingComplete(rv, parser) {
+ if (Components.isSuccessCode(rv)) {
+ resolve(parser.getItems()[0]);
+ } else {
+ reject(rv);
+ }
+ },
+ }
+ );
+ });
+
+ Assert.equal(item.startDate.year, 2024);
+ Assert.equal(item.startDate.month, 2);
+ Assert.equal(item.startDate.day, 31);
+ Assert.equal(item.endDate.year, 2024);
+ Assert.equal(item.endDate.month, 2);
+ Assert.equal(item.endDate.day, 31);
+});
diff --git a/comm/calendar/test/unit/test_providers.js b/comm/calendar/test/unit/test_providers.js
new file mode 100644
index 0000000000..b800e47727
--- /dev/null
+++ b/comm/calendar/test/unit/test_providers.js
@@ -0,0 +1,426 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint no-useless-concat: "off" */
+
+var icalStringArray = [
+ // Comments refer to the range defined in testGetItems().
+ // 1: one-hour event
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n",
+ // 2: Test a zero-length event with DTSTART and DTEND
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n",
+ // 3: Test a zero-length event with DTSTART and no DTEND
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T000000Z\n" + "END:VEVENT\n",
+ // 4: Test a zero-length event with DTEND set and no DTSTART. Invalid!
+ "BEGIN:VEVENT\n" + "DTEND:20020402T000000Z\n" + "END:VEVENT\n",
+ // 5: one-hour event that is outside the range
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020401T124500Z\n" + "END:VEVENT\n",
+ // 6: one-hour event that starts outside the range and ends inside.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T114500Z\n" + "DTEND:20020402T124500Z\n" + "END:VEVENT\n",
+ // 7: one-hour event that starts inside the range and ends outside.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n",
+ // 8: one-hour event that starts at the end of the range.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DTEND:20020403T124500Z\n" + "END:VEVENT\n",
+ // 9: allday event that starts at start of range and ends at end of range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "END:VEVENT\n",
+ // 10: allday event that starts at end of range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020403\n" +
+ "DTEND;VALUE=DATE:20020404\n" +
+ "END:VEVENT\n",
+ // 11: allday event that ends at start of range. See bug 333363.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "END:VEVENT\n",
+ // 12: daily recurring allday event. parent item in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 13: daily recurring allday event. First occurrence in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 14: two-daily recurring allday event. Not in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020401\n" +
+ "DTEND;VALUE=DATE:20020402\n" +
+ "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 15: daily recurring one-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T100000Z\n" +
+ "DTEND:20020402T110000Z\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 16: daily recurring one-hour event. Occurrence in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020401T100000Z\n" +
+ "DTEND:20020401T110000Z\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 17: zero-length task with DTSTART and DUE set at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 18: zero-length event with only DTSTART set at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T000000Z\n" + "END:VTODO\n",
+ // 19: zero-length event with only DUE set at start of range.
+ "BEGIN:VTODO\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 20: one-hour todo within the range.
+ "BEGIN:VTODO\n" + "DTSTART:20020402T110000Z\n" + "DUE:20020402T120000Z\n" + "END:VTODO\n",
+ // 21: zero-length todo that starts at end of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020403T000000Z\n" + "DUE:20020403T010000Z\n" + "END:VTODO\n",
+ // 22: one-hour todo that ends at start of range.
+ "BEGIN:VTODO\n" + "DTSTART:20020401T230000Z\n" + "DUE:20020402T000000Z\n" + "END:VTODO\n",
+ // 23: daily recurring one-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T000000\n" +
+ "DTEND:20020402T010000\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 24: daily recurring 24-hour event. Parent in the range.
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T000000\n" +
+ "DTEND:20020403T000000\n" +
+ "RRULE:FREQ=DAILY;COUNT=10\n" +
+ "END:VEVENT\n",
+ // 25: todo that has neither start nor due date set.
+ // Should be returned on every getItems() call. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "END:VTODO\n",
+ // 26: todo that has neither start nor due date but
+ // a completion time set after range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20030404T000001\n" + "END:VTODO\n",
+ // 27: todo that has neither start nor due date but a
+ // completion time set in the range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T120001\n" + "END:VTODO\n",
+ // 28: todo that has neither start nor due date but a
+ // completion time set before the range. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "COMPLETED:20020402T000000\n" + "END:VTODO\n",
+ // 29: todo that has neither start nor due date set,
+ // has the status "COMPLETED" but no completion time. See bug 405459.
+ "BEGIN:VTODO\n" + "SUMMARY:Todo\n" + "STATUS:COMPLETED\n" + "END:VTODO\n",
+ // 30: one-hour event with duration (in the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T114500Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 31: one-hour event with duration (after the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 32: one-hour event with duration (before the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T230000Z\n" + "DURATION:PT1H\n" + "END:VEVENT\n",
+ // 33: one-day event with duration. Starts in the range, Ends outside. See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020402T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 34: one-day event with duration. Starts before the range. Ends inside. See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T120000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 35: one-day event with duration (before the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020401T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+ // 36: one-day event with duration (after the range). See bug 390492.
+ "BEGIN:VEVENT\n" + "DTSTART:20020403T000000Z\n" + "DURATION:P1D\n" + "END:VEVENT\n",
+];
+
+add_task(async function testIcalData() {
+ // First entry is test number, second item is expected result for testGetItems().
+ let wantedArray = [
+ [1, 1],
+ [2, 1],
+ [3, 1],
+ [5, 0],
+ [6, 1],
+ [7, 1],
+ [8, 0],
+ [9, 1],
+ [10, 0],
+ [11, 0],
+ [12, 1],
+ [13, 1],
+ [14, 0],
+ [15, 1],
+ [16, 1],
+ [17, 1],
+ [18, 1],
+ [19, 1],
+ [20, 1],
+ [21, 0],
+ [22, 0],
+ [23, 1],
+ [24, 1],
+ [25, 1],
+ [26, 1],
+ [27, 1],
+ [28, 0],
+ [29, 1],
+ [30, 1],
+ [31, 0],
+ [32, 0],
+ [33, 1],
+ [34, 1],
+ [35, 0],
+ [36, 0],
+ ];
+
+ for (let i = 0; i < wantedArray.length; i++) {
+ let itemArray = wantedArray[i];
+ // Correct for 1 to stay in synch with test numbers.
+ let calItem = icalStringArray[itemArray[0] - 1];
+
+ let item;
+ if (calItem.search(/VEVENT/) != -1) {
+ item = createEventFromIcalString(calItem);
+ } else if (calItem.search(/VTODO/) != -1) {
+ item = createTodoFromIcalString(calItem);
+ }
+
+ print("Test " + wantedArray[i][0]);
+ await testGetItems(item, itemArray[1]);
+ await testGetItem(item);
+ }
+
+ /**
+ * Adds aItem to a calendar and performs a getItems() call using the
+ * following range:
+ * 2002/04/02 0:00 - 2002/04/03 0:00
+ * The amount of returned items is compared with expected amount (aResult).
+ * Additionally, the properties of the returned item are compared with aItem.
+ */
+ async function testGetItems(aItem, aResult) {
+ for (let calendar of [getStorageCal(), getMemoryCal()]) {
+ await checkCalendar(calendar, aItem, aResult);
+ }
+ }
+
+ async function checkCalendar(calendar, aItem, aResult) {
+ // add item to calendar
+ await calendar.addItem(aItem);
+
+ // construct range
+ let rangeStart = createDate(2002, 3, 2); // 3 = April
+ let rangeEnd = rangeStart.clone();
+ rangeEnd.day += 1;
+
+ // filter options
+ let filter =
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL;
+
+ // implement listener
+ let count = 0;
+ for await (let items of cal.iterate.streamValues(
+ calendar.getItems(filter, 0, rangeStart, rangeEnd)
+ )) {
+ if (items.length) {
+ count += items.length;
+ for (let i = 0; i < items.length; i++) {
+ // Don't check creationDate as it changed when we added the item to the database.
+ compareItemsSpecific(items[i].parentItem, aItem, [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ]);
+ }
+ }
+ }
+ equal(count, aResult);
+ }
+
+ /**
+ * (1) Add aItem to a calendar.
+ * The properties of the added item are compared with the passed item.
+ * (2) Perform a getItem() call.
+ * The properties of the returned item are compared with the passed item.
+ */
+ async function testGetItem(aItem) {
+ // get calendars
+ let calArray = [];
+ calArray.push(getStorageCal());
+ calArray.push(getMemoryCal());
+ for (let calendar of calArray) {
+ let count = 0;
+ let returnedItem = null;
+
+ let aDetail = await calendar.addItem(aItem);
+ compareItemsSpecific(aDetail, aItem);
+ // perform getItem() on calendar
+ returnedItem = await calendar.getItem(aDetail.id);
+ count = returnedItem ? 1 : 0;
+
+ equal(count, 1);
+ // Don't check creationDate as it changed when we added the item to the database.
+ compareItemsSpecific(returnedItem, aItem, [
+ "start",
+ "end",
+ "duration",
+ "title",
+ "priority",
+ "privacy",
+ "status",
+ "alarmLastAck",
+ "recurrenceStartDate",
+ ]);
+ }
+ }
+});
+
+add_task(async function testMetaData() {
+ async function testMetaData_(aCalendar) {
+ dump("testMetaData_() calendar type: " + aCalendar.type + "\n");
+ let event1 = createEventFromIcalString(
+ "BEGIN:VEVENT\n" + "DTSTART;VALUE=DATE:20020402\n" + "END:VEVENT\n"
+ );
+
+ event1.id = "item1";
+ await aCalendar.addItem(event1);
+
+ aCalendar.setMetaData("item1", "meta1");
+ equal(aCalendar.getMetaData("item1"), "meta1");
+ equal(aCalendar.getMetaData("unknown"), null);
+
+ let event2 = event1.clone();
+ event2.id = "item2";
+ await aCalendar.addItem(event2);
+
+ aCalendar.setMetaData("item2", "meta2-");
+ equal(aCalendar.getMetaData("item2"), "meta2-");
+
+ aCalendar.setMetaData("item2", "meta2");
+ equal(aCalendar.getMetaData("item2"), "meta2");
+
+ let ids = aCalendar.getAllMetaDataIds();
+ let values = aCalendar.getAllMetaDataValues();
+ equal(values.length, 2);
+ equal(ids.length, 2);
+ ok(ids[0] == "item1" || ids[1] == "item1");
+ ok(ids[0] == "item2" || ids[1] == "item2");
+ ok(values[0] == "meta1" || values[1] == "meta1");
+ ok(values[0] == "meta2" || values[1] == "meta2");
+
+ await aCalendar.deleteItem(event1);
+
+ equal(aCalendar.getMetaData("item1"), null);
+ ids = aCalendar.getAllMetaDataIds();
+ values = aCalendar.getAllMetaDataValues();
+ equal(values.length, 1);
+ equal(ids.length, 1);
+ ok(ids[0] == "item2");
+ ok(values[0] == "meta2");
+
+ aCalendar.deleteMetaData("item2");
+ equal(aCalendar.getMetaData("item2"), null);
+ values = aCalendar.getAllMetaDataValues();
+ ids = aCalendar.getAllMetaDataIds();
+ equal(values.length, 0);
+ equal(ids.length, 0);
+
+ aCalendar.setMetaData("item2", "meta2");
+ equal(aCalendar.getMetaData("item2"), "meta2");
+ await new Promise(resolve => {
+ aCalendar.QueryInterface(Ci.calICalendarProvider).deleteCalendar(aCalendar, {
+ onCreateCalendar: () => {},
+ onDeleteCalendar: resolve,
+ });
+ });
+ values = aCalendar.getAllMetaDataValues();
+ ids = aCalendar.getAllMetaDataIds();
+ equal(values.length, 0);
+ equal(ids.length, 0);
+
+ aCalendar.deleteMetaData("unknown"); // check graceful return
+ }
+
+ await testMetaData_(getMemoryCal());
+ await testMetaData_(getStorageCal());
+});
+
+/*
+async function testOfflineStorage(storageGetter, isRecurring) {
+ let storage = storageGetter();
+ print(`Running offline storage test for ${storage.type} calendar for ${isRecurring ? "recurring" : "normal"} item`);
+
+ let event1 = createEventFromIcalString("BEGIN:VEVENT\n" +
+ "DTSTART;VALUE=DATE:20020402\n" +
+ "DTEND;VALUE=DATE:20020403\n" +
+ "SUMMARY:event1\n" +
+ (isRecurring ? "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10\n" : "") +
+ "END:VEVENT\n");
+
+ event1 = await storage.addItem(event1);
+
+ // Make sure the event is really in the calendar
+ let result = await storage.getAllItems();
+ equal(result.length, 1);
+
+ // When searching for offline added items, there are none
+ let filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ // Mark the item as offline added
+ await storage.addOfflineItem(event1);
+
+ // Now there should be an offline item
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ let event2 = event1.clone();
+ event2.title = "event2";
+
+ event2 = await storage.modifyItem(event2, event1);
+
+ await storage.modifyOfflineItem(event2);
+
+ // The flag should still be offline added, as it was already marked as such
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Reset the flag
+ await storage.resetItemOfflineFlag(event2);
+
+ // No more offline items after resetting the flag
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ // Setting modify flag without one set should actually set that flag
+ await storage.modifyOfflineItem(event2);
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_CREATED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Setting the delete flag should modify the flag accordingly
+ await storage.deleteOfflineItem(event2);
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_MODIFIED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 0);
+
+ filter = Ci.calICalendar.ITEM_FILTER_ALL_ITEMS | Ci.calICalendar.ITEM_FILTER_OFFLINE_DELETED;
+ result = await storage.getItems(filter, 0, null, null);
+ equal(result.length, 1);
+
+ // Setting the delete flag on an offline added item should remove it
+ await storage.resetItemOfflineFlag(event2);
+ await storage.addOfflineItem(event2);
+ await storage.deleteOfflineItem(event2);
+ result = await storage.getItemsAsArray(Ci.calICalendar.ITEM_FILTER_ALL_ITEMS, 0, null, null);
+ equal(result.length, 0);
+}
+
+add_task(testOfflineStorage.bind(null, () => getMemoryCal(), false));
+add_task(testOfflineStorage.bind(null, () => getStorageCal(), false));
+add_task(testOfflineStorage.bind(null, () => getMemoryCal(), true));
+add_task(testOfflineStorage.bind(null, () => getStorageCal(), true));
+*/
diff --git a/comm/calendar/test/unit/test_recur.js b/comm/calendar/test/unit/test_recur.js
new file mode 100644
index 0000000000..32033d5b85
--- /dev/null
+++ b/comm/calendar/test/unit/test_recur.js
@@ -0,0 +1,1361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+});
+
+function makeEvent(str) {
+ return createEventFromIcalString("BEGIN:VEVENT\n" + str + "END:VEVENT");
+}
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_interface();
+ test_rrule_interface();
+ test_rules();
+ test_failures();
+ test_limit();
+ test_startdate_change();
+ test_idchange();
+ test_rrule_icalstring();
+ test_immutable();
+ test_icalComponent();
+}
+
+function test_rules() {
+ function check_recur(event, expected, endDate, ignoreNextOccCheck) {
+ dump("Checking '" + event.getProperty("DESCRIPTION") + "'\n");
+
+ // Immutability is required for testing the recurrenceEndDate property.
+ event.makeImmutable();
+
+ // Get recurrence dates
+ let start = createDate(1990, 0, 1);
+ let end = createDate(2020, 0, 1);
+ let recdates = event.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ let occurrences = event.recurrenceInfo.getOccurrences(start, end, 0);
+
+ // Check number of items
+ dump("Expected " + expected.length + " occurrences\n");
+ dump("Got: " + recdates.map(x => x.toString()) + "\n");
+ equal(recdates.length, expected.length);
+ let fmt = cal.dtz.formatter;
+
+ for (let i = 0; i < expected.length; i++) {
+ // Check each date
+ let expectedDate = cal.createDateTime(expected[i]);
+ dump(
+ "Expecting instance at " + expectedDate + "(" + fmt.dayName(expectedDate.weekday) + ")\n"
+ );
+ dump("Recdate:");
+ equal(recdates[i].icalString, expected[i]);
+
+ // Make sure occurrences are correct
+ dump("Occurrence:");
+ occurrences[i].QueryInterface(Ci.calIEvent);
+ equal(occurrences[i].startDate.icalString, expected[i]);
+
+ if (ignoreNextOccCheck) {
+ continue;
+ }
+
+ // Make sure getNextOccurrence works correctly
+ let nextOcc = event.recurrenceInfo.getNextOccurrence(recdates[i]);
+ if (expected.length > i + 1) {
+ notEqual(nextOcc, null);
+ dump("Checking next occurrence: " + expected[i + 1] + "\n");
+ nextOcc.QueryInterface(Ci.calIEvent);
+ equal(nextOcc.startDate.icalString, expected[i + 1]);
+ } else {
+ dump("Expecting no more occurrences, found " + (nextOcc ? nextOcc.startDate : null) + "\n");
+ equal(nextOcc, null);
+ }
+
+ // Make sure getPreviousOccurrence works correctly
+ let prevOcc = event.recurrenceInfo.getPreviousOccurrence(recdates[i]);
+ if (i > 0) {
+ dump(
+ "Checking previous occurrence: " +
+ expected[i - 1] +
+ ", found " +
+ (prevOcc ? prevOcc.startDate : prevOcc) +
+ "\n"
+ );
+ notEqual(prevOcc, null);
+ prevOcc.QueryInterface(Ci.calIEvent);
+ equal(prevOcc.startDate.icalString, expected[i - 1]);
+ } else {
+ dump(
+ "Expecting no previous occurrences, found " +
+ (prevOcc ? prevOcc.startDate : prevOcc) +
+ "\n"
+ );
+ equal(prevOcc, null);
+ }
+ }
+
+ if (typeof endDate == "string") {
+ endDate = cal.createDateTime(endDate).nativeTime;
+ }
+ equal(event.recurrenceInfo.recurrenceEndDate, endDate);
+
+ // Make sure recurrenceInfo.clone works correctly
+ test_clone(event);
+ }
+
+ // Test specific items/rules
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Repeat every tuesday and wednesday starting " +
+ "Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TU,WE\n" +
+ "DTSTART:20020402T114500\n" +
+ "DTEND:20020402T124500\n"
+ ),
+ [
+ "20020402T114500",
+ "20020403T114500",
+ "20020409T114500",
+ "20020410T114500",
+ "20020416T114500",
+ "20020417T114500",
+ ],
+ "20020417T124500"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Repeat every thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" +
+ "DTSTART:20020402T114500\n" +
+ "DTEND:20020402T124500\n"
+ ),
+ [
+ "20020402T114500", // DTSTART part of the resulting set
+ "20020404T114500",
+ "20020411T114500",
+ "20020418T114500",
+ "20020425T114500",
+ "20020502T114500",
+ "20020509T114500",
+ ],
+ "20020509T124500"
+ );
+
+ // Bug 469840 - Recurring Sundays incorrect
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;BYDAY=WE,SA,SU\n" +
+ "DTSTART:20081217T133000\n" +
+ "DTEND:20081217T143000\n"
+ ),
+ [
+ "20081217T133000",
+ "20081220T133000",
+ "20081221T133000",
+ "20081231T133000",
+ "20090103T133000",
+ "20090104T133000",
+ ],
+ "20090104T143000"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU with DTSTART:20081217T133000\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=6;WKST=SU;BYDAY=WE,SA,SU\n" +
+ "DTSTART:20081217T133000\n" +
+ "DTEND:20081217T143000\n"
+ ),
+ [
+ "20081217T133000",
+ "20081220T133000",
+ "20081228T133000",
+ "20081231T133000",
+ "20090103T133000",
+ "20090111T133000",
+ ],
+ "20090111T143000"
+ );
+
+ // bug 353797: occurrences for repeating all day events should stay "all-day"
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Allday repeat every thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=3;BYDAY=TH\n" +
+ "DTSTART;VALUE=DATE:20020404\n" +
+ "DTEND;VALUE=DATE:20020405\n"
+ ),
+ ["20020404", "20020411", "20020418"],
+ "20020419"
+ );
+
+ /* Test disabled, because BYWEEKNO is known to be broken
+ check_recur(makeEvent("DESCRIPTION:Monday of week number 20 (where the default start of the week is Monday)\n" +
+ "RRULE:FREQ=YEARLY;INTERVAL=1;COUNT=6;BYDAY=MO;BYWEEKNO=20\n" +
+ "DTSTART:19970512T090000",
+ ["19970512T090000", "19980511T090000", "19990517T090000" +
+ "20000515T090000", "20010514T090000", "20020513T090000"]);
+ */
+
+ // bug 899326: Recurrences with BYMONTHDAY=X,X,31 don't show at all in months with less than 31 days
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every 11th & 31st of every Month\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYMONTHDAY=11,31\n" +
+ "DTSTART:20130731T160000\n" +
+ "DTEND:20130731T170000\n"
+ ),
+ [
+ "20130731T160000",
+ "20130811T160000",
+ "20130831T160000",
+ "20130911T160000",
+ "20131011T160000",
+ "20131031T160000",
+ ],
+ "20131031T170000"
+ );
+
+ // bug 899770: Monthly Recurrences with BYDAY and BYMONTHDAY with more than 2 dates are not working
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every WE & SA the 6th, 20th & 31st\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=WE,SA;BYMONTHDAY=6,20,31\n" +
+ "DTSTART:20130706T160000\n" +
+ "DTEND:20130706T170000\n"
+ ),
+ [
+ "20130706T160000",
+ "20130720T160000",
+ "20130731T160000",
+ "20130831T160000",
+ "20131106T160000",
+ "20131120T160000",
+ ],
+ "20131120T170000"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every day, use exdate to exclude the second day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n"
+ ),
+ ["20020402T114500Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ // test for issue 734245
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Every day, use exdate of type DATE to exclude the second day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE;VALUE=DATE:20020403\n"
+ ),
+ ["20020402T114500Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ check_recur(
+ makeEvent(
+ "DESCRIPTION:Use EXDATE to eliminate the base event\n" +
+ "RRULE:FREQ=DAILY;COUNT=1\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020402T114500Z\n"
+ ),
+ [],
+ -9223372036854775000
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "UID:123\n" +
+ "DESCRIPTION:Every day, exception put on exdated day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n" +
+ "END:VEVENT\n" +
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020403T114500Z\n" +
+ "UID:123\n" +
+ "RECURRENCE-ID:20020404T114500Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20020402T114500Z", "20020403T114500Z"],
+ "20020403T114500",
+ true
+ ); // ignore next occ check, bug 455490
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "UID:123\n" +
+ "DESCRIPTION:Every day, exception put on exdated start day\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020402T114500Z\n" +
+ "END:VEVENT\n" +
+ "BEGIN:VEVENT\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "UID:123\n" +
+ "RECURRENCE-ID:20020404T114500Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20020402T114500Z", "20020403T114500Z"],
+ "20020403T114500",
+ true /* ignore next occ check, bug 455490 */
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Daily on weekdays with UNTIL\n" +
+ "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" +
+ "DTSTART:20111212T220000Z\n" +
+ "DTEND:20111212T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20111212T220000Z",
+ "20111213T220000Z",
+ "20111214T220000Z",
+ "20111215T220000Z",
+ "20111216T220000Z",
+ ],
+ "20111216T230000",
+ false
+ );
+
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Daily on weekdays with UNTIL and exception\n" +
+ "RRULE:FREQ=DAILY;UNTIL=20111217T220000Z;BYDAY=MO,TU,WE,TH,FR\n" +
+ "EXDATE:20111214T220000Z\n" +
+ "DTSTART:20111212T220000Z\n" +
+ "DTEND:20111212T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20111212T220000Z", "20111213T220000Z", "20111215T220000Z", "20111216T220000Z"],
+ "20111216T230000",
+ false
+ );
+
+ // Bug 958978: Yearly recurrence, the last day of a specified month.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly the last day of February\n" +
+ "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1;BYMONTH=2\n" +
+ "DTSTART:20140228T220000Z\n" +
+ "DTEND:20140228T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140228T220000Z",
+ "20150228T220000Z",
+ "20160229T220000Z",
+ "20170228T220000Z",
+ "20180228T220000Z",
+ "20190228T220000Z",
+ ],
+ "20190228T230000",
+ false
+ );
+
+ // Bug 958978: Yearly recurrence, the last day of a not specified month.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly the last day of April without BYMONTH=4 in the rule\n" +
+ "RRULE:FREQ=YEARLY;COUNT=6;BYMONTHDAY=-1\n" +
+ "DTSTART:20140430T220000Z\n" +
+ "DTEND:20140430T230000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140430T220000Z",
+ "20150430T220000Z",
+ "20160430T220000Z",
+ "20170430T220000Z",
+ "20180430T220000Z",
+ "20190430T220000Z",
+ ],
+ "20190430T230000",
+ false
+ );
+
+ // Bug 958978 - Check a yearly recurrence on every WE and FR of January and March
+ // (more BYMONTH and more BYDAY).
+ // Check for the occurrences in the first year.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Yearly every WE and FR of January and March (more BYMONTH and more BYDAY)\n" +
+ "RRULE:FREQ=YEARLY;COUNT=18;BYMONTH=1,3;BYDAY=WE,FR\n" +
+ "DTSTART:20140101T150000Z\n" +
+ "DTEND:20140101T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20140101T150000Z",
+ "20140103T150000Z",
+ "20140108T150000Z",
+ "20140110T150000Z",
+ "20140115T150000Z",
+ "20140117T150000Z",
+ "20140122T150000Z",
+ "20140124T150000Z",
+ "20140129T150000Z",
+ "20140131T150000Z",
+ "20140305T150000Z",
+ "20140307T150000Z",
+ "20140312T150000Z",
+ "20140314T150000Z",
+ "20140319T150000Z",
+ "20140321T150000Z",
+ "20140326T150000Z",
+ "20140328T150000Z",
+ ],
+ "20140328T160000",
+ false
+ );
+
+ // Bug 958978 - Check a yearly recurrence every day of January (BYMONTH and more BYDAY).
+ // Check for all the occurrences in the first year.
+ let expectedDates = [];
+ for (let i = 1; i < 32; i++) {
+ expectedDates.push("201401" + (i < 10 ? "0" + i : i) + "T150000Z");
+ }
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Yearly, every day of January (one BYMONTH and more BYDAY)\n" +
+ "RRULE:FREQ=YEARLY;COUNT=31;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA\n" +
+ "DTSTART:20140101T150000Z\n" +
+ "DTEND:20140101T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ expectedDates,
+ "20140131T160000",
+ false
+ );
+
+ // Bug 958974 - Monthly recurrence every WE, FR and the third MO (monthly with more bydays).
+ // Check the occurrences in the first month until the week with the first monday of the rule.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly every Wednesday, Friday and the third Monday\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=8;BYDAY=3MO,WE,FR\n" +
+ "DTSTART:20150102T080000Z\n" +
+ "DTEND:20150102T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150102T080000Z",
+ "20150107T080000Z",
+ "20150109T080000Z",
+ "20150114T080000Z",
+ "20150116T080000Z",
+ "20150119T080000Z",
+ "20150121T080000Z",
+ "20150123T080000Z",
+ ],
+ "20150123T090000",
+ false
+ );
+
+ // Bug 419490 - Monthly recurrence, the fifth Saturday starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the fifth Saturday\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=5SA\n" +
+ "DTSTART:20150202T080000Z\n" +
+ "DTEND:20150202T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150202T080000Z",
+ "20150530T080000Z",
+ "20150829T080000Z",
+ "20151031T080000Z",
+ "20160130T080000Z",
+ "20160430T080000Z",
+ "20160730T080000Z",
+ ],
+ "20160730T090000",
+ false
+ );
+
+ // Bug 419490 - Monthly recurrence, the fifth Wednesday every two months starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the fifth Friday every two months\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6;BYDAY=5FR\n" +
+ "DTSTART:20150202T080000Z\n" +
+ "DTEND:20150202T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150202T080000Z",
+ "20151030T080000Z",
+ "20160429T080000Z",
+ "20161230T080000Z",
+ "20170630T080000Z",
+ "20171229T080000Z",
+ "20180629T080000Z",
+ ],
+ "20180629T090000",
+ false
+ );
+
+ // Bugs 419490, 958974 - Monthly recurrence, the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month starting from February.
+ // Check a monthly rule that specifies a day that is not part of the month
+ // the events starts in with positive and negative position along with other byday.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Repeat Monthly the 2nd Monday, 5th Wednesday and the 5th to last Saturday every month\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=7;BYDAY=2MO,-5WE,5SA\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150413T080000Z",
+ "20150511T080000Z",
+ "20150530T080000Z",
+ "20150608T080000Z",
+ "20150701T080000Z",
+ "20150713T080000Z",
+ ],
+ "20150713T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of March.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150301T080000Z\n" +
+ "DTEND:20150301T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150301T080000Z",
+ "20150309T080000Z",
+ "20150313T080000Z",
+ "20150323T080000Z",
+ "20150327T080000Z",
+ ],
+ "20150327T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150403T080000Z",
+ "20150413T080000Z",
+ "20150417T080000Z",
+ "20150427T080000Z",
+ ],
+ "20150427T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly recurrence, every MO and FR when are odd days starting from the 1st of April.
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every MO and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=MO,SA;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4\n" +
+ "DTSTART:20150401T080000Z\n" +
+ "DTEND:20150401T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150401T080000Z",
+ "20150411T080000Z",
+ "20150413T080000Z",
+ "20150425T080000Z",
+ "20150427T080000Z",
+ ],
+ "20150427T090000",
+ false
+ );
+
+ // Bug 1146500 - Monthly every SU and FR when are odd days starting from 28 of February (BYDAY and BYMONTHDAY).
+ // Check the first occurrence when we have BYDAY along with BYMONTHDAY.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Monthly recurrence, every SU and FR when are odd days starting from the 1st of March\n" +
+ "RRULE:FREQ=MONTHLY;BYDAY=SU,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=9\n" +
+ "DTSTART:20150228T080000Z\n" +
+ "DTEND:20150228T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20150228T080000Z",
+ "20150301T080000Z",
+ "20150313T080000Z",
+ "20150315T080000Z",
+ "20150327T080000Z",
+ "20150329T080000Z",
+ "20150403T080000Z",
+ "20150405T080000Z",
+ "20150417T080000Z",
+ "20150419T080000Z",
+ ],
+ "20150419T090000",
+ false
+ );
+
+ // Bug 1103187 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence day taken
+ // from the start date. Check four occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=4\n" +
+ "DTSTART:20160404T080000Z\n" +
+ "DTEND:20160404T090000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ ["20160404T080000Z", "20160504T080000Z", "20160604T080000Z", "20160704T080000Z"],
+ "20160704T090000",
+ false
+ );
+
+ // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st
+ // of the month. Check for 6 occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence, the 31st\n" +
+ "RRULE:FREQ=MONTHLY;COUNT=6\n" +
+ "DTSTART:20160131T150000Z\n" +
+ "DTEND:20160131T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20160131T150000Z",
+ "20160331T150000Z",
+ "20160531T150000Z",
+ "20160731T150000Z",
+ "20160831T150000Z",
+ "20161031T150000Z",
+ ],
+ "20161031T160000",
+ false
+ );
+
+ // Bug 1265554 - Monthly recurrence with only MONTHLY tag in the rule. Recurrence on the 31st
+ // of the month every two months. Check for 6 occurrences.
+ check_recur(
+ createEventFromIcalString(
+ "BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
+ "DESCRIPTION:Only Monthly recurrence, the 31st every 2 months\n" +
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6\n" +
+ "DTSTART:20151231T150000Z\n" +
+ "DTEND:20151231T160000Z\n" +
+ "END:VEVENT\nEND:VCALENDAR\n"
+ ),
+ [
+ "20151231T150000Z",
+ "20160831T150000Z",
+ "20161031T150000Z",
+ "20161231T150000Z",
+ "20170831T150000Z",
+ "20171031T150000Z",
+ ],
+ "20171031T160000",
+ false
+ );
+
+ let item, occ1;
+ item = makeEvent(
+ "DESCRIPTION:occurrence on day 1 moved between the occurrences " +
+ "on days 2 and 3\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(
+ item,
+ ["20020403T114500Z", "20020403T120000Z", "20020404T114500Z"],
+ "20020404T114500"
+ );
+
+ item = makeEvent(
+ "DESCRIPTION:occurrence on day 1 moved between the occurrences " +
+ "on days 2 and 3, EXDATE on day 2\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "EXDATE:20020403T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(item, ["20020403T120000Z", "20020404T114500Z"], "20020404T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:all occurrences have exceptions\n" +
+ "RRULE:FREQ=DAILY;COUNT=2\n" +
+ "DTSTART:20020402T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 3, 2, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ let occ2 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 3, true, 11, 45, 0));
+ occ2.QueryInterface(Ci.calIEvent);
+ occ2.startDate = createDate(2002, 3, 3, true, 12, 0, 0);
+ item.recurrenceInfo.modifyException(occ2, true);
+ check_recur(item, ["20020402T120000Z", "20020403T120000Z"], "20020403T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:rdate and exception before the recurrence start date\n" +
+ "RRULE:FREQ=DAILY;COUNT=2\n" +
+ "DTSTART:20020402T114500Z\n" +
+ "RDATE:20020401T114500Z\n"
+ );
+ occ1 = item.recurrenceInfo.getOccurrenceFor(createDate(2002, 3, 2, true, 11, 45, 0));
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = createDate(2002, 2, 30, true, 11, 45, 0);
+ item.recurrenceInfo.modifyException(occ1, true);
+ check_recur(
+ item,
+ ["20020330T114500Z", "20020401T114500Z", "20020403T114500Z"],
+ "20020403T114500"
+ );
+
+ item = makeEvent(
+ "DESCRIPTION:bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART:20020401T114500Z\n" +
+ "EXDATE;VALUE=DATE:20020402\n"
+ );
+ check_recur(item, ["20020401T114500Z", "20020403T114500Z"], "20020403T114500");
+
+ item = makeEvent(
+ "DESCRIPTION:EXDATE with a timezone\n" +
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "DTSTART;TZID=Europe/Berlin:20020401T114500\n" +
+ "EXDATE;TZID=Europe/Berlin:20020402T114500\n"
+ );
+ check_recur(item, ["20020401T114500", "20020403T114500"], "20020403T094500");
+
+ // Unsupported SECONDLY FREQ value.
+ item = makeEvent(
+ "DESCRIPTION:bug 1770984\nRRULE:FREQ=SECONDLY;COUNT=60\nDTSTART:20220606T114500Z\n"
+ );
+ check_recur(item, [], "20220606T114500Z");
+
+ // Unsupported MINUTELY FREQ value.
+ item = makeEvent(
+ "DESCRIPTION:bug 1770984\nRRULE:FREQ=MINUTELY;COUNT=60\nDTSTART:20220606T114500Z\n"
+ );
+ check_recur(item, [], "20220606T114500Z");
+}
+
+function test_limit() {
+ let item = makeEvent(
+ "RRULE:FREQ=DAILY;COUNT=3\n" +
+ "UID:1\n" +
+ "DTSTART:20020401T114500\n" +
+ "DTEND:20020401T124500\n"
+ );
+ dump("ics: " + item.icalString + "\n");
+
+ let start = createDate(1990, 0, 1);
+ let end = createDate(2020, 0, 1);
+ let recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 0);
+ let occurrences = item.recurrenceInfo.getOccurrences(start, end, 0);
+
+ equal(recdates.length, 3);
+ equal(occurrences.length, 3);
+
+ recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 2);
+ occurrences = item.recurrenceInfo.getOccurrences(start, end, 2);
+
+ equal(recdates.length, 2);
+ equal(occurrences.length, 2);
+
+ recdates = item.recurrenceInfo.getOccurrenceDates(start, end, 9);
+ occurrences = item.recurrenceInfo.getOccurrences(start, end, 9);
+
+ equal(recdates.length, 3);
+ equal(occurrences.length, 3);
+}
+
+function test_clone(event) {
+ let oldRecurItems = event.recurrenceInfo.getRecurrenceItems();
+ let cloned = event.recurrenceInfo.clone();
+ let newRecurItems = cloned.getRecurrenceItems();
+
+ // Check number of recurrence items
+ equal(oldRecurItems.length, newRecurItems.length);
+
+ for (let i = 0; i < oldRecurItems.length; i++) {
+ // Check if recurrence item cloned correctly
+ equal(oldRecurItems[i].icalProperty.icalString, newRecurItems[i].icalProperty.icalString);
+ }
+}
+
+function test_interface() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\n" +
+ "DTEND:20020402T124500Z\n" +
+ "RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n" +
+ "EXDATE:20020403T114500Z\r\n" +
+ "RDATE:20020401T114500Z\r\n"
+ );
+
+ let rinfo = item.recurrenceInfo;
+ ok(cal.data.compareObjects(rinfo.item, item, Ci.calIEvent));
+
+ // getRecurrenceItems
+ let ritems = rinfo.getRecurrenceItems();
+ equal(ritems.length, 3);
+
+ let checkritems = new Map(
+ ritems.map(ritem => [ritem.icalProperty.propertyName, ritem.icalProperty])
+ );
+ let rparts = new Map(
+ checkritems
+ .get("RRULE")
+ .value.split(";")
+ .map(value => value.split("=", 2))
+ );
+ equal(rparts.size, 3);
+ equal(rparts.get("FREQ"), "WEEKLY");
+ equal(rparts.get("COUNT"), "6");
+ equal(rparts.get("BYDAY"), "TU,WE");
+ equal(checkritems.get("EXDATE").value, "20020403T114500Z");
+ equal(checkritems.get("RDATE").value, "20020401T114500Z");
+
+ // setRecurrenceItems
+ let newRItems = [cal.createRecurrenceRule(), cal.createRecurrenceDate()];
+
+ newRItems[0].type = "DAILY";
+ newRItems[0].interval = 1;
+ newRItems[0].count = 1;
+ newRItems[1].isNegative = true;
+ newRItems[1].date = cal.createDateTime("20020404T114500Z");
+
+ rinfo.setRecurrenceItems(newRItems);
+ let itemString = item.icalString;
+
+ equal(itemString.match(/RRULE:[A-Z=,]*FREQ=WEEKLY/), null);
+ equal(itemString.match(/EXDATE[A-Z;=-]*:20020403T114500Z/, null));
+ equal(itemString.match(/RDATE[A-Z;=-]*:20020401T114500Z/, null));
+ notEqual(itemString.match(/RRULE:[A-Z=,]*FREQ=DAILY/), null);
+ notEqual(itemString.match(/EXDATE[A-Z;=-]*:20020404T114500Z/, null));
+
+ // This may be an implementation detail, but we don't want this breaking
+ rinfo.wrappedJSObject.ensureSortedRecurrenceRules();
+ equal(
+ rinfo.wrappedJSObject.mNegativeRules[0].icalProperty.icalString,
+ newRItems[1].icalProperty.icalString
+ );
+ equal(
+ rinfo.wrappedJSObject.mPositiveRules[0].icalProperty.icalString,
+ newRItems[0].icalProperty.icalString
+ );
+
+ // countRecurrenceItems
+ equal(2, rinfo.countRecurrenceItems());
+
+ // clearRecurrenceItems
+ rinfo.clearRecurrenceItems();
+ equal(0, rinfo.countRecurrenceItems());
+
+ // appendRecurrenceItems / getRecurrenceItemAt / insertRecurrenceItemAt
+ rinfo.appendRecurrenceItem(ritems[0]);
+ rinfo.appendRecurrenceItem(ritems[1]);
+ rinfo.insertRecurrenceItemAt(ritems[2], 0);
+
+ ok(cal.data.compareObjects(ritems[2], rinfo.getRecurrenceItemAt(0), Ci.calIRecurrenceItem));
+ ok(cal.data.compareObjects(ritems[0], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem));
+ ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(2), Ci.calIRecurrenceItem));
+
+ // deleteRecurrenceItem
+ rinfo.deleteRecurrenceItem(ritems[0]);
+ ok(!item.icalString.includes("RRULE"));
+
+ // deleteRecurrenceItemAt
+ rinfo.deleteRecurrenceItemAt(1);
+ itemString = item.icalString;
+ ok(!itemString.includes("EXDATE"));
+ ok(itemString.includes("RDATE"));
+
+ // insertRecurrenceItemAt with exdate
+ rinfo.insertRecurrenceItemAt(ritems[1], 1);
+ ok(cal.data.compareObjects(ritems[1], rinfo.getRecurrenceItemAt(1), Ci.calIRecurrenceItem));
+ rinfo.deleteRecurrenceItem(ritems[1]);
+
+ // isFinite = true
+ ok(rinfo.isFinite);
+ rinfo.appendRecurrenceItem(ritems[0]);
+ ok(rinfo.isFinite);
+
+ // isFinite = false
+ let item2 = makeEvent(
+ // eslint-disable-next-line no-useless-concat
+ "DTSTART:20020402T114500Z\n" + "DTEND:20020402T124500Z\n" + "RRULE:FREQ=WEEKLY;BYDAY=TU,WE\n"
+ );
+ ok(!item2.recurrenceInfo.isFinite);
+
+ // removeOccurrenceAt/restoreOccurreceAt
+ let occDate1 = cal.createDateTime("20020403T114500Z");
+ let occDate2 = cal.createDateTime("20020404T114500Z");
+ rinfo.removeOccurrenceAt(occDate1);
+ ok(item.icalString.includes("EXDATE"));
+ rinfo.restoreOccurrenceAt(occDate1);
+ ok(!item.icalString.includes("EXDATE"));
+
+ // modifyException / getExceptionFor
+ let occ1 = rinfo.getOccurrenceFor(occDate1);
+ occ1.QueryInterface(Ci.calIEvent);
+ occ1.startDate = cal.createDateTime("20020401T114500");
+ rinfo.modifyException(occ1, true);
+ ok(rinfo.getExceptionFor(occDate1) != null);
+
+ // modifyException immutable
+ let occ2 = rinfo.getOccurrenceFor(occDate2);
+ occ2.makeImmutable();
+ rinfo.modifyException(occ2, true);
+ ok(rinfo.getExceptionFor(occDate2) != null);
+
+ // getExceptionIds
+ let ids = rinfo.getExceptionIds();
+ equal(ids.length, 2);
+ ok(ids[0].compare(occDate1) == 0);
+ ok(ids[1].compare(occDate2) == 0);
+
+ // removeExceptionFor
+ rinfo.removeExceptionFor(occDate1);
+ ok(rinfo.getExceptionFor(occDate1) == null);
+ equal(rinfo.getExceptionIds().length, 1);
+}
+
+function test_rrule_interface() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n"
+ );
+
+ let rrule = item.recurrenceInfo.getRecurrenceItemAt(0);
+ rrule.QueryInterface(Ci.calIRecurrenceRule);
+ equal(rrule.type, "WEEKLY");
+ equal(rrule.interval, 2);
+ equal(rrule.count, 6);
+ ok(rrule.isByCount);
+ ok(!rrule.isNegative);
+ ok(rrule.isFinite);
+ equal(rrule.getComponent("BYDAY").toString(), [3, 4].toString());
+
+ // Now start changing things
+ rrule.setComponent("BYDAY", [4, 5]);
+ equal(rrule.icalString.match(/BYDAY=WE,TH/), "BYDAY=WE,TH");
+
+ rrule.count = -1;
+ ok(!rrule.isByCount);
+ ok(!rrule.isFinite);
+ equal(rrule.icalString.match(/COUNT=/), null);
+ throws(() => rrule.count, /0x80004005/);
+
+ rrule.interval = 1;
+ equal(rrule.interval, 1);
+ equal(rrule.icalString.match(/INTERVAL=/), null);
+
+ rrule.interval = 3;
+ equal(rrule.interval, 3);
+ equal(rrule.icalString.match(/INTERVAL=3/), "INTERVAL=3");
+
+ rrule.type = "MONTHLY";
+ equal(rrule.type, "MONTHLY");
+ equal(rrule.icalString.match(/FREQ=MONTHLY/), "FREQ=MONTHLY");
+
+ // untilDate (without UTC)
+ rrule.count = 3;
+ let untilDate = cal.createDateTime();
+ untilDate.timezone = cal.timezoneService.getTimezone("Europe/Berlin");
+ rrule.untilDate = untilDate;
+ ok(!rrule.isByCount);
+ throws(() => rrule.count, /0x80004005/);
+ equal(rrule.untilDate.icalString, untilDate.getInTimezone(cal.dtz.UTC).icalString);
+
+ // untilDate (with UTC)
+ rrule.count = 3;
+ untilDate = cal.createDateTime();
+ untilDate.timezone = cal.dtz.UTC;
+ rrule.untilDate = untilDate;
+ ok(!rrule.isByCount);
+ throws(() => rrule.count, /0x80004005/);
+ equal(rrule.untilDate.icalString, untilDate.icalString);
+}
+
+function test_startdate_change() {
+ // Setting a start date if its missing shouldn't throw
+ // eslint-disable-next-line no-useless-concat
+ let item = makeEvent("DTEND:20020402T124500Z\r\n" + "RRULE:FREQ=DAILY\r\n");
+ item.startDate = cal.createDateTime("20020502T114500Z");
+
+ function makeRecEvent(str) {
+ // eslint-disable-next-line no-useless-concat
+ return makeEvent("DTSTART:20020402T114500Z\r\n" + "DTEND:20020402T134500Z\r\n" + str);
+ }
+
+ function changeBy(changeItem, dur) {
+ let newDate = changeItem.startDate.clone();
+ newDate.addDuration(cal.createDuration(dur));
+ changeItem.startDate = newDate;
+ }
+
+ let ritem;
+
+ // Changing an existing start date for a recurring item shouldn't either
+ item = makeRecEvent("RRULE:FREQ=DAILY\r\n");
+ changeBy(item, "PT1H");
+
+ // Event with an rdate
+ item = makeRecEvent("RDATE:20020403T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceDate);
+ equal(ritem.date.icalString, "20020403T124500Z");
+
+ // Event with an exdate
+ item = makeRecEvent("EXDATE:20020403T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceDate);
+ equal(ritem.date.icalString, "20020403T124500Z");
+
+ // Event with an rrule with until date
+ item = makeRecEvent("RRULE:FREQ=WEEKLY;UNTIL=20020406T114500Z\r\n");
+ changeBy(item, "PT1H");
+ ritem = item.recurrenceInfo.getRecurrenceItemAt(0);
+ ritem.QueryInterface(Ci.calIRecurrenceRule);
+ equal(ritem.untilDate.icalString, "20020406T124500Z");
+
+ // Event with an exception item
+ item = makeRecEvent("RRULE:FREQ=DAILY\r\n");
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20020406T124500Z");
+ item.recurrenceInfo.modifyException(occ, true);
+ changeBy(item, "PT1H");
+ equal(item.startDate.icalString, "20020402T124500Z");
+ occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T124500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ equal(occ.startDate.icalString, "20020406T134500Z");
+}
+
+function test_idchange() {
+ let item = makeEvent(
+ "UID:unchanged\r\n" +
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:FREQ=DAILY\r\n"
+ );
+ let occ = item.recurrenceInfo.getOccurrenceFor(cal.createDateTime("20020406T114500Z"));
+ occ.QueryInterface(Ci.calIEvent);
+ occ.startDate = cal.createDateTime("20020406T124500Z");
+ item.recurrenceInfo.modifyException(occ, true);
+ equal(occ.id, "unchanged");
+
+ item.id = "changed";
+
+ occ = item.recurrenceInfo.getExceptionFor(cal.createDateTime("20020406T114500Z"));
+ equal(occ.id, "changed");
+}
+
+function test_failures() {
+ let item = makeEvent(
+ "DTSTART:20020402T114500Z\r\n" +
+ "DTEND:20020402T124500Z\r\n" +
+ "RRULE:INTERVAL=2;FREQ=WEEKLY;COUNT=6;BYDAY=TU,WE\r\n"
+ );
+ let rinfo = item.recurrenceInfo;
+ let ritem = cal.createRecurrenceDate();
+
+ throws(() => rinfo.getRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.getRecurrenceItemAt(1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItemAt(-1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItemAt(1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.deleteRecurrenceItem(ritem), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.insertRecurrenceItemAt(ritem, -1), /Illegal value/, "Invalid Argument");
+ throws(() => rinfo.insertRecurrenceItemAt(ritem, 2), /Illegal value/, "Invalid Argument");
+ throws(
+ () => rinfo.restoreOccurrenceAt(cal.createDateTime("20080101T010101")),
+ /Illegal value/,
+ "Invalid Argument"
+ );
+ throws(() => new CalRecurrenceInfo().isFinite, /Component not initialized/);
+
+ // modifyException with a different parent item
+ let occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z"));
+ occ.calendar = {};
+ occ.id = "1234";
+ occ.parentItem = occ;
+ throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument");
+
+ occ = rinfo.getOccurrenceFor(cal.createDateTime("20120102T114500Z"));
+ occ.recurrenceId = null;
+ throws(() => rinfo.modifyException(occ, true), /Illegal value/, "Invalid Argument");
+
+ // Missing DTSTART/DUE but RRULE
+ item = createTodoFromIcalString(
+ "BEGIN:VCALENDAR\r\n" +
+ "BEGIN:VTODO\r\n" +
+ "RRULE:FREQ=DAILY\r\n" +
+ "END:VTODO\r\n" +
+ "END:VCALENDAR\r\n"
+ );
+ rinfo = item.recurrenceInfo;
+ equal(
+ rinfo.getOccurrenceDates(
+ cal.createDateTime("20120101T010101"),
+ cal.createDateTime("20120203T010101"),
+ 0
+ ).length,
+ 0
+ );
+}
+
+function test_immutable() {
+ let item = createTodoFromIcalString(
+ "BEGIN:VCALENDAR\r\n" +
+ "BEGIN:VTODO\r\n" +
+ "RRULE:FREQ=DAILY\r\n" +
+ "END:VTODO\r\n" +
+ "END:VCALENDAR\r\n"
+ );
+ ok(item.recurrenceInfo.isMutable);
+ let rinfo = item.recurrenceInfo.clone();
+ let ritem = cal.createRecurrenceDate();
+ rinfo.makeImmutable();
+ rinfo.makeImmutable(); // Doing so twice shouldn't throw
+ throws(() => rinfo.appendRecurrenceItem(ritem), /Can not modify immutable data container/);
+ ok(!rinfo.isMutable);
+
+ item.recurrenceInfo.appendRecurrenceItem(ritem);
+}
+
+function test_rrule_icalstring() {
+ let recRule = cal.createRecurrenceRule();
+ recRule.type = "DAILY";
+ recRule.interval = 4;
+ equal(recRule.icalString, "RRULE:FREQ=DAILY;INTERVAL=4\r\n");
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "DAILY";
+ recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]);
+ equal(recRule.icalString, "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "WEEKLY";
+ recRule.interval = 3;
+ recRule.setComponent("BYDAY", [2, 4, 6]);
+ equal(recRule.icalString, "RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,WE,FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 4, 6]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [2, 3, 4, 5, 6, 7, 1]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR,SA,SU\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [2, 3, 4, 5, 6, 7, 1]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [10]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=1MO\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [10]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [20]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=2WE\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [20]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYDAY", [-22]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYDAY=-2FR\r\n");
+ deepEqual(recRule.getComponent("BYDAY"), [-22]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYMONTHDAY", [5]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=5\r\n");
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [5]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "MONTHLY";
+ recRule.setComponent("BYMONTHDAY", [1, 9, 17]);
+ equal(recRule.icalString, "RRULE:FREQ=MONTHLY;BYMONTHDAY=1,9,17\r\n");
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [1, 9, 17]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [1]);
+ recRule.setComponent("BYMONTHDAY", [3]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYMONTHDAY=3;BYMONTH=1\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=3\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [1]);
+ deepEqual(recRule.getComponent("BYMONTHDAY"), [3]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [3]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=TU;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=TU\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [3]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [10]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=1MO;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [10]);
+
+ recRule = cal.createRecurrenceRule();
+ recRule.type = "YEARLY";
+ recRule.setComponent("BYMONTH", [4]);
+ recRule.setComponent("BYDAY", [-22]);
+ ok(
+ [
+ "RRULE:FREQ=YEARLY;BYDAY=-2FR;BYMONTH=4\r\n",
+ "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-2FR\r\n",
+ ].includes(recRule.icalString)
+ );
+ deepEqual(recRule.getComponent("BYMONTH"), [4]);
+ deepEqual(recRule.getComponent("BYDAY"), [-22]);
+}
+
+function test_icalComponent() {
+ let duration = "PT3600S";
+ let eventString =
+ "DESCRIPTION:Repeat every Thursday starting Tue 2nd April 2002\n" +
+ "RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=6;BYDAY=TH\n" +
+ "DTSTART:20020402T114500\n" +
+ `DURATION:${duration}\n`;
+
+ let firstOccurrenceDate = createDate(2002, 4, 4, true, 11, 45, 0);
+
+ // Test each of these cases from the conditional in the icalComponent getter.
+ // * mIsProxy = true, value === null
+ // * mIsProxy = true, value !== null
+ // * mIsProxy = false, value === null
+ // * mIsProxy = false, value !== null
+ //
+ // Create a proxy for a given occurrence, modify properties on the proxy
+ // (checking before and after), then call the icalComponent getter to see
+ // whether both parent item and proxy item have the correct properties.
+
+ let parent = makeEvent(eventString);
+ let proxy = parent.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate);
+
+ equal(parent.getProperty("DURATION"), duration);
+ equal(proxy.getProperty("DURATION"), duration);
+
+ equal(parent.getProperty("LOCATION"), null);
+ equal(proxy.getProperty("LOCATION"), null);
+
+ let newDuration = "PT2200S";
+ let location = "Sherwood Forest";
+
+ proxy.setProperty("DURATION", newDuration);
+ proxy.setProperty("LOCATION", location);
+
+ equal(parent.getProperty("DURATION"), duration);
+ equal(proxy.getProperty("DURATION"), newDuration);
+
+ equal(parent.getProperty("LOCATION"), null);
+ equal(proxy.getProperty("LOCATION"), location);
+
+ equal(parent.icalComponent.duration.toString(), duration);
+ equal(proxy.icalComponent.duration.toString(), newDuration);
+
+ equal(parent.icalComponent.location, null);
+ equal(proxy.icalComponent.location, location);
+
+ // Test for bug 580896.
+
+ let event = makeEvent(eventString);
+ equal(event.getProperty("DURATION"), duration, "event has correct DURATION");
+
+ let occurrence = event.recurrenceInfo.getOccurrenceFor(firstOccurrenceDate);
+
+ equal(occurrence.getProperty("DURATION"), duration, "occurrence has correct DURATION");
+ equal(Boolean(occurrence.getProperty("DTEND")), true, "occurrence has DTEND");
+
+ ok(occurrence.icalComponent.duration, "occurrence icalComponent has DURATION");
+
+ // Changing the end date causes the duration to be set to null.
+ occurrence.endDate = createDate(2002, 4, 3);
+
+ equal(occurrence.getProperty("DURATION"), null, "occurrence DURATION has been set to null");
+
+ ok(!occurrence.icalComponent.duration, "occurrence icalComponent does not have DURATION");
+}
diff --git a/comm/calendar/test/unit/test_recurrence_utils.js b/comm/calendar/test/unit/test_recurrence_utils.js
new file mode 100644
index 0000000000..53f1aaf99f
--- /dev/null
+++ b/comm/calendar/test/unit/test_recurrence_utils.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { countOccurrences } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// tests for calRecurrenceUtils.jsm
+/* Incomplete - still missing test coverage for:
+ * recurrenceRule2String
+ * splitRecurrenceRules
+ * checkRecurrenceRule
+ */
+
+function getIcs(aProperties) {
+ let calendar = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ ];
+ calendar = calendar.concat(aProperties);
+ calendar = calendar.concat(["END:VCALENDAR"]);
+
+ return calendar.join("\r\n");
+}
+
+add_task(async function countOccurrences_test() {
+ let data = [
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98000",
+ "SUMMARY:Occurring 3 times until a date",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98001",
+ "SUMMARY:Occurring 3 times until a date with one exception in the middle",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180921T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98002",
+ "SUMMARY:Occurring 3 times until a date with one exception at the end",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180922T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98003",
+ "SUMMARY:Occurring 3 times until a date with one exception at the beginning",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+ "SUMMARY:Occurring 3 times until a date with the middle occurrence moved after the end",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98004",
+ "SUMMARY:The moved occurrence",
+ "RECURRENCE-ID:20180921T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180924T120000",
+ "DTEND;TZID=Europe/Berlin:20180924T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+ "SUMMARY:Occurring 3 times until a date with the middle occurrence moved before the beginning",
+ "RRULE:FREQ=DAILY;UNTIL=20180922T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98005",
+ "SUMMARY:The moved occurrence",
+ "RECURRENCE-ID:20180921T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180918T120000",
+ "DTEND;TZID=Europe/Berlin:20180918T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98006",
+ "SUMMARY:Occurring 1 times until a date",
+ "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 1,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98007",
+ "SUMMARY:Occurring 1 times until a date with occernce removed",
+ "RRULE:FREQ=DAILY;UNTIL=20180920T100000Z",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 0,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98008",
+ "SUMMARY:Occurring for 3 times",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98009",
+ "SUMMARY:Occurring for 3 times with an exception in the middle",
+ "EXDATE;TZID=Europe/Berlin:20180921T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98010",
+ "SUMMARY:Occurring for 3 times with an exception at the end",
+ "EXDATE;TZID=Europe/Berlin:20180922T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98011",
+ "SUMMARY:Occurring for 3 times with an exception at the beginning",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 2,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98012",
+ "SUMMARY:Occurring for 1 time",
+ "RRULE:FREQ=DAILY;COUNT=1",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 1,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98013",
+ "SUMMARY:Occurring for 0 times",
+ "RRULE:FREQ=DAILY;COUNT=1",
+ "EXDATE;TZID=Europe/Berlin:20180920T120000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 0,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98014",
+ "SUMMARY:Occurring infinitely",
+ "RRULE:FREQ=DAILY",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: null,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98015",
+ "SUMMARY:Non-occurring item",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: null,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98016",
+ "SUMMARY:Occurring for 3 time and 1 rdate",
+ "RRULE:FREQ=DAILY;COUNT=3",
+ "RDATE;TZID=Europe/Berlin:20180923T100000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 4,
+ },
+ {
+ input: [
+ "BEGIN:VEVENT",
+ "CREATED:20180912T090539Z",
+ "LAST-MODIFIED:20180912T090539Z",
+ "DTSTAMP:20180912T090539Z",
+ "UID:5b47fa17-f2fe-4d96-8cc2-19ce5be98017",
+ "SUMMARY:Occurring for 3 rdates",
+ "RDATE;TZID=Europe/Berlin:20180920T120000",
+ "RDATE;TZID=Europe/Berlin:20180921T100000",
+ "RDATE;TZID=Europe/Berlin:20180922T140000",
+ "DTSTART;TZID=Europe/Berlin:20180920T120000",
+ "DTEND;TZID=Europe/Berlin:20180920T130000",
+ "END:VEVENT",
+ ],
+ expected: 3,
+ },
+ ];
+
+ let i = 0;
+ for (let test of data) {
+ i++;
+
+ let ics = getIcs(test.input);
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ parser.parseString(ics);
+ let items = parser.getItems();
+
+ ok(items.length > 0, "parsing input succeeded (test #" + i + ")");
+ for (let item of items) {
+ equal(
+ countOccurrences(item),
+ test.expected,
+ "expected number of occurrences (test #" + i + " - '" + item.title + "')"
+ );
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_relation.js b/comm/calendar/test/unit/test_relation.js
new file mode 100644
index 0000000000..148d5a6118
--- /dev/null
+++ b/comm/calendar/test/unit/test_relation.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+function run_test() {
+ // Create Relation
+ let relation1 = new CalRelation();
+
+ // Create Items
+ let event1 = new CalEvent();
+ let event2 = new CalEvent();
+
+ // Testing relation set/get.
+ let properties = {
+ relType: "PARENT",
+ relId: event2.id,
+ };
+
+ for (let [property, value] of Object.entries(properties)) {
+ relation1[property] = value;
+ equal(relation1[property], value);
+ }
+
+ // Add relation to event
+ event1.addRelation(relation1);
+
+ // Add 2nd relation to event.
+ let relation2 = new CalRelation();
+ relation2.relId = "myid2";
+ event1.addRelation(relation2);
+
+ // Check the item functions
+ checkRelations(event1, [relation1, relation2]);
+
+ // modify the Relations
+ modifyRelations(event1, [relation1, relation2]);
+
+ // test icalproperty
+ // eslint-disable-next-line no-unused-expressions
+ relation2.icalProperty;
+
+ test_icalprop();
+}
+
+function checkRelations(event, expRel) {
+ let allRel = event.getRelations();
+ equal(allRel.length, expRel.length);
+
+ // check if all expacted relations are found
+ for (let i = 0; i < expRel.length; i++) {
+ ok(allRel.includes(expRel[i]));
+ }
+
+ // Check if all found relations are expected
+ for (let i = 0; i < allRel.length; i++) {
+ ok(expRel.includes(allRel[i]));
+ }
+}
+
+function modifyRelations(event, oldRel) {
+ let allRel = event.getRelations();
+ let rel = allRel[0];
+
+ // modify the properties
+ rel.relType = "SIBLING";
+ equal(rel.relType, "SIBLING");
+ equal(rel.relType, allRel[0].relType);
+
+ // remove one relation
+ event.removeRelation(rel);
+ equal(event.getRelations().length, oldRel.length - 1);
+
+ // add one relation and remove all relations
+ event.addRelation(oldRel[0]);
+ event.removeAllRelations();
+ equal(event.getRelations(), 0);
+}
+
+function test_icalprop() {
+ let rel = new CalRelation();
+
+ rel.relType = "SIBLING";
+ rel.setParameter("X-PROP", "VAL");
+ rel.relId = "value";
+
+ let prop = rel.icalProperty;
+ let propOrig = rel.icalProperty;
+
+ equal(rel.icalString, prop.icalString);
+
+ equal(prop.value, "value");
+ equal(prop.getParameter("X-PROP"), "VAL");
+ equal(prop.getParameter("RELTYPE"), "SIBLING");
+
+ prop.value = "changed";
+ prop.setParameter("RELTYPE", "changedtype");
+ prop.setParameter("X-PROP", "changedxprop");
+
+ equal(rel.relId, "value");
+ equal(rel.getParameter("X-PROP"), "VAL");
+ equal(rel.relType, "SIBLING");
+
+ rel.icalProperty = prop;
+
+ equal(rel.relId, "changed");
+ equal(rel.getParameter("X-PROP"), "changedxprop");
+ equal(rel.relType, "changedtype");
+
+ rel.icalString = propOrig.icalString;
+
+ equal(rel.relId, "value");
+ equal(rel.getParameter("X-PROP"), "VAL");
+ equal(rel.relType, "SIBLING");
+
+ let rel2 = rel.clone();
+ rel.icalProperty = prop;
+
+ notEqual(rel.icalString, rel2.icalString);
+
+ rel.deleteParameter("X-PROP");
+ equal(rel.icalProperty.getParameter("X-PROP"), null);
+
+ throws(() => {
+ rel.icalString = "X-UNKNOWN:value";
+ }, /Illegal value/);
+}
diff --git a/comm/calendar/test/unit/test_rfc3339_parser.js b/comm/calendar/test/unit/test_rfc3339_parser.js
new file mode 100644
index 0000000000..8098bdddf6
--- /dev/null
+++ b/comm/calendar/test/unit/test_rfc3339_parser.js
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ // Check if the RFC 3339 date and timezone are properly parsed to the
+ // expected result and if the result is properly mapped back into the RFC
+ // 3339 date.
+ function testRfc3339(
+ aRfc3339Date,
+ aTimezone,
+ aExpectedDateTime,
+ aExpectedRfc3339Date = aRfc3339Date
+ ) {
+ // Test creating a dateTime object from an RFC 3339 string.
+ let dateTime = cal.dtz.fromRFC3339(aRfc3339Date, aTimezone);
+
+ // Check that each property is as expected.
+ let expectedDateProps = {
+ year: aExpectedDateTime[0],
+ month: aExpectedDateTime[1] - 1, // 0 based month.
+ day: aExpectedDateTime[2],
+ hour: aExpectedDateTime[3],
+ minute: aExpectedDateTime[4],
+ second: aExpectedDateTime[5],
+ timezone: aExpectedDateTime[6],
+ isDate: aExpectedDateTime[7],
+ };
+ for (let prop in expectedDateProps) {
+ info("Checking prop: " + prop);
+ // Object comparison fails with ical.js, and we only want to check
+ // that we have the right timezone.
+ if (prop == "timezone") {
+ equal(dateTime[prop].tzid, expectedDateProps[prop].tzid);
+ } else {
+ equal(dateTime[prop], expectedDateProps[prop]);
+ }
+ }
+
+ // Test round tripping that dateTime object back to an RFC 3339 string.
+ let rfc3339Date = cal.dtz.toRFC3339(dateTime);
+
+ // In theory this should just match the input RFC 3339 date, but there are
+ // multiple ways of generating the same time, e.g. 2006-03-14Z is
+ // equivalent to 2006-03-14.
+ equal(rfc3339Date, aExpectedRfc3339Date);
+ }
+
+ /*
+ * Some notes about the differences between calIDateTime and the RFC 3339
+ * specification:
+ * 1. calIDateTime does not support fractions of a second, they are
+ * stripped.
+ * 2. If a timezone cannot be matched to the given time offset, the
+ * date/time is returned as a UTC date/time.
+ * 3. The first timezone (alphabetically) that has the same offset is
+ * chosen.
+ * 4. Leap seconds are not supported by calIDateTime, it resets to
+ * [0-23]:[0-59]:[0-59].
+ *
+ * All tests are done under the default timezone and UTC (although both
+ * should give the same time).
+ */
+
+ // An arbitrary timezone (that has daylight savings time).
+ let getTz = aTz => cal.timezoneService.getTimezone(aTz);
+ let timezone = getTz("America/New_York");
+ let utc = cal.dtz.UTC;
+
+ // Timezones used in tests. This isn't a great representation, as we don't
+ // care what the actual timezone is, just that the offset is correct. Offset
+ // isn't presently easily accessible from the timezone object, however.
+ let utcminus6 = getTz("America/Bahia_Banderas");
+ let dawson = getTz("America/Dawson");
+
+ /*
+ * Basic tests
+ */
+ // This represents March 14, 2006 in the default timezone.
+ testRfc3339("2006-03-14", timezone, [2006, 3, 14, 0, 0, 0, timezone, true]);
+ testRfc3339("2006-03-14", utc, [2006, 3, 14, 0, 0, 0, utc, true]);
+ // This represents March 14, 2006 in UTC.
+ testRfc3339("2006-03-14Z", timezone, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14");
+ testRfc3339("2006-03-14Z", utc, [2006, 3, 14, 0, 0, 0, utc, true], "2006-03-14");
+
+ // This represents 30 minutes and 53 seconds past the 13th hour of November
+ // 14, 2050 in UTC.
+ testRfc3339(
+ "2050-11-14t13:30:53z",
+ timezone,
+ [2050, 11, 14, 13, 30, 53, utc, false],
+ "2050-11-14T13:30:53Z"
+ );
+ testRfc3339(
+ "2050-11-14t13:30:53z",
+ utc,
+ [2050, 11, 14, 13, 30, 53, utc, false],
+ "2050-11-14T13:30:53Z"
+ );
+
+ // This represents 03:00:23 on October 14, 2004 in Central Standard Time.
+ testRfc3339("2004-10-14T03:00:23-06:00", timezone, [2004, 10, 14, 3, 0, 23, utcminus6, false]);
+ testRfc3339("2004-10-14T03:00:23-06:00", utc, [2004, 10, 14, 3, 0, 23, utcminus6, false]);
+
+ /*
+ * The following tests are the RFC 3339 examples
+ * http://tools.ietf.org/html/rfc3339
+ * Most of these would "fail" since iCalDateTime does not supported
+ * all parts of the specification, the true proper response is next to each
+ * test line as a comment.
+ */
+
+ // This represents 20 minutes and 50.52 seconds after the 23rd hour of
+ // April 12th, 1985 in UTC.
+ testRfc3339(
+ "1985-04-12T23:20:50.52Z",
+ timezone,
+ [1985, 4, 12, 23, 20, 50, utc, false],
+ "1985-04-12T23:20:50Z"
+ ); // 1985/04/12 23:20:50.52 UTC isDate=0
+ testRfc3339(
+ "1985-04-12T23:20:50.52Z",
+ utc,
+ [1985, 4, 12, 23, 20, 50, utc, false],
+ "1985-04-12T23:20:50Z"
+ ); // 1985/04/12 23:20:50.52 UTC isDate=0
+
+ // This represents 39 minutes and 57 seconds after the 16th hour of December
+ // 19th, 1996 with an offset of -08:00 from UTC (Pacific Standard Time).
+ // Note that this is equivalent to in UTC.
+ testRfc3339("1996-12-19T16:39:57-08:00", timezone, [1996, 12, 19, 16, 39, 57, dawson, false]);
+ testRfc3339("1996-12-19T16:39:57-08:00", utc, [1996, 12, 19, 16, 39, 57, dawson, false]);
+ testRfc3339("1996-12-20T00:39:57Z", timezone, [1996, 12, 20, 0, 39, 57, utc, false]);
+ testRfc3339("1996-12-20T00:39:57Z", utc, [1996, 12, 20, 0, 39, 57, utc, false]);
+
+ // This represents the same instant of time as noon, January 1, 1937,
+ // Netherlands time. Standard time in the Netherlands was exactly 19 minutes
+ // and 32.13 seconds ahead of UTC by law from 1909-05-01 through 1937-06-30.
+ // This time zone cannot be represented exactly using the HH:MM format, and
+ // this timestamp uses the closest representable UTC offset.
+ //
+ // Since no current timezone exists at +00:20 it will default to giving the
+ // time in UTC.
+ testRfc3339(
+ "1937-01-01T12:00:27.87+00:20",
+ timezone,
+ [1937, 1, 1, 12, 20, 27, utc, false],
+ "1937-01-01T12:20:27Z"
+ ); // 1937/01/01 12:20:27.87 UTC isDate=0
+ testRfc3339(
+ "1937-01-01T12:00:27.87+00:20",
+ utc,
+ [1937, 1, 1, 12, 20, 27, utc, false],
+ "1937-01-01T12:20:27Z"
+ ); // 1937/01/01 12:20:27.87 UTC isDate=0
+
+ // This represents the leap second inserted at the end of 1990.
+ testRfc3339(
+ "1990-12-31T23:59:60Z",
+ timezone,
+ [1991, 1, 1, 0, 0, 0, utc, false],
+ "1991-01-01T00:00:00Z"
+ ); // 1990/12/31 23:59:60 UTC isDate=0
+ testRfc3339(
+ "1990-12-31T23:59:60Z",
+ utc,
+ [1991, 1, 1, 0, 0, 0, utc, false],
+ "1991-01-01T00:00:00Z"
+ ); // 1990/12/31 23:59:60 UTC isDate=0
+ // This represents the same leap second in Pacific Standard Time, 8
+ // hours behind UTC.
+ testRfc3339(
+ "1990-12-31T15:59:60-08:00",
+ timezone,
+ [1990, 12, 31, 16, 0, 0, dawson, false],
+ "1990-12-31T16:00:00-08:00"
+ ); // 1990/12/31 15:59:60 America/Dawson isDate=0
+ testRfc3339(
+ "1990-12-31T15:59:60-08:00",
+ utc,
+ [1990, 12, 31, 16, 0, 0, dawson, false],
+ "1990-12-31T16:00:00-08:00"
+ ); // 1990/12/31 15:59:60 America/Dawson isDate=0
+}
diff --git a/comm/calendar/test/unit/test_startup_service.js b/comm/calendar/test/unit/test_startup_service.js
new file mode 100644
index 0000000000..cfb3f76571
--- /dev/null
+++ b/comm/calendar/test/unit/test_startup_service.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let ssvc = Cc["@mozilla.org/calendar/startup-service;1"].getService(Ci.nsIObserver);
+
+ let first = {
+ startup(aListener) {
+ second.canStart = true;
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aListener) {
+ ok(this.canStop);
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ let second = {
+ startup(aListener) {
+ ok(this.canStart);
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ shutdown(aListener) {
+ first.canStop = true;
+ aListener.onResult(null, Cr.NS_OK);
+ },
+ };
+
+ // Change the startup order so we can test our services
+ let oldStartupOrder = ssvc.wrappedJSObject.getStartupOrder;
+ ssvc.wrappedJSObject.getStartupOrder = function () {
+ let origOrder = oldStartupOrder.call(this);
+
+ let notify = origOrder[origOrder.length - 1];
+ return [first, second, notify];
+ };
+
+ // Pretend a startup run
+ ssvc.observe(null, "profile-after-change", null);
+ ok(second.canStart);
+
+ // Pretend a stop run
+ ssvc.observe(null, "profile-before-change", null);
+ ok(first.canStop);
+}
diff --git a/comm/calendar/test/unit/test_storage.js b/comm/calendar/test/unit/test_storage.js
new file mode 100644
index 0000000000..d4a42be428
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async () => {
+ await new Promise(resolve => {
+ do_calendar_startup(resolve);
+ });
+
+ let storage = getStorageCal();
+ let str = [
+ "BEGIN:VEVENT",
+ "UID:attachItem",
+ "DTSTART:20120101T010101Z",
+ "ATTACH;FMTTYPE=text/calendar;ENCODING=BASE64;FILENAME=test.ics:http://example.com/test.ics",
+ "ATTENDEE;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Name;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT;X-THING=BAR:mailto:test@example.com",
+ "RELATED-TO;RELTYPE=SIBLING;FOO=BAR:VALUE",
+ "RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5;BYDAY=MO",
+ "RDATE:20120201T010101Z",
+ "EXDATE:20120301T010101Z",
+ "END:VEVENT",
+ ].join("\r\n");
+
+ let storageItem = createEventFromIcalString(str);
+
+ let addedItemId = (await storage.addItem(storageItem)).id;
+
+ // Make sure the cache is cleared, otherwise we'll get the cached item.
+ delete storage.wrappedJSObject.mItemModel.itemCache[addedItemId];
+
+ let item = await storage.getItem(addedItemId);
+
+ // Check start date
+ equal(item.startDate.compare(cal.createDateTime("20120101T010101Z")), 0);
+
+ // Check attachment
+ let attaches = item.getAttachments();
+ let attach = attaches[0];
+ equal(attaches.length, 1);
+ equal(attach.uri.spec, "http://example.com/test.ics");
+ equal(attach.formatType, "text/calendar");
+ equal(attach.encoding, "BASE64");
+ equal(attach.getParameter("FILENAME"), "test.ics");
+
+ // Check attendee
+ let attendees = item.getAttendees();
+ let attendee = attendees[0];
+ equal(attendees.length, 1);
+ equal(attendee.id, "mailto:test@example.com");
+ equal(attendee.commonName, "Name");
+ equal(attendee.rsvp, "TRUE");
+ equal(attendee.isOrganizer, false);
+ equal(attendee.role, "REQ-PARTICIPANT");
+ equal(attendee.participationStatus, "ACCEPTED");
+ equal(attendee.userType, "INDIVIDUAL");
+ equal(attendee.getProperty("X-THING"), "BAR");
+
+ // Check relation
+ let relations = item.getRelations();
+ let rel = relations[0];
+ equal(relations.length, 1);
+ equal(rel.relType, "SIBLING");
+ equal(rel.relId, "VALUE");
+ equal(rel.getParameter("FOO"), "BAR");
+
+ // Check recurrence item
+ for (let ritem of item.recurrenceInfo.getRecurrenceItems()) {
+ if (ritem instanceof Ci.calIRecurrenceRule) {
+ equal(ritem.type, "MONTHLY");
+ equal(ritem.interval, 2);
+ equal(ritem.count, 5);
+ equal(ritem.isByCount, true);
+ equal(ritem.getComponent("BYDAY").toString(), [2].toString());
+ equal(ritem.isNegative, false);
+ } else if (ritem instanceof Ci.calIRecurrenceDate) {
+ if (ritem.isNegative) {
+ equal(ritem.date.compare(cal.createDateTime("20120301T010101Z")), 0);
+ } else {
+ equal(ritem.date.compare(cal.createDateTime("20120201T010101Z")), 0);
+ }
+ } else {
+ do_throw("Found unknown recurrence item " + ritem);
+ }
+ }
+});
diff --git a/comm/calendar/test/unit/test_storage_connection.js b/comm/calendar/test/unit/test_storage_connection.js
new file mode 100644
index 0000000000..2984fa2dc4
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage_connection.js
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_setup(async function () {
+ do_get_profile();
+ await new Promise(resolve => cal.manager.startup({ onResult: resolve }));
+});
+
+/**
+ * Tests that local storage calendars share a database connection.
+ */
+add_task(async function testLocal() {
+ let localCalendarA = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI(`moz-storage-calendar://`)
+ );
+ localCalendarA.id = cal.getUUID();
+ let dbA = localCalendarA.wrappedJSObject.mStorageDb.db;
+
+ let localCalendarB = cal.manager.createCalendar(
+ "storage",
+ Services.io.newURI(`moz-storage-calendar://`)
+ );
+ localCalendarB.id = cal.getUUID();
+ let dbB = localCalendarB.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"),
+ "local calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "local.sqlite"),
+ "local calendar B uses the right database file"
+ );
+ Assert.equal(dbA, dbB, "local calendars share a database connection");
+});
+
+/**
+ * Tests that local storage calendars using the same specified database file share a connection,
+ * and that local storage calendars with a different specified database file do not.
+ */
+add_task(async function testLocalFile() {
+ let testFileA = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-a.sqlite"));
+ let testFileB = new FileUtils.File(PathUtils.join(PathUtils.tempDir, "file-b.sqlite"));
+
+ let fileCalendarA = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA));
+ fileCalendarA.id = cal.getUUID();
+ let dbA = fileCalendarA.wrappedJSObject.mStorageDb.db;
+
+ let fileCalendarB = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileB));
+ fileCalendarB.id = cal.getUUID();
+ let dbB = fileCalendarB.wrappedJSObject.mStorageDb.db;
+
+ let fileCalendarC = cal.manager.createCalendar("storage", Services.io.newFileURI(testFileA));
+ fileCalendarC.id = cal.getUUID();
+ let dbC = fileCalendarC.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ testFileA.path,
+ "local calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ testFileB.path,
+ "local calendar B uses the right database file"
+ );
+ Assert.equal(
+ dbC.databaseFile.path,
+ testFileA.path,
+ "local calendar C uses the right database file"
+ );
+ Assert.notEqual(
+ dbA,
+ dbB,
+ "calendars with different file URLs do not share a database connection"
+ );
+ Assert.notEqual(
+ dbB,
+ dbC,
+ "calendars with different file URLs do not share a database connection"
+ );
+ Assert.equal(dbA, dbC, "calendars with matching file URLs share a database connection");
+});
+
+/**
+ * Tests that cached network calendars share a database connection.
+ */
+add_task(async function testNetwork() {
+ // Pretend to be offline so connecting to calendars that don't exist doesn't throw errors.
+ Services.io.offline = true;
+
+ let networkCalendarA = cal.manager.createCalendar(
+ "ics",
+ Services.io.newURI("http://localhost/ics")
+ );
+ networkCalendarA.id = cal.getUUID();
+ networkCalendarA.setProperty("cache.enabled", true);
+ cal.manager.registerCalendar(networkCalendarA);
+ networkCalendarA = cal.manager.getCalendarById(networkCalendarA.id);
+ let dbA = networkCalendarA.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db;
+
+ let networkCalendarB = cal.manager.createCalendar(
+ "caldav",
+ Services.io.newURI("http://localhost/caldav")
+ );
+ networkCalendarB.id = cal.getUUID();
+ networkCalendarB.setProperty("cache.enabled", true);
+ cal.manager.registerCalendar(networkCalendarB);
+ networkCalendarB = cal.manager.getCalendarById(networkCalendarB.id);
+ let dbB = networkCalendarB.wrappedJSObject.mCachedCalendar.wrappedJSObject.mStorageDb.db;
+
+ Assert.equal(
+ dbA.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"),
+ "network calendar A uses the right database file"
+ );
+ Assert.equal(
+ dbB.databaseFile.path,
+ PathUtils.join(PathUtils.profileDir, "calendar-data", "cache.sqlite"),
+ "network calendar B uses the right database file"
+ );
+ Assert.equal(dbA, dbB, "network calendars share a database connection");
+});
diff --git a/comm/calendar/test/unit/test_storage_get_items.js b/comm/calendar/test/unit/test_storage_get_items.js
new file mode 100644
index 0000000000..828e59fecd
--- /dev/null
+++ b/comm/calendar/test/unit/test_storage_get_items.js
@@ -0,0 +1,338 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalStorageCalendar.getItems method.
+ */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+
+do_get_profile();
+
+/**
+ * The bug we are interested in testing requires the calendar to clear its
+ * caches in order to take effect. Since we can't directly access the internals
+ * of the calendar here, we instead provide a custom function that lets us
+ * create more than one calendar with the same id.
+ */
+function createStorageCalendar(id) {
+ let db = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ db.append("test_storage.sqlite");
+ let uri = Services.io.newFileURI(db);
+
+ // Make sure timezone service is initialized
+ Cc["@mozilla.org/calendar/timezone-service;1"].getService(Ci.calIStartupService).startup(null);
+
+ let calendar = Cc["@mozilla.org/calendar/calendar;1?type=storage"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+
+ calendar.uri = uri;
+ calendar.id = id;
+ return calendar;
+}
+
+/**
+ * Tests that recurring event/todo exceptions have their properties properly
+ * loaded. See bug 1664731.
+ *
+ * @param {number} filterType - Number indicating the filter type.
+ * @param {calIITemBase} originalItem - The original item to add to the calendar.
+ * @param {object} originalProps - The initial properites of originalItem to
+ * expect.
+ * @param {object[]} changedProps - A list containing property values to update
+ * each occurrence with or null. The length indicates how many occurrences to
+ * expect.
+ */
+async function doPropertiesTest(filterType, originalItem, originalProps, changedPropList) {
+ for (let [key, value] of Object.entries(originalProps)) {
+ if (key == "CATEGORIES") {
+ originalItem.setCategories(value);
+ } else {
+ originalItem.setProperty(key, value);
+ }
+ }
+
+ let calId = cal.getUUID();
+ let calendar = createStorageCalendar(calId);
+ await calendar.addItem(originalItem);
+
+ let filter =
+ filterType |
+ Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL |
+ Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES;
+
+ let savedItems = await calendar.getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+
+ Assert.equal(
+ savedItems.length,
+ changedPropList.length,
+ `created ${changedPropList.length} items successfully`
+ );
+
+ // Ensure all occurrences have the correct properties initially.
+ for (let item of savedItems) {
+ for (let [key, value] of Object.entries(originalProps)) {
+ if (key == "CATEGORIES") {
+ Assert.equal(
+ item.getCategories().join(),
+ value.join(),
+ `item categories are set to ${value}`
+ );
+ } else {
+ Assert.equal(item.getProperty(key), value, `item property "${key}" is set to "${value}"`);
+ }
+ }
+ }
+
+ // Modify the occurrences that have new properties set in changedPropList.
+ for (let idx = 0; idx < changedPropList.length; idx++) {
+ let changedProps = changedPropList[idx];
+ if (changedProps) {
+ let targetOccurrence = savedItems[idx];
+ let targetException = targetOccurrence.clone();
+
+ // Make the changes to the properties.
+ for (let [key, value] of Object.entries(changedProps)) {
+ if (key == "CATEGORIES") {
+ targetException.setCategories(value);
+ } else {
+ targetException.setProperty(key, value);
+ }
+ }
+
+ await calendar.modifyItem(
+ cal.itip.prepareSequence(targetException, targetOccurrence),
+ targetOccurrence
+ );
+
+ // Refresh the saved items list after the change.
+ savedItems = await calendar.getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+ }
+ }
+
+ // Get a fresh copy of the occurrences by using a new calendar with the
+ // same id.
+ let itemsAfterUpdate = await createStorageCalendar(calId).getItemsAsArray(
+ filter,
+ 0,
+ cal.createDateTime("20201201T000000Z"),
+ cal.createDateTime("20201231T000000Z")
+ );
+
+ Assert.equal(
+ itemsAfterUpdate.length,
+ changedPropList.length,
+ `count of occurrences retrieved after update is ${changedPropList.length}`
+ );
+
+ // Compare each property of each occurrence to ensure the changed
+ // occurrences have the values we expect.
+ for (let i = 0; i < itemsAfterUpdate.length; i++) {
+ let item = itemsAfterUpdate[i];
+ let isException = changedPropList[i] != null;
+ let label = isException ? `modified occurrence ${i}` : `unmodified occurrence ${i}`;
+ let checkedProps = isException ? changedPropList[i] : originalProps;
+
+ for (let [key, value] of Object.entries(checkedProps)) {
+ if (key == "CATEGORIES") {
+ Assert.equal(
+ item.getCategories().join(),
+ value.join(),
+ `item categories has value "${value}"`
+ );
+ } else {
+ Assert.equal(
+ item.getProperty(key),
+ value,
+ `property "${key}" has value "${value}" for "${label}"`
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Test event exceptions load their properties.
+ */
+add_task(async function testEventPropertiesForRecurringExceptionsLoad() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VEVENT
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test event.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ };
+
+ let changedProps = [
+ null,
+ null,
+ {
+ DESCRIPTION: "This is an edited occurrence.",
+ CATEGORIES: ["Holiday"],
+ LOCATION: "Georgetown",
+ },
+ null,
+ null,
+ ];
+
+ return doPropertiesTest(
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT,
+ event,
+ originalProps,
+ changedProps
+ );
+});
+
+/**
+ * Test todo exceptions load their properties.
+ */
+add_task(async function testTodoPropertiesForRecurringExceptionsLoad() {
+ let todo = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VTODO
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test todo.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ STATUS: "NEEDS-ACTION",
+ };
+
+ let changedProps = [
+ null,
+ null,
+ {
+ DESCRIPTION: "This is an edited occurrence.",
+ CATEGORIES: ["Holiday"],
+ LOCATION: "Georgetown",
+ STATUS: "COMPLETE",
+ },
+ null,
+ null,
+ ];
+
+ return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps);
+});
+
+/**
+ * Tests calling getItems() does not overwrite subsequent event occurrence
+ * exceptions with their parent item. See bug 1686466.
+ */
+add_task(async function testRecurringEventChangesAreNotHiddenByCache() {
+ let event = new CalEvent(CalendarTestUtils.dedent`
+ BEGIN:VEVENT
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VEVENT
+ `);
+
+ let originalProps = {
+ LOCATION: "San Juan",
+ };
+
+ let changedProps = [
+ null,
+ {
+ LOCATION: "Buenos Aries",
+ },
+ {
+ LOCATION: "Bridgetown",
+ },
+ {
+ LOCATION: "Freetown",
+ },
+ null,
+ ];
+
+ return doPropertiesTest(
+ Ci.calICalendar.ITEM_FILTER_TYPE_EVENT,
+ event,
+ originalProps,
+ changedProps,
+ true
+ );
+});
+
+/**
+ * Tests calling getItems() does not overwrite subsequent todo occurrence
+ * exceptions with their parent item. See bug 1686466.
+ */
+add_task(async function testRecurringTodoChangesNotHiddenByCache() {
+ let todo = new CalTodo(CalendarTestUtils.dedent`
+ BEGIN:VTODO
+ CREATED:20201211T000000Z
+ LAST-MODIFIED:20201211T000000Z
+ DTSTAMP:20201210T080410Z
+ UID:c1a6cfe7-7fbb-4bfb-a00d-861e07c649a5
+ SUMMARY:Original Test Event
+ DTSTART:20201211T000000Z
+ DTEND:20201211T110000Z
+ RRULE:FREQ=DAILY;UNTIL=20201215T140000Z
+ END:VTODO
+ `);
+
+ let originalProps = {
+ DESCRIPTION: "This is a test todo.",
+ CATEGORIES: ["Birthday"],
+ LOCATION: "Castara",
+ STATUS: "NEEDS-ACTION",
+ };
+
+ let changedProps = [
+ null,
+ {
+ STATUS: "COMPLETE",
+ },
+ {
+ STATUS: "COMPLETE",
+ },
+ {
+ STATUS: "COMPLETE",
+ },
+ null,
+ ];
+
+ return doPropertiesTest(Ci.calICalendar.ITEM_FILTER_TYPE_TODO, todo, originalProps, changedProps);
+});
diff --git a/comm/calendar/test/unit/test_timezone.js b/comm/calendar/test/unit/test_timezone.js
new file mode 100644
index 0000000000..d11b380b52
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+});
+
+function run_test() {
+ do_test_pending();
+ cal.timezoneService.QueryInterface(Ci.calIStartupService).startup({
+ onResult() {
+ really_run_test();
+ do_test_finished();
+ },
+ });
+}
+
+function really_run_test() {
+ let event = new CalEvent();
+
+ let str = [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//RDU Software//NONSGML HandCal//EN",
+ "VERSION:2.0",
+ "BEGIN:VTIMEZONE",
+ "TZID:America/New_York",
+ "BEGIN:STANDARD",
+ "DTSTART:19981025T020000",
+ "TZOFFSETFROM:-0400",
+ "TZOFFSETTO:-0500",
+ "TZNAME:EST",
+ "END:STANDARD",
+ "BEGIN:DAYLIGHT",
+ "DTSTART:19990404T020000",
+ "TZOFFSETFROM:-0500",
+ "TZOFFSETTO:-0400",
+ "TZNAME:EDT",
+ "END:DAYLIGHT",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ "DTSTAMP:19980309T231000Z",
+ "UID:guid-1.example.com",
+ "ORGANIZER:mailto:mrbig@example.com",
+ "ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:",
+ " mailto:employee-A@example.com",
+ "DESCRIPTION:Project XYZ Review Meeting",
+ "CATEGORIES:MEETING",
+ "CLASS:PUBLIC",
+ "CREATED:19980309T130000Z",
+ "SUMMARY:XYZ Project Review",
+ "DTSTART;TZID=America/New_York:19980312T083000",
+ "DTEND;TZID=America/New_York:19980312T093000",
+ "LOCATION:1CP Conference Room 4350",
+ "END:VEVENT",
+ "END:VCALENDAR",
+ "",
+ ].join("\r\n");
+
+ let strTz = [
+ "BEGIN:VTIMEZONE",
+ "TZID:America/New_York",
+ "BEGIN:STANDARD",
+ "DTSTART:19981025T020000",
+ "TZOFFSETFROM:-0400",
+ "TZOFFSETTO:-0500",
+ "TZNAME:EST",
+ "END:STANDARD",
+ "BEGIN:DAYLIGHT",
+ "DTSTART:19990404T020000",
+ "TZOFFSETFROM:-0500",
+ "TZOFFSETTO:-0400",
+ "TZNAME:EDT",
+ "END:DAYLIGHT",
+ "END:VTIMEZONE",
+ "",
+ ].join("\r\n");
+
+ event.icalString = str;
+
+ let startDate = event.startDate;
+ let endDate = event.endDate;
+
+ startDate.timezone = cal.timezoneService.getTimezone(startDate.timezone.tzid);
+ endDate.timezone = cal.timezoneService.getTimezone(endDate.timezone.tzid);
+ notEqual(strTz, startDate.timezone.toString());
+}
diff --git a/comm/calendar/test/unit/test_timezone_changes.js b/comm/calendar/test/unit/test_timezone_changes.js
new file mode 100644
index 0000000000..125187ca67
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone_changes.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const FEBRUARY = 1;
+const OCTOBER = 9;
+const NOVEMBER = 10;
+
+const UTC_MINUS_3 = -3 * 3600;
+const UTC_MINUS_2 = -2 * 3600;
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// This test requires timezone data going back to 2016. It's been kept here as an example.
+/* add_test(function testCaracas() {
+ let time = cal.createDateTime();
+ let zone = cal.timezoneService.getTimezone("America/Caracas");
+
+ for (let month = JANUARY; month <= DECEMBER; month++) {
+ time.resetTo(2015, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+ }
+
+ for (let month = JANUARY; month <= APRIL; month++) {
+ time.resetTo(2016, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+ }
+
+ time.resetTo(2016, MAY, 1, 1, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_430, time.toString());
+
+ time.resetTo(2016, MAY, 1, 3, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+
+ for (let month = JUNE; month <= DECEMBER; month++) {
+ time.resetTo(2016, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+ }
+
+ for (let month = JANUARY; month <= DECEMBER; month++) {
+ time.resetTo(2017, month, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_4, time.toString());
+ }
+
+ run_next_test();
+}); */
+
+// Brazil's rules are complicated. This tests every change in the time range we have data for.
+// Updated for 2019b: Brazil no longer has DST.
+add_test(function testSaoPaulo() {
+ let time = cal.createDateTime();
+ let zone = cal.timezoneService.getTimezone("America/Sao_Paulo");
+
+ time.resetTo(2018, FEBRUARY, 17, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2018, FEBRUARY, 18, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2018, NOVEMBER, 3, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2018, NOVEMBER, 4, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2019, FEBRUARY, 16, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_2, time.toString());
+
+ time.resetTo(2019, FEBRUARY, 17, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2019, NOVEMBER, 2, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2019, NOVEMBER, 3, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, FEBRUARY, 15, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, FEBRUARY, 16, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, OCTOBER, 31, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ time.resetTo(2020, NOVEMBER, 1, 0, 0, 0, zone);
+ equal(time.timezoneOffset, UTC_MINUS_3, time.toString());
+
+ run_next_test();
+});
diff --git a/comm/calendar/test/unit/test_timezone_definition.js b/comm/calendar/test/unit/test_timezone_definition.js
new file mode 100644
index 0000000000..79cddc2245
--- /dev/null
+++ b/comm/calendar/test/unit/test_timezone_definition.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_calendar_startup(run_next_test);
+}
+
+// check tz database version
+add_task(async function version_test() {
+ ok(cal.timezoneService.version, "service should provide timezone version");
+});
+
+// check whether all tz definitions have all properties
+add_task(async function zone_test() {
+ function resolveZone(aZoneId) {
+ let timezone = cal.timezoneService.getTimezone(aZoneId);
+ equal(aZoneId, timezone.tzid, "Zone test " + aZoneId);
+ ok(
+ timezone.icalComponent.serializeToICS().startsWith("BEGIN:VTIMEZONE"),
+ "VTIMEZONE test " + aZoneId
+ );
+ }
+
+ let foundZone = false;
+ for (let zone of cal.timezoneService.timezoneIds) {
+ foundZone = true;
+ resolveZone(zone);
+ }
+
+ ok(foundZone, "There is at least one timezone");
+});
diff --git a/comm/calendar/test/unit/test_transaction_manager.js b/comm/calendar/test/unit/test_transaction_manager.js
new file mode 100644
index 0000000000..bd9c591560
--- /dev/null
+++ b/comm/calendar/test/unit/test_transaction_manager.js
@@ -0,0 +1,431 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests for the CalTransactionManager and the various CalTransaction instances.
+ */
+
+const { CalendarTestUtils } = ChromeUtils.import(
+ "resource://testing-common/calendar/CalendarTestUtils.jsm"
+);
+const { CalEvent } = ChromeUtils.import("resource:///modules/CalEvent.jsm");
+const { CalTodo } = ChromeUtils.import("resource:///modules/CalTodo.jsm");
+const {
+ CalTransactionManager,
+ CalTransaction,
+ CalBatchTransaction,
+ CalAddTransaction,
+ CalModifyTransaction,
+ CalDeleteTransaction,
+} = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm");
+
+/**
+ * Records the number of times doTransction() and undoTransction() is called.
+ */
+class MockCalTransaction extends CalTransaction {
+ /**
+ * The number of times doTransaction() was called.
+ *
+ * @type {number}
+ */
+ done = 0;
+
+ /**
+ * The number of times undoTransaction() was called.
+ */
+ undone = 0;
+
+ _writable;
+
+ constructor(writable = true) {
+ super();
+ this._writable = writable;
+ }
+
+ canWrite() {
+ return this._writable;
+ }
+
+ async doTransaction() {
+ this.done++;
+ }
+
+ async undoTransaction() {
+ this.undone++;
+ }
+}
+
+/**
+ * Tests a list of CalMockTransactions have the expected "done" and "undone"
+ * values.
+ *
+ * @param {CalMockTransaction[][]} batches The transaction batches to check.
+ * @param {number[][][]} expected - A 3 dimensional array containing
+ * the expected "done" and "undone" values for each transaction in each batch
+ * to be tested.
+ */
+function doBatchTest(batches, expected) {
+ for (let [batch, transactions] of batches.entries()) {
+ for (let [index, trn] of transactions.entries()) {
+ let [doneCount, undoneCount] = expected[batch][index];
+ Assert.equal(
+ trn.done,
+ doneCount,
+ `batch ${batch}, transaction ${index} doTransaction() called ${doneCount} times`
+ );
+ Assert.equal(
+ trn.undone,
+ undoneCount,
+ `batch ${batch}, transaction ${index} undoTransaction() called ${undoneCount} times`
+ );
+ }
+ }
+}
+
+add_setup(async function () {
+ await new Promise(resolve => do_load_calmgr(resolve));
+});
+
+/**
+ * Tests the CalTransactionManager methods work as expected.
+ */
+add_task(async function testCalTransactionManager() {
+ let manager = new CalTransactionManager();
+
+ Assert.ok(!manager.canUndo(), "canUndo() returns false with an empty undo stack");
+ Assert.ok(!manager.canRedo(), "canRedo() returns false with an empty redo stack");
+ Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returns nothing with an empty undo stack");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returns nothing with an empty redo stack");
+
+ info("calling CalTransactionManager.commit()");
+ let trn = new MockCalTransaction();
+ await manager.commit(trn);
+ Assert.equal(trn.done, 1, "doTransaction() called once");
+ Assert.equal(trn.undone, 0, "undoTransaction() was not called");
+ Assert.ok(manager.canUndo(), "canUndo() returned true");
+ Assert.ok(!manager.canRedo(), "canRedo() returned false");
+ Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing");
+
+ info("calling CalTransactionManager.undo()");
+ await manager.undo();
+ Assert.equal(trn.done, 1, "doTransaction() was not called again");
+ Assert.equal(trn.undone, 1, "undoTransaction() was called once");
+ Assert.ok(!manager.canUndo(), "canUndo() returned false");
+ Assert.ok(manager.canRedo(), "canRedo() returned true");
+ Assert.ok(!manager.peekUndoStack(), "peekUndoStack() returned nothing");
+ Assert.equal(manager.peekRedoStack(), trn, "peekRedoStack() returned the transaction");
+
+ info("calling CalTransactionManager.redo()");
+ await manager.redo();
+ Assert.equal(trn.done, 2, "doTransaction() was called again");
+ Assert.equal(trn.undone, 1, "undoTransaction() was not called again");
+ Assert.ok(manager.canUndo(), "canUndo() returned true");
+ Assert.ok(!manager.canRedo(), "canRedo() returned false");
+ Assert.equal(manager.peekUndoStack(), trn, "peekUndoStack() returned the transaction");
+ Assert.ok(!manager.peekRedoStack(), "peekRedoStack() returned nothing");
+
+ info("testing CalTransactionManager.beginBatch()");
+ manager = new CalTransactionManager();
+
+ let batch = manager.beginBatch();
+ Assert.ok(batch instanceof CalBatchTransaction, "beginBatch() returned a CalBatchTransaction");
+ Assert.equal(manager.undoStack[0], batch, "the CalBatchTransaction is on the undo stack");
+});
+
+/**
+ * Tests the BatchTransaction works as expected.
+ */
+add_task(async function testBatchTransaction() {
+ let batch = new CalBatchTransaction();
+
+ Assert.ok(!batch.canWrite(), "canWrite() returns false for an empty BatchTransaction");
+ await batch.commit(new MockCalTransaction());
+ await batch.commit(new MockCalTransaction(false));
+ await batch.commit(new MockCalTransaction());
+ Assert.ok(!batch.canWrite(), "canWrite() returns false if any transaction is not writable");
+
+ let transactions = [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()];
+ batch = new CalBatchTransaction();
+ for (let trn of transactions) {
+ await batch.commit(trn);
+ }
+
+ Assert.ok(batch.canWrite(), "canWrite() returns true when all transactions are writable");
+ info("testing commit() calls doTransaction() on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ ]
+ );
+
+ await batch.undoTransaction();
+ info("testing undoTransaction() called on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]
+ );
+
+ await batch.doTransaction();
+ info("testing doTransaction() called again on each transaction in batch");
+ doBatchTest(
+ [transactions],
+ [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ ]
+ );
+});
+
+/**
+ * Tests that executing multiple batch transactions in sequence works.
+ */
+add_task(async function testSequentialBatchTransactions() {
+ let manager = new CalTransactionManager();
+
+ let batchTransactions = [
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ [new MockCalTransaction(), new MockCalTransaction(), new MockCalTransaction()],
+ ];
+
+ let batch0 = manager.beginBatch();
+ for (let trn of batchTransactions[0]) {
+ await batch0.commit(trn);
+ }
+
+ let batch1 = manager.beginBatch();
+ for (let trn of batchTransactions[1]) {
+ await batch1.commit(trn);
+ }
+
+ let batch2 = manager.beginBatch();
+ for (let trn of batchTransactions[2]) {
+ await batch2.commit(trn);
+ }
+
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ ]);
+
+ // Undo the top most batch.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Undo the next batch.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 0],
+ [1, 0],
+ [1, 0],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Undo the last batch left.
+ await manager.undo();
+ doBatchTest(batchTransactions, [
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the first batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the second batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ],
+ ]);
+
+ // Redo the last batch.
+ await manager.redo();
+ doBatchTest(batchTransactions, [
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ [
+ [2, 1],
+ [2, 1],
+ [2, 1],
+ ],
+ ]);
+});
+
+/**
+ * Tests CalAddTransaction executes and reverses as expected.
+ */
+add_task(async function testCalAddTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+
+ let trn = new CalAddTransaction(event, calendar, null, null);
+ await trn.doTransaction();
+
+ let addedEvent = await calendar.getItem("test");
+ Assert.ok(!!addedEvent, "transaction added event to the calendar");
+
+ await trn.undoTransaction();
+ addedEvent = await calendar.getItem("test");
+ Assert.ok(!addedEvent, "transaction removed event from the calendar");
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Tests CalModifyTransaction executes and reverses as expected.
+ */
+add_task(async function testCalModifyTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+ event.title = "Event";
+
+ let addedEvent = await calendar.addItem(event);
+ Assert.ok(!!addedEvent, "event was added to the calendar");
+
+ let modifiedEvent = addedEvent.clone();
+ modifiedEvent.title = "Modified Event";
+
+ let trn = new CalModifyTransaction(modifiedEvent, calendar, addedEvent, null);
+ await trn.doTransaction();
+ modifiedEvent = await calendar.getItem("test");
+ Assert.ok(!!modifiedEvent);
+ Assert.equal(modifiedEvent.title, "Modified Event", "transaction modified event");
+
+ await trn.undoTransaction();
+ let revertedEvent = await calendar.getItem("test");
+ Assert.ok(!!revertedEvent);
+ Assert.equal(revertedEvent.title, "Event", "transaction reverted event to original state");
+ CalendarTestUtils.removeCalendar(calendar);
+});
+
+/**
+ * Tests CalDeleteTransaction executes and reverses as expected.
+ */
+add_task(async function testCalDeleteTransaction() {
+ let calendar = CalendarTestUtils.createCalendar("Test", "memory");
+ let event = new CalEvent();
+ event.id = "test";
+ event.title = "Event";
+
+ let addedEvent = await calendar.addItem(event);
+ Assert.ok(!!addedEvent, "event was added to the calendar");
+
+ let trn = new CalDeleteTransaction(addedEvent, calendar, null, null);
+ await trn.doTransaction();
+
+ let result = await calendar.getItem("test");
+ Assert.ok(!result, "event was deleted from the calendar");
+
+ await trn.undoTransaction();
+ let revertedEvent = await calendar.getItem("test");
+ Assert.ok(!!revertedEvent, "event was restored to the calendar");
+ CalendarTestUtils.removeCalendar(calendar);
+});
diff --git a/comm/calendar/test/unit/test_unifinder_utils.js b/comm/calendar/test/unit/test_unifinder_utils.js
new file mode 100644
index 0000000000..ae44379781
--- /dev/null
+++ b/comm/calendar/test/unit/test_unifinder_utils.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ test_get_item_sort_key();
+ test_sort_items();
+}
+
+function test_get_item_sort_key() {
+ let event = new CalEvent(dedent`
+ BEGIN:VEVENT
+ PRIORITY:8
+ SUMMARY:summary
+ DTSTART:20180102T030405Z
+ DTEND:20180607T080910Z
+ CATEGORIES:a,b,c
+ LOCATION:location
+ STATUS:CONFIRMED
+ END:VEVENT
+ `);
+
+ strictEqual(cal.unifinder.getItemSortKey(event, "nothing"), null);
+ equal(cal.unifinder.getItemSortKey(event, "priority"), 8);
+ equal(cal.unifinder.getItemSortKey(event, "title"), "summary");
+ equal(cal.unifinder.getItemSortKey(event, "startDate"), 1514862245000000);
+ equal(cal.unifinder.getItemSortKey(event, "endDate"), 1528358950000000);
+ equal(cal.unifinder.getItemSortKey(event, "categories"), "a, b, c");
+ equal(cal.unifinder.getItemSortKey(event, "location"), "location");
+ equal(cal.unifinder.getItemSortKey(event, "status"), 1);
+
+ let task = new CalTodo(dedent`
+ BEGIN:VTODO
+ DTSTART:20180102T030405Z
+ DUE:20180607T080910Z
+ PERCENT-COMPLETE:20
+ STATUS:COMPLETED
+ END:VTODO
+ `);
+
+ equal(cal.unifinder.getItemSortKey(task, "priority"), 5);
+ strictEqual(cal.unifinder.getItemSortKey(task, "title"), "");
+ equal(cal.unifinder.getItemSortKey(task, "entryDate"), 1514862245000000);
+ equal(cal.unifinder.getItemSortKey(task, "dueDate"), 1528358950000000);
+ equal(cal.unifinder.getItemSortKey(task, "completedDate"), -62168601600000000);
+ equal(cal.unifinder.getItemSortKey(task, "percentComplete"), 20);
+ strictEqual(cal.unifinder.getItemSortKey(task, "categories"), "");
+ strictEqual(cal.unifinder.getItemSortKey(task, "location"), "");
+ equal(cal.unifinder.getItemSortKey(task, "status"), 2);
+
+ let task2 = new CalTodo(dedent`
+ BEGIN:VTODO
+ STATUS:GETTIN' THERE
+ END:VTODO
+ `);
+ equal(cal.unifinder.getItemSortKey(task2, "percentComplete"), 0);
+ equal(cal.unifinder.getItemSortKey(task2, "status"), -1);
+
+ // Default CalTodo objects have the default percentComplete.
+ let task3 = new CalTodo();
+ equal(cal.unifinder.getItemSortKey(task3, "percentComplete"), 0);
+}
+
+function test_sort_items() {
+ // string comparison
+ let summaries = ["", "a", "b"];
+ let items = summaries.map(summary => {
+ return new CalEvent(dedent`
+ BEGIN:VEVENT
+ SUMMARY:${summary}
+ END:VEVENT
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "title", 1);
+ deepEqual(
+ items.map(item => item.title),
+ ["a", "b", null]
+ );
+
+ cal.unifinder.sortItems(items, "title", -1);
+ deepEqual(
+ items.map(item => item.title),
+ [null, "b", "a"]
+ );
+
+ // date comparison
+ let dates = ["20180101T000002Z", "20180101T000000Z", "20180101T000001Z"];
+ items = dates.map(date => {
+ return new CalEvent(dedent`
+ BEGIN:VEVENT
+ DTSTART:${date}
+ END:VEVENT
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "startDate", 1);
+ deepEqual(
+ items.map(item => item.startDate.icalString),
+ ["20180101T000000Z", "20180101T000001Z", "20180101T000002Z"]
+ );
+
+ cal.unifinder.sortItems(items, "startDate", -1);
+ deepEqual(
+ items.map(item => item.startDate.icalString),
+ ["20180101T000002Z", "20180101T000001Z", "20180101T000000Z"]
+ );
+
+ // number comparison
+ let percents = [3, 1, 2];
+ items = percents.map(percent => {
+ return new CalTodo(dedent`
+ BEGIN:VTODO
+ PERCENT-COMPLETE:${percent}
+ END:VTODO
+ `);
+ });
+
+ cal.unifinder.sortItems(items, "percentComplete", 1);
+ deepEqual(
+ items.map(item => item.percentComplete),
+ [1, 2, 3]
+ );
+
+ cal.unifinder.sortItems(items, "percentComplete", -1);
+ deepEqual(
+ items.map(item => item.percentComplete),
+ [3, 2, 1]
+ );
+}
diff --git a/comm/calendar/test/unit/test_utils.js b/comm/calendar/test/unit/test_utils.js
new file mode 100644
index 0000000000..05d7423808
--- /dev/null
+++ b/comm/calendar/test/unit/test_utils.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_recentzones();
+ test_formatcss();
+ test_getDefaultStartDate();
+ test_getStartEndProps();
+ test_OperationGroup();
+ test_sameDay();
+ test_binarySearch();
+}
+
+function test_recentzones() {
+ equal(cal.dtz.getRecentTimezones().length, 0);
+ equal(cal.dtz.getRecentTimezones(true).length, 0);
+
+ cal.dtz.saveRecentTimezone("Europe/Berlin");
+
+ let zones = cal.dtz.getRecentTimezones();
+ equal(zones.length, 1);
+ equal(zones[0], "Europe/Berlin");
+ zones = cal.dtz.getRecentTimezones(true);
+ equal(zones.length, 1);
+ equal(zones[0].tzid, "Europe/Berlin");
+
+ cal.dtz.saveRecentTimezone(cal.dtz.defaultTimezone.tzid);
+ equal(cal.dtz.getRecentTimezones().length, 1);
+ equal(cal.dtz.getRecentTimezones(true).length, 1);
+
+ cal.dtz.saveRecentTimezone("Europe/Berlin");
+ equal(cal.dtz.getRecentTimezones().length, 1);
+ equal(cal.dtz.getRecentTimezones(true).length, 1);
+
+ cal.dtz.saveRecentTimezone("America/New_York");
+ equal(cal.dtz.getRecentTimezones().length, 2);
+ equal(cal.dtz.getRecentTimezones(true).length, 2);
+
+ cal.dtz.saveRecentTimezone("Unknown");
+ equal(cal.dtz.getRecentTimezones().length, 3);
+ equal(cal.dtz.getRecentTimezones(true).length, 2);
+}
+
+function test_formatcss() {
+ equal(cal.view.formatStringForCSSRule(" "), "_");
+ equal(cal.view.formatStringForCSSRule("ΓΌ"), "-uxfc-");
+ equal(cal.view.formatStringForCSSRule("a"), "a");
+}
+
+function test_getDefaultStartDate() {
+ function transform(nowString, refDateString) {
+ now = cal.createDateTime(nowString);
+ let refDate = refDateString ? cal.createDateTime(refDateString) : null;
+ return cal.dtz.getDefaultStartDate(refDate);
+ }
+
+ let oldNow = cal.dtz.now;
+ let now = cal.createDateTime("20120101T000000");
+ cal.dtz.now = function () {
+ return now;
+ };
+
+ dump("TT: " + cal.createDateTime("20120101T000000") + "\n");
+ dump("TT: " + cal.dtz.getDefaultStartDate(cal.createDateTime("20120101T000000")) + "\n");
+
+ equal(transform("20120101T000000").icalString, "20120101T010000");
+ equal(transform("20120101T015959").icalString, "20120101T020000");
+ equal(transform("20120101T230000").icalString, "20120101T230000");
+ equal(transform("20120101T235959").icalString, "20120101T230000");
+
+ equal(transform("20120101T000000", "20120202").icalString, "20120202T010000");
+ equal(transform("20120101T015959", "20120202").icalString, "20120202T020000");
+ equal(transform("20120101T230000", "20120202").icalString, "20120202T230000");
+ equal(transform("20120101T235959", "20120202").icalString, "20120202T230000");
+
+ let event = new CalEvent();
+ now = cal.createDateTime("20120101T015959");
+ cal.dtz.setDefaultStartEndHour(event, cal.createDateTime("20120202"));
+ equal(event.startDate.icalString, "20120202T020000");
+ equal(event.endDate.icalString, "20120202T030000");
+
+ let todo = new CalTodo();
+ now = cal.createDateTime("20120101T000000");
+ cal.dtz.setDefaultStartEndHour(todo, cal.createDateTime("20120202"));
+ equal(todo.entryDate.icalString, "20120202T010000");
+
+ cal.dtz.now = oldNow;
+}
+
+function test_getStartEndProps() {
+ equal(cal.dtz.startDateProp(new CalEvent()), "startDate");
+ equal(cal.dtz.endDateProp(new CalEvent()), "endDate");
+ equal(cal.dtz.startDateProp(new CalTodo()), "entryDate");
+ equal(cal.dtz.endDateProp(new CalTodo()), "dueDate");
+
+ throws(() => cal.dtz.startDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/);
+ throws(() => cal.dtz.endDateProp(null), /NS_ERROR_NOT_IMPLEMENTED/);
+}
+
+function test_OperationGroup() {
+ let cancelCalled = false;
+ function cancelFunc() {
+ cancelCalled = true;
+ return true;
+ }
+
+ let group = new cal.data.OperationGroup(cancelFunc);
+
+ ok(group.isEmpty);
+ ok(group.id.endsWith("-0"));
+ equal(group.status, Cr.NS_OK);
+ equal(group.isPending, true);
+
+ let completedOp = { isPending: false };
+
+ group.add(completedOp);
+ ok(group.isEmpty);
+ equal(group.isPending, true);
+
+ let pendingOp1 = {
+ id: 1,
+ isPending: true,
+ cancel() {
+ this.cancelCalled = true;
+ return true;
+ },
+ };
+
+ group.add(pendingOp1);
+ ok(!group.isEmpty);
+ equal(group.isPending, true);
+
+ let pendingOp2 = {
+ id: 2,
+ isPending: true,
+ cancel() {
+ this.cancelCalled = true;
+ return true;
+ },
+ };
+
+ group.add(pendingOp2);
+ group.remove(pendingOp1);
+ ok(!group.isEmpty);
+ equal(group.isPending, true);
+
+ group.cancel();
+
+ equal(group.status, Ci.calIErrors.OPERATION_CANCELLED);
+ ok(!group.isPending);
+ ok(cancelCalled);
+ ok(pendingOp2.cancelCalled);
+}
+
+function test_sameDay() {
+ let createDate = cal.createDateTime.bind(cal);
+
+ ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101T120000")));
+ ok(cal.dtz.sameDay(createDate("20120101"), createDate("20120101")));
+ ok(!cal.dtz.sameDay(createDate("20120101"), createDate("20120102")));
+ ok(!cal.dtz.sameDay(createDate("20120101T120000"), createDate("20120102T120000")));
+}
+
+function test_binarySearch() {
+ let arr = [2, 5, 7, 9, 20, 27, 34, 39, 41, 53, 62];
+ equal(cal.data.binarySearch(arr, 27), 5); // Center
+ equal(cal.data.binarySearch(arr, 2), 0); // Left most
+ equal(cal.data.binarySearch(arr, 62), 11); // Right most
+
+ equal(cal.data.binarySearch([5], 5), 1); // One element found
+ equal(cal.data.binarySearch([1], 0), 0); // One element insert left
+ equal(cal.data.binarySearch([1], 2), 1); // One element insert right
+}
diff --git a/comm/calendar/test/unit/test_view_utils.js b/comm/calendar/test/unit/test_view_utils.js
new file mode 100644
index 0000000000..da988f8042
--- /dev/null
+++ b/comm/calendar/test/unit/test_view_utils.js
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+function run_test() {
+ do_calendar_startup(really_run_test);
+}
+
+function really_run_test() {
+ test_not_a_date();
+ test_compare_event_and_todo();
+ test_compare_startdate();
+ test_compare_enddate();
+ test_compare_alldayevent();
+ test_compare_title();
+ test_compare_todo();
+}
+
+function test_not_a_date() {
+ let item = new CalEvent();
+
+ let result = cal.view.compareItems(null, item);
+ equal(result, -1);
+
+ result = cal.view.compareItems(item, null);
+ equal(result, 1);
+}
+
+function test_compare_event_and_todo() {
+ let a = new CalEvent();
+ let b = new CalTodo();
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, 1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, -1);
+}
+
+function test_compare_startdate() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(2000, 0, 1, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_enddate() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1, 1);
+ a.endDate = createDate(1990, 0, 2, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1, 1);
+ b.endDate = createDate(1990, 0, 5, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_alldayevent() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1);
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1, 1);
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_title() {
+ let a = new CalEvent();
+ a.startDate = createDate(1990, 0, 1);
+ a.title = "Abc";
+ let b = new CalEvent();
+ b.startDate = createDate(1990, 0, 1);
+ b.title = "Xyz";
+
+ let result = cal.view.compareItems(a, b);
+ equal(result, -1);
+
+ result = cal.view.compareItems(b, a);
+ equal(result, 1);
+
+ result = cal.view.compareItems(a, a);
+ equal(result, 0);
+}
+
+function test_compare_todo() {
+ let a = new CalTodo();
+ let b = new CalTodo();
+
+ let cmp = cal.view.compareItems(a, b);
+ equal(cmp, 0);
+
+ cmp = cal.view.compareItems(b, a);
+ equal(cmp, 0);
+
+ cmp = cal.view.compareItems(a, a);
+ equal(cmp, 0);
+}
diff --git a/comm/calendar/test/unit/test_webcal.js b/comm/calendar/test/unit/test_webcal.js
new file mode 100644
index 0000000000..77d9576f4b
--- /dev/null
+++ b/comm/calendar/test/unit/test_webcal.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+function run_test() {
+ let httpserv = new HttpServer();
+ httpserv.registerPrefixHandler("/", {
+ handle(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ equal(request.path, "/test_webcal");
+ },
+ });
+ httpserv.start(-1);
+
+ let baseUri = "://localhost:" + httpserv.identity.primaryPort + "/test_webcal";
+ add_test(check_webcal_uri.bind(null, "webcal" + baseUri));
+ // TODO webcals needs bug 466524 to be fixed
+ // add_test(check_webcal_uri.bind(null, "webcals" + baseUri));
+ add_test(() => httpserv.stop(run_next_test));
+
+ // Now lets go...
+ run_next_test();
+}
+
+function check_webcal_uri(aUri) {
+ let uri = Services.io.newURI(aUri);
+
+ let channel = Services.io.newChannelFromURI(
+ uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ NetUtil.asyncFetch(channel, (data, status, request) => {
+ ok(Components.isSuccessCode(status));
+ run_next_test();
+ });
+}
diff --git a/comm/calendar/test/unit/test_weekinfo_service.js b/comm/calendar/test/unit/test_weekinfo_service.js
new file mode 100644
index 0000000000..9be4d02dd3
--- /dev/null
+++ b/comm/calendar/test/unit/test_weekinfo_service.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ // Bug 1239622. The 1st of January after a leap year which ends with
+ // a Thursday belongs to week number 53 unless the start of week is
+ // set on Friday.
+ let wkst_wknum_date = [
+ [1, 53, "20210101T000000Z"], // Year 2021 affected by Bug 1239622
+ [5, 1, "20210101T000000Z"], //
+ [3, 53, "20490101T000000Z"], // Year 2049 affected by Bug 1239622
+ [5, 1, "20490101T000000Z"], //
+ [0, 1, "20170101T000000Z"], // Year that starts on Sunday ...
+ [3, 52, "20180101T000000Z"], // ... Monday
+ [0, 1, "20190101T000000Z"], // ... Tuesday
+ [4, 52, "20200101T000000Z"], // ... Wednesday
+ [0, 1, "20260101T000000Z"], // ... Thursday
+ [0, 53, "20270101T000000Z"], // ... Friday
+ [0, 52, "20280101T000000Z"],
+ ]; // ... Saturday
+
+ let savedWeekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ for (let [weekStart, weekNumber, dateString] of wkst_wknum_date) {
+ Services.prefs.setIntPref("calendar.week.start", weekStart);
+ let date = cal.createDateTime(dateString);
+ date.isDate = true;
+ let week = cal.weekInfoService.getWeekTitle(date);
+
+ equal(week, weekNumber, "Week number matches for " + dateString);
+ }
+ Services.prefs.setIntPref("calendar.week.start", savedWeekStart);
+}
diff --git a/comm/calendar/test/unit/xpcshell.ini b/comm/calendar/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..2d4639d0b6
--- /dev/null
+++ b/comm/calendar/test/unit/xpcshell.ini
@@ -0,0 +1,82 @@
+[DEFAULT]
+head = head.js
+prefs =
+ calendar.timezone.local=UTC
+ calendar.timezone.useSystemTimezone=false
+ calendar.itip.updateInvitationForNewAttendeesOnly=true
+support-files = data/**
+tags = calendar
+
+[test_alarm.js]
+[test_alarmservice.js]
+[test_alarmutils.js]
+[test_attachment.js]
+[test_attendee.js]
+[test_auth_utils.js]
+[test_bug1199942.js]
+[test_bug1204255.js]
+[test_bug1209399.js]
+[test_bug1790339.js]
+[test_bug272411.js]
+[test_bug343792.js]
+[test_bug350845.js]
+[test_bug356207.js]
+[test_bug485571.js]
+[test_bug486186.js]
+[test_bug494140.js]
+[test_bug523860.js]
+[test_bug653924.js]
+[test_bug668222.js]
+[test_bug759324.js]
+[test_caldav_requests.js]
+[test_CalendarFileImporter.js]
+[test_calIteratorUtils.js]
+[test_calmgr.js]
+[test_calreadablestreamfactory.js]
+[test_calStorageHelpers.js]
+[test_data_bags.js]
+[test_datetime.js]
+[test_datetime_before_1970.js]
+[test_datetimeformatter.js]
+[test_deleted_items.js]
+[test_duration.js]
+[test_email_utils.js]
+[test_extract.js]
+[test_extract_parser.js]
+[test_extract_parser_parse.js]
+[test_extract_parser_service.js]
+[test_extract_parser_tokenize.js]
+[test_filter.js]
+[test_filter_mixin.js]
+[test_filter_tree_view.js]
+[test_freebusy.js]
+[test_freebusy_service.js]
+[test_hashedarray.js]
+[test_ics.js]
+[test_ics_parser.js]
+[test_ics_service.js]
+[test_imip.js]
+[test_invitationutils.js]
+[test_items.js]
+[test_itip_message_sender.js]
+[test_itip_utils.js]
+[test_l10n_utils.js]
+[test_lenient_parsing.js]
+[test_providers.js]
+[test_recur.js]
+[test_recurrence_utils.js]
+[test_relation.js]
+[test_rfc3339_parser.js]
+[test_startup_service.js]
+[test_storage.js]
+[test_storage_connection.js]
+[test_storage_get_items.js]
+[test_timezone.js]
+[test_timezone_changes.js]
+[test_timezone_definition.js]
+[test_transaction_manager.js]
+[test_unifinder_utils.js]
+[test_utils.js]
+[test_view_utils.js]
+[test_webcal.js]
+[test_weekinfo_service.js]