Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org).

### Added

- Calendar events in the interactive TUI now open into a scrollable detail view
with description, organizer, attendees, location, and explicit meeting and
Outlook link availability. `j` opens the supported
`onlineMeeting.joinUrl`; `o` opens the event `webLink`.
- `gh msft mail view <message-id>` displays a message's metadata and safe
plain-text body, with `--json` for clean scriptable output.
- A cohesive, adaptive visual design system for the interactive TUI, including
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,19 @@ terminals use compact panels and two-line inbox rows to preserve message context
| `g` / `G` or home/end | jump to top / bottom of a list or message |
| `tab` | switch mail / calendar |
| `a` | archive selected message (mail only) |
| `enter` | open selected message; close an open message |
| `enter` | open selected mail or calendar event; close detail |
| `esc` / `?` | dismiss / toggle expanded help |
| `r` | toggle read state (visual only; mail only) |
| `R` | refresh current view or retry after an error |
| `q` | quit, or close an open message |

Open messages use `j` / `k`, arrow keys, `g`, and `G` to scroll their full
contents. Their footer lists the detail-specific bindings; list footers and
expanded help list only bindings that apply to the active mail or calendar mode.
contents. Open calendar events show their description, participants, location,
and available links. Press `j` to join through `onlineMeeting.joinUrl` (the
preferred meeting link) and `o` to open the Outlook event when its `webLink` is
available. Missing links and browser-launch failures remain visible in the event
detail view. Detail footers, list footers, and expanded help list only bindings
that apply to the active view.

## Why this over WorkIQ directly?

Expand Down
44 changes: 44 additions & 0 deletions internal/browser/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Package browser opens validated web URLs with the operating system's default browser.
package browser

import (
"fmt"
"net/url"
"os/exec"
"runtime"
)

// OpenURL opens rawURL in the default browser without invoking a shell.
func OpenURL(rawURL string) error {
return openURL(runtime.GOOS, rawURL, func(name string, args ...string) error {
return exec.Command(name, args...).Run()
})
}

func openURL(goos, rawURL string, run func(string, ...string) error) error {
name, args, err := commandForURL(goos, rawURL)
if err != nil {
return err
}
if err := run(name, args...); err != nil {
return fmt.Errorf("open browser: %w", err)
}
return nil
}

func commandForURL(goos, rawURL string) (string, []string, error) {
parsed, err := url.ParseRequestURI(rawURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "", nil, fmt.Errorf("invalid browser URL %q", rawURL)
}
switch goos {
case "darwin":
return "open", []string{rawURL}, nil
case "linux":
return "xdg-open", []string{rawURL}, nil
case "windows":
return "rundll32.exe", []string{"url.dll,FileProtocolHandler", rawURL}, nil
default:
return "", nil, fmt.Errorf("opening browser links is not supported on %s", goos)
}
}
72 changes: 72 additions & 0 deletions internal/browser/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package browser

import (
"errors"
"reflect"
"testing"
)

func TestCommandForURL(t *testing.T) {
tests := []struct {
name string
goos string
rawURL string
wantName string
wantArgs []string
wantErr bool
}{
{"macOS", "darwin", "https://teams.microsoft.com/l/meetup-join/abc", "open", []string{"https://teams.microsoft.com/l/meetup-join/abc"}, false},
{"Linux", "linux", "https://outlook.office.com/calendar/item", "xdg-open", []string{"https://outlook.office.com/calendar/item"}, false},
{"Windows", "windows", "https://outlook.office.com/calendar/item", "rundll32.exe", []string{"url.dll,FileProtocolHandler", "https://outlook.office.com/calendar/item"}, false},
{"rejects non-web URL", "darwin", "file:///tmp/event", "", nil, true},
{"rejects malformed URL", "darwin", "not a URL", "", nil, true},
{"rejects unsupported platform", "plan9", "https://example.com", "", nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotArgs, err := commandForURL(tt.goos, tt.rawURL)
if (err != nil) != tt.wantErr {
t.Fatalf("commandForURL() error = %v, wantErr %v", err, tt.wantErr)
}
if gotName != tt.wantName {
t.Errorf("commandForURL() name = %q, want %q", gotName, tt.wantName)
}
if len(gotArgs) != len(tt.wantArgs) {
t.Fatalf("commandForURL() args = %v, want %v", gotArgs, tt.wantArgs)
}
for i, arg := range gotArgs {
if arg != tt.wantArgs[i] {
t.Errorf("commandForURL() arg %d = %q, want %q", i, arg, tt.wantArgs[i])
}
}
})
}
}

