Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor calendar engine into its own package #267

Merged
merged 63 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
00e1eb0
oauth works
mickmister Dec 11, 2020
742a49c
autocomplete
mickmister Dec 11, 2020
7f238a2
viewcal works
mickmister Dec 11, 2020
19ff374
add unimplemented if blocks
mickmister Feb 20, 2021
38ebd1f
Merge branch 'master' into migrate-to-gcal
mickmister Feb 20, 2021
a88921f
fix tests and lint
mickmister Sep 14, 2021
8259bde
implement CreateMySubscription, RenewSubscription, and DeleteSubscrip…
mickmister Sep 17, 2021
83db731
WIP gcal subs
mickmister Sep 21, 2021
b057dcf
setup-attach make target
mickmister Sep 21, 2021
863cb37
Merge branch 'master' into gcal-merge
mickmister Jul 24, 2023
38f3fdf
fix lint and tests
mickmister Jul 24, 2023
9ed5ee6
chore: more compatible tar command
fmartingr Jul 24, 2023
d78381a
add review comments
mickmister Jul 25, 2023
728630e
[GCAL] Create event logic (#269)
fmartingr Jul 25, 2023
585e821
bug: get timezone from datetime instead of event data (#271)
fmartingr Jul 26, 2023
e30acd4
[GCAL] Allow building two different plugins under the same codebase (…
fmartingr Jul 31, 2023
b139719
[GCAL] Endpoint to autocomplete connected calendar users (#275)
fmartingr Aug 1, 2023
e3d6a94
[GCAL] Refactor Microsoft Calendar references to provider references …
fmartingr Aug 1, 2023
336a906
[GCAL] Enable notifications and reminders when a superuser token is n…
fmartingr Aug 1, 2023
34ca3ed
[GCAL] Added encrypted key value storage (#270)
fmartingr Aug 1, 2023
421c8d5
[GCAL] today/tomorrow commands with styling (#273)
fmartingr Aug 1, 2023
f226b5e
[GCAL] Fix test in main branch from PR merges (#277)
fmartingr Aug 1, 2023
aa14450
[GCAL] Move configuration readyness logic to remotes (#279)
fmartingr Aug 1, 2023
d3fe76b
[GCAL] Remove unused code (#278)
fmartingr Aug 1, 2023
b345fef
[GCAL] Fix event notifications not working due to missing scope permi…
fmartingr Aug 2, 2023
eba9102
[GCAL] Unsubscriptions (#283)
fmartingr Aug 2, 2023
b3f4558
update gcal manifest (#285)
fmartingr Aug 4, 2023
23a3f23
[GCAL] Better logging information (#287)
fmartingr Aug 4, 2023
95c9a44
use correct manifest file on manifest calls (#290)
fmartingr Aug 4, 2023
735fc05
[GCAL] Embed tzdata and correctly parse location from event datetime …
fmartingr Aug 4, 2023
c47dd5e
[GCAL] Event notifications behind a feature flag (#292)
fmartingr Aug 4, 2023
7a45ad0
ignore notifications if there's no processor (#293)
fmartingr Aug 4, 2023
7f673be
[GCAL] Remove join event column (broken from merges) (#294)
fmartingr Aug 4, 2023
2a4ec5d
[GCAL] Channels reminder underlying logic (#274)
fmartingr Aug 7, 2023
1ab957a
Updated google calendar readme
fmartingr Aug 9, 2023
2ddf28b
[GCAL] Summary command fixes (#286)
fmartingr Aug 11, 2023
4eda283
[GCAL] Add a modal to create events (#281)
mickmister Aug 16, 2023
37a55c0
[GCAL/MSCAL] Store linked events per user to remove links on disconne…
fmartingr Aug 16, 2023
aeb352a
[GCAL/MSCAL] Remind only accepted events (#295)
fmartingr Aug 16, 2023
ac12e63
Remove GoogleDomainVerifyKey setting (#299)
fmartingr Aug 16, 2023
f8a8f75
fix: encrypt user store (#297)
fmartingr Aug 16, 2023
dc937e2
feat: send notifications when an event is created (#300)
fmartingr Aug 17, 2023
abd0eec
typo: user already connected message (#301)
fmartingr Aug 18, 2023
1cc2149
typo: user already connected message (#301)
fmartingr Aug 18, 2023
974d5d0
[GCAL/MSCAL] Exclude rejected events from agenda commands (#302)
fmartingr Aug 18, 2023
b6bed01
[GCAL/MSCAL] Create event modal only for connected accounts (#303)
fmartingr Aug 23, 2023
39544fd
updated settings to use style and removed current value line (#305)
fmartingr Aug 23, 2023
9ef367a
show link to connect if not connected (#306)
fmartingr Aug 23, 2023
73dfe63
[GCAL/MSCAL] Reduce welcome steps (#308)
fmartingr Aug 24, 2023
2bb4367
[GCAL/MSCAL] Control start/date times if the selected date is today (…
fmartingr Aug 24, 2023
395cbfa
database replication workaround (#312)
fmartingr Aug 28, 2023
6b8defa
[GCAL/MSCAL] Catch errors when using `findmeetings` commands without …
fmartingr Aug 29, 2023
8a728fd
Replace Equals with ElementsMatch
fmartingr Aug 30, 2023
8925e67
makefile dist build for production (#314)
fmartingr Aug 31, 2023
dfeb3d1
[GCAL/MSCAL] Create event UX improvements (#309)
fmartingr Sep 4, 2023
c1bd4d3
[GCAL] Store conference data (#298)
fmartingr Sep 6, 2023
5ef3953
Common code refactor, back to mscalendar plugin (#334)
fmartingr Oct 4, 2023
8b4065b
apped user to handle after all actions (#335)
fmartingr Oct 10, 2023
3d0dc0b
refactor msgraph to mscalendar
fmartingr Nov 6, 2023
bf4cefe
Merge remote-tracking branch 'origin/master' into migrate-to-gcal
fmartingr Feb 15, 2024
783c2f0
goimports
fmartingr Feb 15, 2024
ae5f3d8
removed apply command from merge
fmartingr Feb 15, 2024
1de44d0
remove "REVIEW:" comments
mickmister Feb 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/api/post_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ func getEventInfo(ctx map[string]interface{}) (string, error) {
return views.RenderEventWillStartLine(subject, weblink, startTime), nil
}

// REVIEW: mscalendar http status logic
func isAcceptedError(err error) bool {
return strings.Contains(err.Error(), "202 Accepted")
}
Expand Down
2 changes: 2 additions & 0 deletions server/command/create_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/mattermost/mattermost-plugin-mscalendar/server/utils"
)

// REVIEW: No autocomplete for this command

func getCreateEventFlagSet() *flag.FlagSet {
flagSet := flag.NewFlagSet("create", flag.ContinueOnError)
flagSet.Bool("help", false, "show help")
Expand Down
1 change: 1 addition & 0 deletions server/command/find_meeting_times.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func (c *Command) findMeetings(parameters ...string) (string, bool, error) {
for a := range parameters {
s := strings.Split(parameters[a], ":")
t, email := s[0], s[1]
// REVIEW: very small struct being used to fetch meeting times. FindMeetingTimesParameters is a large struct, but only attendees being filled here
attendee := remote.Attendee{
Type: t,
EmailAddress: &remote.EmailAddress{
Expand Down
1 change: 1 addition & 0 deletions server/command/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/mattermost/mattermost-plugin-mscalendar/server/config"
)

// REVIEW: hardcoded "microsoft"
func (c *Command) info(parameters ...string) (string, bool, error) {
resp := fmt.Sprintf("Mattermost Microsoft Calendar plugin version: %s, "+
"[%s](https://github.com/mattermost/%s/commit/%s), built %s\n",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config: plugin name

Expand Down
2 changes: 2 additions & 0 deletions server/config/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

package config

// REVIEW: need an interface for returning bot info
// probably good to have a struct to capture the data clump
const (
BotUserName = "gcal"
BotDisplayName = "Google Calendar"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all fields being used here, that should be put into a struct

BotUserName    = "gcal"
BotDisplayName = "Google Calendar"
BotDescription = "Created by the Google Calendar Plugin."
ApplicationName    = "Google Calendar"
Repository         = "mattermost-plugin-gcal"
CommandTrigger     = "gcal"
TelemetryShortName = "gcal"

Expand Down
2 changes: 2 additions & 0 deletions server/jobs/renew_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func runRenewJob(env mscalendar.Env) {

for _, u := range uindex {
asUser := mscalendar.New(env, u.MattermostUserID)

// REVIEW: logging here is probably overkill
env.Logger.Debugf("Renewing for user: %s", u.MattermostUserID)
_, err = asUser.RenewMyEventSubscription()
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions server/jobs/status_sync_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ func runSyncJob(env mscalendar.Env) {
env.Logger.Errorf("Error during user status sync job. err=%v", err)
}

// REVIEW: This could be made easier to read
env.Logger.Debugf("User status sync job finished.\nSummary\nNumber of users processed:- %d\nNumber of users had their status changed:- %d\nNumber of users had errors:- %d", syncJobSummary.NumberOfUsersProcessed, syncJobSummary.NumberOfUsersStatusChanged, syncJobSummary.NumberOfUsersFailedStatusChanged)
}
9 changes: 6 additions & 3 deletions server/mscalendar/availability.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import (
)

const (
calendarViewTimeWindowSize = 10 * time.Minute
StatusSyncJobInterval = 5 * time.Minute
upcomingEventNotificationTime = 10 * time.Minute
calendarViewTimeWindowSize = 10 * time.Minute
StatusSyncJobInterval = 5 * time.Minute
upcomingEventNotificationTime = 10 * time.Minute

// REVIEW: This should be documented how this works. A dev has to read code to understand how the timing of these jobs and close proximity calendar events work
upcomingEventNotificationWindow = (StatusSyncJobInterval * 11) / 10 // 110% of the interval
logTruncateMsg = "We've truncated the logs due to too many messages"
logTruncateLimit = 5
Expand Down Expand Up @@ -401,6 +403,7 @@ func (m *mscalendar) GetCalendarViews(users []*store.User) ([]*remote.ViewCalend
})
}

// REVIEW: gcal batching requirement. maybe don't do batching, and instead use a channel to stream results back to here more concurrently
return m.client.DoBatchViewCalendarRequests(params)
}

Expand Down
3 changes: 3 additions & 0 deletions server/mscalendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func (m *mscalendar) CreateEvent(user *User, event *remote.Event, mattermostUser
return m.client.CreateEvent(user.Remote.ID, event)
}

// REVIEW: remove all delete calendar references. dead code/feature
func (m *mscalendar) DeleteCalendar(user *User, calendarID string) error {
err := m.Filter(
withClient,
Expand All @@ -105,6 +106,7 @@ func (m *mscalendar) FindMeetingTimes(user *User, meetingParams *remote.FindMeet
return nil, err
}

// REVIEW: need to figure out exact usages of this return value, and shorten the scope into a smaller struct, to make it so there are no missed expectations of present data between providers
return m.client.FindMeetingTimes(user.Remote.ID, meetingParams)
}

Expand All @@ -117,5 +119,6 @@ func (m *mscalendar) GetCalendars(user *User) ([]*remote.Calendar, error) {
return nil, err
}

// REVIEW: same here. need to figure out exact usages of this return value, and shorten the scope into a smaller struct, to make it so there are no missed expectations of present data between providers
return m.client.GetCalendars(user.Remote.ID)
}
1 change: 1 addition & 0 deletions server/mscalendar/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (m *mscalendar) MakeClient() (remote.Client, error) {
return m.Remote.MakeClient(context.Background(), m.actingUser.OAuth2Token), nil
}

// REVIEW: google service account? maybe not needed. this is only used for the status sync batch requests
func (m *mscalendar) MakeSuperuserClient() (remote.Client, error) {
return m.Remote.MakeSuperuserClient(context.Background())
}
2 changes: 2 additions & 0 deletions server/mscalendar/daily_summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ func (m *mscalendar) ProcessAllDailySummary(now time.Time) error {
}

m.Poster.DM(user.MattermostUserID, postStr)

// REVIEW: Seems kind of pointless to track a passive event like this
m.Dependencies.Tracker.TrackDailySummarySent(user.MattermostUserID)
dsum.LastPostTime = time.Now().Format(time.RFC3339)
err = m.Store.StoreUser(user)
Expand Down
1 change: 1 addition & 0 deletions server/mscalendar/event_responder.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type EventResponder interface {
RespondToEvent(user *User, eventID, response string) error
}

// REVIEW: See if this is still necessary. I believe RespondToEvent handles all cases already
func (m *mscalendar) AcceptEvent(user *User, eventID string) error {
err := m.Filter(
withClient,
Expand Down
4 changes: 4 additions & 0 deletions server/mscalendar/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func (processor *notificationProcessor) processNotification(n *remote.Notificati

client := processor.Remote.MakeClient(context.Background(), creator.OAuth2Token)

// REVIEW: depends on lifecycle of subscriptions. its always false for gcal. set to true in msgraph client here https://github.com/mattermost/mattermost-plugin-mscalendar/blob/9ed5ee6e2141e7e6f32a5a80d7a20ab0881c8586/server/remote/msgraph/handle_webhook.go#L77-L80
if n.RecommendRenew {
var renewed *remote.Subscription
renewed, err = client.RenewSubscription(processor.Config.GetNotificationURL(), sub.Remote.CreatorID, n.SubscriptionID)
Expand All @@ -164,6 +165,7 @@ func (processor *notificationProcessor) processNotification(n *remote.Notificati
}).Debugf("webhook notification: renewed user subscription.")
}

// REVIEW: this seems to be implemented for gcal's case already
if n.IsBare {
n, err = client.GetNotificationData(n)
if err != nil {
Expand Down Expand Up @@ -398,6 +400,7 @@ func eventToFields(e *remote.Event, timezone string) fields.Fields {
case e.IsAllDay:
dur = "all-day"

// REVIEW: would be good to extract some of this stuff out into separate functions. different file too
default:
switch hours {
case 0:
Expand Down Expand Up @@ -426,6 +429,7 @@ func eventToFields(e *remote.Event, timezone string) fields.Fields {
attendees = append(attendees, fields.NewStringValue("None"))
}

// REVIEW: some good stuff here. gotta make sure they are all filled in for gcal's case
ff := fields.Fields{
FieldSubject: fields.NewStringValue(views.EnsureSubject(e.Subject)),
FieldBodyPreview: fields.NewStringValue(valueOrNotDefined(e.BodyPreview)),
Expand Down
2 changes: 1 addition & 1 deletion server/mscalendar/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func (app *oauth2App) CompleteOAuth2(authedUserID, code, state string) error {
}

u.Settings.DailySummary = &store.DailySummaryUserSettings{
PostTime: "8:00AM",
PostTime: "8:00AM", // REVIEW: we shouls support military time for user inputs elsewhere
Timezone: mailboxSettings.TimeZone,
Enable: false,
}
Expand Down
1 change: 1 addition & 0 deletions server/mscalendar/settings_notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func (s *notificationSetting) Set(userID string, value interface{}) error {
cal := s.getCal(userID)

if boolValue {
// REVIEW: notification subscription logic in notification settings. this seems a bit weird. what does this function/block have to do specifically with notifications?
_, err := cal.LoadMyEventSubscription()
if err != nil {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really sure what's going on here. It seems like:

"If a setting is a boolean, create an event subscription if not exists"

This is notificationSetting.Set, so I suppose this is "I want to change notification settings". what do we do when the value hasn't changed? I suppose this function takes care of no-ops

_, err := cal.CreateMyEventSubscription()
Expand Down
1 change: 1 addition & 0 deletions server/mscalendar/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Subscriptions interface {
LoadMyEventSubscription() (*store.Subscription, error)
}

// REVIEW: depends on the overlap of subscription logic between providers, but lots of logic about supscription lifecycle in this file
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to figure out if google's subscription lifecycle lines up with this

func (m *mscalendar) CreateMyEventSubscription() (*store.Subscription, error) {
err := m.Filter(withClient)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions server/mscalendar/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func (m *mscalendar) ExpandMattermostUser(user *User) error {
return nil
}

// REVIEW: the timezone is the only thing used from the mailbox settings
func (m *mscalendar) GetTimezone(user *User) (string, error) {
err := m.Filter(
withClient,
Expand Down Expand Up @@ -131,11 +132,13 @@ func (m *mscalendar) DisconnectUser(mattermostUserID string) error {

eventSubscriptionID := storedUser.Settings.EventSubscriptionID
if eventSubscriptionID != "" {
// REVIEW: deleting local notification subscription during disconnect
err = m.Store.DeleteUserSubscription(storedUser, eventSubscriptionID)
if err != nil && err != store.ErrNotFound {
return errors.WithMessagef(err, "failed to delete subscription %s", eventSubscriptionID)
}

// REVIEW: deleting remote notification subscription during disconnect
err = m.client.DeleteSubscription(eventSubscriptionID)
if err != nil {
m.Logger.Warnf("failed to delete remote subscription %s. err=%v", eventSubscriptionID, err)
Expand Down
2 changes: 2 additions & 0 deletions server/mscalendar/welcomer.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type mscBot struct {
}

const (
// REVIEW: "microsoft" referenced here
WelcomeMessage = `Welcome to the Microsoft Calendar plugin.
[Click here to link your account.](%s/oauth2/connect)`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Microsoft Calendar plugin" mentioned here

)
Expand Down Expand Up @@ -122,6 +123,7 @@ func (bot *mscBot) newConnectAttachment() *model.SlackAttachment {

func (bot *mscBot) newConnectedAttachment(userLogin string) *model.SlackAttachment {
title := "Connect"
// REVIEW: "microsoft" referenced here
text := ":tada: Congratulations! Your microsoft account (*" + userLogin + "*) has been connected to Mattermost."
return &model.SlackAttachment{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Microsoft" mentioned here

Title: title,
Expand Down
1 change: 1 addition & 0 deletions server/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ func (p *Plugin) OnConfigurationChange() (err error) {
e.bot = e.bot.WithConfig(stored.Config)
e.Dependencies.Remote = remote.Makers[gcal.Kind](e.Config, e.bot)

// REVIEW: need to make this provider agnostic terminology
mscalendarBot := mscalendar.NewMSCalendarBot(e.bot, e.Env, pluginURL)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mscalendar terminology leaked into plugin package. not much else than what's visible here though


e.Dependencies.Logger = e.bot
Expand Down
1 change: 1 addition & 0 deletions server/remote/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

package remote

// REVIEW: we should vet exactly what fields are used from the remote package, and get rid of any "dead fields" from these structs
type Event struct {
Start *DateTime `json:"start,omitempty"`
Location *Location `json:"location,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions server/remote/gcal/get_default_calendar_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ type calendarViewBatchResponse struct {
}

func (c *client) DoBatchViewCalendarRequests(allParams []*remote.ViewCalendarParams) ([]*remote.ViewCalendarResponse, error) {
// REVIEW: we can just not use googles batch api if necessary
if true {
return nil, errors.New("gcal DoBatchViewCalendarRequests not implemented")
}
Expand Down
22 changes: 20 additions & 2 deletions server/remote/gcal/get_mailbox_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@
package gcal

import (
"context"

"github.com/pkg/errors"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"

"github.com/mattermost/mattermost-plugin-mscalendar/server/remote"
)

func (c *client) GetMailboxSettings(remoteUserID string) (*remote.MailboxSettings, error) {
// GCAL TODO
// REVIEW: Implemented timezone but need to verify if it is correct

service, err := calendar.NewService(context.Background(), option.WithHTTPClient(c.httpClient))
if err != nil {
return nil, errors.Wrap(err, "gcal GetMailboxSettings, error creating service")
}

setting, err := service.Settings.Get("timezone").Do()
if err != nil {
return nil, errors.Wrap(err, "gcal GetMailboxSettings, error getting timezone setting")
}

// probably should get rid of `MailBoxSettings` and just return the timezone as a string. or fill out the rest of the struct, which is not necessarily possible or useful. "WorkingHours" is the only other field in the struct
out := &remote.MailboxSettings{
TimeZone: "Eastern Standard Time",
TimeZone: setting.Value,
}
return out, nil
}
2 changes: 2 additions & 0 deletions server/store/setting_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ func (s *pluginStore) GetSetting(userID, settingID string) (interface{}, error)
func DefaultDailySummaryUserSettings() *DailySummaryUserSettings {
return &DailySummaryUserSettings{
PostTime: "8:00AM",

// REVIEW: hardcoding timezone seems bad. I think we replace it with the user's timezone right afterwards though
Timezone: "Eastern Standard Time",
Enable: false,
}
Expand Down
1 change: 1 addition & 0 deletions server/utils/oauth2connect/oauth2_complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (oa *oa) oauth2Complete(w http.ResponseWriter, r *http.Request) {
return
}

// REVIEW "microsoft" is hardcoded
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Microsoft Calendar" is hardcoded

html := `
<!DOCTYPE html>
<html>
Expand Down