func TestOpenURLPropagatesLauncherResult(t *testing.T) {
tests := []struct {
name string
runErr error
wantErr bool
}{
{"successful launcher", nil, false},
{"failed launcher", errors.New("exit status 1"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotName string
var gotArgs []string
err := openURL("linux", "https://outlook.office.com/calendar/item", func(name string, args ...string) error {
gotName = name
gotArgs = args
return tt.runErr
})
if (err != nil) != tt.wantErr {
t.Fatalf("openURL() error = %v, wantErr %v", err, tt.wantErr)
}
if gotName != "xdg-open" || !reflect.DeepEqual(gotArgs, []string{"https://outlook.office.com/calendar/item"}) {
t.Errorf("launcher = %q %v, want xdg-open [https://outlook.office.com/calendar/item]", gotName, gotArgs)
}
})
}
}
127 changes: 115 additions & 12 deletions internal/calendar/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"

"github.com/maxbeizer/gh-msft/internal/mstime"
"github.com/maxbeizer/gh-msft/internal/plaintext"
"github.com/maxbeizer/gh-msft/internal/workiq"
)

Expand All @@ -22,10 +25,35 @@ type Event struct {
Organizer string `json:"organizer"`
}

// Participant identifies an event organizer or attendee.
type Participant struct {
Name string `json:"name"`
Email string `json:"email"`
}

// Detail contains the full event data rendered by the interactive TUI.
type Detail struct {
ID string `json:"id"`
Subject string `json:"subject"`
Start mstime.Time `json:"start"`
End mstime.Time `json:"end"`
IsAllDay bool `json:"isAllDay"`
Organizer Participant `json:"organizer"`
Attendees []Participant `json:"attendees"`
Location string `json:"location"`
Body string `json:"body"`
BodyPreview string `json:"bodyPreview"`
WebLink string `json:"webLink"`
JoinURL string `json:"joinUrl"`
IsOnlineMeeting bool `json:"isOnlineMeeting"`
}

// Provider reads calendar data.
type Provider interface {
// Upcoming returns up to top events starting from now, ordered by start time.
Upcoming(ctx context.Context, top int) ([]Event, error)
// GetDetail returns the full event data by id.
GetDetail(ctx context.Context, id string) (Detail, error)
}

// graphClient is the subset of the WorkIQ client this package needs.
Expand Down Expand Up @@ -61,6 +89,25 @@ type graphEvent struct {
Address string `json:"address"`
} `json:"emailAddress"`
} `json:"organizer"`
Attendees []struct {
EmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"emailAddress"`
} `json:"attendees"`
Body struct {
ContentType string `json:"contentType"`
Content string `json:"content"`
} `json:"body"`
BodyPreview string `json:"bodyPreview"`
Location struct {
DisplayName string `json:"displayName"`
} `json:"location"`
WebLink string `json:"webLink"`
IsOnlineMeeting bool `json:"isOnlineMeeting"`
OnlineMeeting struct {
JoinURL string `json:"joinUrl"`
} `json:"onlineMeeting"`
}

type graphEventCollection struct {
Expand Down Expand Up @@ -100,6 +147,29 @@ func (p *WorkIQProvider) upcomingViaEvents(ctx context.Context, top int) ([]Even
return parseEvents(results)
}

// GetDetail retrieves one event with its body, attendees, and browser links.
func (p *WorkIQProvider) GetDetail(ctx context.Context, id string) (Detail, error) {
if id == "" {
return Detail{}, fmt.Errorf("calendar: GetDetail requires an event id")
}
url := fmt.Sprintf(
"/me/events/%s?$select=subject,body,bodyPreview,organizer,attendees,start,end,location,webLink,onlineMeeting,isOnlineMeeting",
url.PathEscape(id),
)
results, err := p.c.Fetch(ctx, url)
if err != nil {
return Detail{}, err
}
if len(results) == 0 {
return Detail{}, fmt.Errorf("calendar: event %q was not found", id)
}
var event graphEvent
if err := json.Unmarshal(results[0].Data, &event); err != nil {
return Detail{}, fmt.Errorf("calendar: decode event: %w", err)
}
return detailFromGraph(event), nil
}

func parseEvents(results []workiq.FetchResult) ([]Event, error) {
if len(results) == 0 {
return nil, nil
Expand All @@ -110,18 +180,51 @@ func parseEvents(results []workiq.FetchResult) ([]Event, error) {
}
events := make([]Event, 0, len(coll.Value))
for _, ge := range coll.Value {
organizer := ge.Organizer.EmailAddress.Name
if organizer == "" {
organizer = ge.Organizer.EmailAddress.Address
}
events = append(events, Event{
ID: ge.ID,
Subject: ge.Subject,
Start: mstime.Parse(ge.Start.DateTime),
End: mstime.Parse(ge.End.DateTime),
IsAllDay: ge.IsAllDay,
Organizer: organizer,
})
events = append(events, eventFromGraph(ge))
}
return events, nil
}

func eventFromGraph(event graphEvent) Event {
organizer := event.Organizer.EmailAddress.Name
if organizer == "" {
organizer = event.Organizer.EmailAddress.Address
}
return Event{
ID: event.ID,
Subject: event.Subject,
Start: mstime.Parse(event.Start.DateTime),
End: mstime.Parse(event.End.DateTime),
IsAllDay: event.IsAllDay,
Organizer: organizer,
}
}

func detailFromGraph(event graphEvent) Detail {
attendees := make([]Participant, 0, len(event.Attendees))
for _, attendee := range event.Attendees {
attendees = append(attendees, Participant{
Name: attendee.EmailAddress.Name,
Email: attendee.EmailAddress.Address,
})
}
body := event.Body.Content
if strings.EqualFold(event.Body.ContentType, "html") {
body = plaintext.HTMLToText(body)
}
return Detail{
ID: event.ID,
Subject: event.Subject,
Start: mstime.Parse(event.Start.DateTime),
End: mstime.Parse(event.End.DateTime),
IsAllDay: event.IsAllDay,
Organizer: Participant{Name: event.Organizer.EmailAddress.Name, Email: event.Organizer.EmailAddress.Address},
Attendees: attendees,
Location: event.Location.DisplayName,
Body: body,
BodyPreview: event.BodyPreview,
WebLink: event.WebLink,
JoinURL: event.OnlineMeeting.JoinURL,
IsOnlineMeeting: event.IsOnlineMeeting,
}
}
49 changes: 49 additions & 0 deletions internal/calendar/calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,55 @@ func TestUpcomingEmpty(t *testing.T) {
}
}

func TestGetDetailParsesEventAndLinks(t *testing.T) {
fg := &fakeGraph{byURL: map[string]string{"/me/events/E1": `{
"id":"E1",
"subject":"Planning",
"start":{"dateTime":"2026-07-20T16:30:00.0000000","timeZone":"UTC"},
"end":{"dateTime":"2026-07-20T17:00:00.0000000","timeZone":"UTC"},
"organizer":{"emailAddress":{"name":"Ada Lovelace","address":"ada@example.com"}},
"attendees":[{"emailAddress":{"name":"Grace Hopper","address":"grace@example.com"}}],
"location":{"displayName":"Conference room"},
"body":{"contentType":"HTML","content":"<p>Discuss <strong>roadmap</strong></p>"},
"bodyPreview":"Discuss roadmap",
"webLink":"https://outlook.office.com/calendar/item",
"onlineMeeting":{"joinUrl":"https://teams.microsoft.com/l/meetup-join/abc"},
"isOnlineMeeting":true
}`}}
p := NewWorkIQProvider(fg)

got, err := p.GetDetail(context.Background(), "E1")
if err != nil {
t.Fatalf("GetDetail: %v", err)
}
if got.Subject != "Planning" || got.Location != "Conference room" || got.Body != "Discuss roadmap" {
t.Errorf("detail = %+v", got)
}
if got.Organizer.Email != "ada@example.com" || len(got.Attendees) != 1 || got.Attendees[0].Email != "grace@example.com" {
t.Errorf("participants = organizer:%+v attendees:%+v", got.Organizer, got.Attendees)
}
if got.JoinURL != "https://teams.microsoft.com/l/meetup-join/abc" || got.WebLink == "" || !got.IsOnlineMeeting {
t.Errorf("links = %+v", got)
}
if len(fg.calls) != 1 {
t.Fatalf("fetch calls = %v, want one", fg.calls)
}
wantSelect := "$select=subject,body,bodyPreview,organizer,attendees,start,end,location,webLink,onlineMeeting,isOnlineMeeting"
if !strings.Contains(fg.calls[0], wantSelect) {
t.Errorf("fetch URL %q missing detail fields", fg.calls[0])
}
if strings.Contains(fg.calls[0], "onlineMeetingUrl") {
t.Errorf("fetch URL %q must not use deprecated onlineMeetingUrl", fg.calls[0])
}
}

func TestGetDetailRequiresID(t *testing.T) {
p := NewWorkIQProvider(&fakeGraph{})
if _, err := p.GetDetail(context.Background(), ""); err == nil {
t.Fatal("expected an error for an empty event id")
}
}

// errThenOK errors on the calendarView call and succeeds on /me/events.
type errThenOK struct {
errURLSub string
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ func (f *fakeMail) Body(ctx context.Context, id string) (string, error) {
type fakeCal struct {
events []calendar.Event
err error
detail calendar.Detail
}

func (f *fakeCal) Upcoming(ctx context.Context, top int) ([]calendar.Event, error) {
return f.events, f.err
}

func (f *fakeCal) GetDetail(ctx context.Context, id string) (calendar.Detail, error) {
return f.detail, f.err
}

type fakeEULA struct {
called bool
err error
Expand Down
Loading
Loading