teems 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. metadata +110 -0
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Formats chat messages as a Markdown document for local storage.
6
+ # Groups messages by date with human-readable formatting.
7
+ class MarkdownFormatter
8
+ REACTION_EMOJI = {
9
+ 'like' => "\u{1F44D}", 'heart' => "\u{2764}\u{FE0F}",
10
+ 'laugh' => "\u{1F602}", 'surprised' => "\u{1F62E}",
11
+ 'sad' => "\u{1F622}", 'angry' => "\u{1F620}",
12
+ 'yes-tone1' => "\u{1F44D}\u{1F3FB}", 'yes-tone2' => "\u{1F44D}\u{1F3FC}",
13
+ 'support' => "\u{1F91D}", 'heartblue' => "\u{1F499}",
14
+ 'computer' => "\u{1F4BB}", '1f37f_popcorn' => "\u{1F37F}",
15
+ '1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
16
+ }.freeze
17
+
18
+ def initialize(chat_name:, chat_type: nil, synced_at: nil)
19
+ @chat_name = chat_name
20
+ @chat_type = chat_type
21
+ @synced_at = synced_at
22
+ end
23
+
24
+ # Format an array of Message objects into a Markdown string.
25
+ # Messages should be in chronological order (oldest first).
26
+ def format(messages)
27
+ lines = [build_header, '']
28
+ return (lines << '_No messages_').join("\n") if messages.empty?
29
+
30
+ format_message_groups(messages, lines)
31
+ lines.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def format_message_groups(messages, lines)
37
+ current_date = nil
38
+ messages.reject(&:system_message?).each do |msg|
39
+ current_date = append_date_header(lines, msg, current_date)
40
+ lines.concat(format_message(msg))
41
+ lines << ''
42
+ end
43
+ end
44
+
45
+ def append_date_header(lines, msg, current_date)
46
+ msg_date = msg.created_at&.strftime('%Y-%m-%d')
47
+ return current_date if msg_date == current_date
48
+
49
+ lines << '' if current_date
50
+ lines.push("## #{msg_date || 'Unknown Date'}", '')
51
+ msg_date
52
+ end
53
+
54
+ def build_header
55
+ parts = ["# #{@chat_name}"]
56
+ parts << "**Type:** #{@chat_type}" if @chat_type
57
+ parts << "_Synced: #{@synced_at.strftime('%Y-%m-%d %H:%M')}_" if @synced_at
58
+ parts.join("\n\n")
59
+ end
60
+
61
+ def format_message(msg)
62
+ [*(msg.reply? ? ['> _Reply to message_'] : []),
63
+ format_message_header(msg), '',
64
+ *message_body_lines(msg)]
65
+ end
66
+
67
+ def message_body_lines(msg)
68
+ content = msg.content
69
+ result = content.to_s.empty? ? [] : [content]
70
+ result.concat(format_message_attachments(msg))
71
+ result.concat(format_message_reactions(msg))
72
+ end
73
+
74
+ def format_message_header(msg)
75
+ prefix = msg.important? ? '**[!]** ' : ''
76
+ edited = msg.edited? ? ' _(edited)_' : ''
77
+ "### #{msg.created_at&.strftime('%H:%M') || '??:??'} — #{prefix}#{msg.sender_name || 'Unknown'}#{edited}"
78
+ end
79
+
80
+ def format_message_attachments(msg)
81
+ Array(msg.attachments).map { |att| format_attachment(att) }
82
+ end
83
+
84
+ def format_attachment(att)
85
+ return "\u{1F4CE} #{att}" unless att.is_a?(Hash)
86
+
87
+ format_file_attachment(att)
88
+ end
89
+
90
+ def format_file_attachment(att)
91
+ name = att['fileName'] || att['name'] || 'file'
92
+ url = att['siteUrl']
93
+ url&.start_with?('https://') ? "\u{1F4CE} [#{name}](#{url})" : "\u{1F4CE} #{name}"
94
+ end
95
+
96
+ def format_message_reactions(msg)
97
+ reactions = msg.reactions
98
+ return [] unless displayable_reactions?(reactions)
99
+
100
+ [format_reactions_line(reactions)]
101
+ end
102
+
103
+ def displayable_reactions?(reactions) = reactions.is_a?(Array) && reactions.any?
104
+
105
+ def format_reactions_line(reactions)
106
+ parts = reactions.map { |reaction| FormatUtils.format_single_reaction(reaction, REACTION_EMOJI) }
107
+ "#{reactions_label} #{parts.join(' ')}"
108
+ end
109
+
110
+ def reactions_label = 'Reactions:'
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # Formats messages for terminal display
6
+ class MessageFormatter
7
+ REACTION_EMOJI = {
8
+ 'like' => "\u{1F44D}", 'heart' => "\u{2764}\u{FE0F}",
9
+ 'laugh' => "\u{1F602}", 'surprised' => "\u{1F62E}",
10
+ 'sad' => "\u{1F622}", 'angry' => "\u{1F620}",
11
+ 'yes-tone1' => "\u{1F44D}\u{1F3FB}", 'yes-tone2' => "\u{1F44D}\u{1F3FC}",
12
+ 'support' => "\u{1F91D}", 'heartblue' => "\u{1F499}",
13
+ 'computer' => "\u{1F4BB}", '1f37f_popcorn' => "\u{1F37F}",
14
+ '1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
15
+ }.freeze
16
+
17
+ def initialize(output:, cache_store: nil)
18
+ @output = output
19
+ @cache_store = cache_store
20
+ end
21
+
22
+ def format(message)
23
+ content = highlight_mentions(message.content, message.mentions)
24
+ [format_header(message), " #{content}", format_attachments(message), format_reactions(message)]
25
+ .compact.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def format_header(message)
31
+ importance = message.important? ? @output.red('!') : ''
32
+ hash = @output.gray(message.short_hash)
33
+ "#{format_timestamp(message)} #{hash} #{importance}#{@output.bold(message.sender_name)}:#{edited_tag(message)}"
34
+ end
35
+
36
+ def format_timestamp(message) = @output.blue("[#{message.created_at&.strftime('%Y-%m-%d %H:%M')}]")
37
+
38
+ def edited_tag(message) = message.edited? ? " #{@output.gray('(edited)')}" : ''
39
+
40
+ def format_attachments(message)
41
+ attachments = message.attachments
42
+ return unless attachments.any?
43
+
44
+ " #{@output.gray("\u{1F4CE} #{attachment_names(attachments)}")}"
45
+ end
46
+
47
+ def format_reactions(message)
48
+ reactions = message.reactions
49
+ return unless reactions.any?
50
+
51
+ " #{@output.gray(reaction_parts(reactions))}"
52
+ end
53
+
54
+ def highlight_mentions(content, mentions)
55
+ mentions.inject(content) { |text, name| text.gsub(name, @output.bold(name)) }
56
+ end
57
+
58
+ def attachment_names(attachments)
59
+ attachments.map { |att| FormatUtils.attachment_name(att) }.join(', ')
60
+ end
61
+
62
+ def reaction_parts(reactions)
63
+ reactions.map { |reaction| FormatUtils.format_single_reaction(reaction, REACTION_EMOJI) }.join(' ')
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Formatters
5
+ # ANSI color wrapping helpers for Output
6
+ module OutputColors
7
+ def red(text) = wrap(:red, text)
8
+ def green(text) = wrap(:green, text)
9
+ def yellow(text) = wrap(:yellow, text)
10
+ def blue(text) = wrap(:blue, text)
11
+ def magenta(text) = wrap(:magenta, text)
12
+ def cyan(text) = wrap(:cyan, text)
13
+ def gray(text) = wrap(:gray, text)
14
+ def bold(text) = wrap(:bold, text)
15
+
16
+ def with_verbose(mode = :verbose)
17
+ self.class.new(io: @io, err: @err, color: @color, mode: mode)
18
+ end
19
+
20
+ def with_quiet(mode = :quiet)
21
+ self.class.new(io: @io, err: @err, color: @color, mode: mode)
22
+ end
23
+ end
24
+
25
+ # Terminal output with ANSI color support
26
+ class Output
27
+ include OutputColors
28
+
29
+ COLORS = {
30
+ red: "\e[0;31m",
31
+ green: "\e[0;32m",
32
+ yellow: "\e[0;33m",
33
+ blue: "\e[0;34m",
34
+ magenta: "\e[0;35m",
35
+ cyan: "\e[0;36m",
36
+ gray: "\e[0;90m",
37
+ bold: "\e[1m",
38
+ reset: "\e[0m"
39
+ }.freeze
40
+
41
+ attr_reader :mode
42
+
43
+ def initialize(io: $stdout, err: $stderr, color: nil, mode: :normal)
44
+ @io = io
45
+ @err = err
46
+ @color = [true, false].include?(color) ? color : io.tty?
47
+ @mode = mode
48
+ end
49
+
50
+ def verbose? = @mode == :verbose
51
+ def quiet? = @mode == :quiet
52
+ def tty? = @io.tty?
53
+
54
+ def puts(message = '')
55
+ write_unless_quiet { @io.puts(message) }
56
+ end
57
+
58
+ def print(message)
59
+ write_unless_quiet { @io.print(message) }
60
+ end
61
+
62
+ def flush
63
+ @io.flush
64
+ end
65
+
66
+ def error(message)
67
+ @err.puts(colorize("#{red('Error:')} #{message}"))
68
+ end
69
+
70
+ def warn(message)
71
+ write_unless_quiet { @err.puts(colorize("#{yellow('Warning:')} #{message}")) }
72
+ end
73
+
74
+ def success(message)
75
+ puts(colorize("#{green('✓')} #{message}"))
76
+ end
77
+
78
+ def info(message)
79
+ puts(colorize(message))
80
+ end
81
+
82
+ def debug(message)
83
+ return unless verbose?
84
+
85
+ @err.puts(colorize("#{gray('[debug]')} #{message}"))
86
+ end
87
+
88
+ private
89
+
90
+ def write_unless_quiet
91
+ yield unless quiet?
92
+ end
93
+
94
+ def wrap(color, text)
95
+ return text.to_s unless @color
96
+
97
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
98
+ end
99
+
100
+ def colorize(text)
101
+ text
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Represents a Teams account with authentication tokens
6
+ Account = Data.define(:name, :auth_token, :skype_token, :chatsvc_token, :presence_token) do
7
+ def initialize(name:, auth_token:, skype_token:, chatsvc_token: nil, presence_token: nil)
8
+ validate_tokens!(auth_token, skype_token)
9
+ super(
10
+ name: name.to_s.freeze,
11
+ auth_token: auth_token.to_s.freeze,
12
+ skype_token: skype_token.to_s.freeze,
13
+ chatsvc_token: chatsvc_token&.to_s&.freeze,
14
+ presence_token: presence_token&.to_s&.freeze
15
+ )
16
+ end
17
+
18
+ # Authorization header for Teams API endpoints
19
+ def teams_auth_header
20
+ "Bearer #{auth_token}"
21
+ end
22
+
23
+ # Authorization header for Skype/chat API endpoints
24
+ def skype_auth_header
25
+ "skypetoken=#{skype_token}"
26
+ end
27
+
28
+ # Authorization header for chatsvcagg endpoints
29
+ def chatsvc_auth_header
30
+ return nil unless chatsvc_token
31
+
32
+ "Bearer #{chatsvc_token}"
33
+ end
34
+
35
+ # Headers for Teams API requests
36
+ def teams_headers
37
+ {
38
+ 'Authorization' => teams_auth_header,
39
+ 'Content-Type' => 'application/json'
40
+ }
41
+ end
42
+
43
+ # Headers for Skype API requests
44
+ def skype_headers
45
+ {
46
+ 'Authentication' => skype_auth_header,
47
+ 'Content-Type' => 'application/json'
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def validate_tokens!(auth_token, skype_token)
54
+ raise ArgumentError, 'auth_token is required' if auth_token.to_s.empty?
55
+ raise ArgumentError, 'skype_token is required' if skype_token.to_s.empty?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Represents a Teams channel within a team
6
+ Channel = Data.define(:id, :name, :team_id, :team_name, :description, :membership_type) do
7
+ def self.from_api(data, team_id: nil, team_name: nil)
8
+ new(
9
+ id: data['id'],
10
+ name: data['displayName'],
11
+ team_id: team_id,
12
+ team_name: team_name,
13
+ description: data['description'],
14
+ membership_type: data['membershipType']
15
+ )
16
+ end
17
+
18
+ def display_name
19
+ team_name ? "#{team_name} / #{name}" : name
20
+ end
21
+
22
+ def private?
23
+ membership_type == 'private'
24
+ end
25
+
26
+ def to_s
27
+ "##{name}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ CHAT_TYPE_LABELS = {
6
+ 'oneOnOne' => '1:1 Chat', 'group' => 'Group Chat', 'meeting' => 'Meeting Chat',
7
+ 'channel' => 'Channel', 'space' => 'Space'
8
+ }.freeze
9
+
10
+ # Represents a chat (1:1, group, or meeting chat)
11
+ Chat = Data.define(:id, :topic, :chat_type, :created_at, :last_updated, :unread, :favorite, :pinned) do
12
+ def self.from_api(data)
13
+ # Handle both Graph API format and ng.msg format
14
+ if data['threadProperties']
15
+ from_ngmsg(data)
16
+ else
17
+ from_graph(data)
18
+ end
19
+ end
20
+
21
+ def self.from_graph(data)
22
+ new(
23
+ id: data['id'],
24
+ topic: data['topic'],
25
+ chat_type: data['chatType'],
26
+ created_at: parse_time(data['createdDateTime']),
27
+ last_updated: parse_time(data['lastUpdatedDateTime']),
28
+ unread: false, favorite: false, pinned: false
29
+ )
30
+ end
31
+
32
+ def self.from_ngmsg(data)
33
+ thread_props = data['threadProperties'] || {}
34
+ props = data['properties'] || {}
35
+ new(
36
+ id: data['id'],
37
+ topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
38
+ chat_type: normalize_chat_type(thread_props['threadType']),
39
+ created_at: parse_time(thread_props['createdat']),
40
+ last_updated: parse_time(props['lastimreceivedtime']),
41
+ **ngmsg_status(props, data['lastMessage'])
42
+ )
43
+ end
44
+
45
+ def self.ngmsg_status(props, last_message)
46
+ horizon = props['consumptionhorizon']
47
+ last_msg_id = last_message&.dig('id')
48
+ {
49
+ unread: horizon && last_msg_id ? horizon.split(';').first.to_i < last_msg_id.to_i : false,
50
+ favorite: props['favorite'] == 'true',
51
+ pinned: props['ispinned'] == 'true'
52
+ }
53
+ end
54
+
55
+ # Normalize ng.msg threadType to a consistent chat type.
56
+ # ng.msg uses: 'chat' (group), 'meeting', 'topic' (channel), 'space'
57
+ # Normalized to: 'group', 'meeting', 'channel', 'space'
58
+ def self.normalize_chat_type(type)
59
+ case type&.downcase
60
+ when 'chat' then 'group'
61
+ when 'meeting' then 'meeting'
62
+ when 'topic' then 'channel'
63
+ when 'space' then 'space'
64
+ else type
65
+ end
66
+ end
67
+
68
+ def self.parse_time(time_str)
69
+ return nil unless time_str
70
+
71
+ Time.parse(time_str)
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ def display_name
77
+ topic.to_s.empty? ? chat_type_label : topic
78
+ end
79
+
80
+ def chat_type_label = CHAT_TYPE_LABELS.fetch(chat_type, chat_type)
81
+
82
+ def unread? = unread
83
+ def favorite? = favorite
84
+ def pinned? = pinned
85
+
86
+ def one_on_one?
87
+ chat_type == 'oneOnOne'
88
+ end
89
+
90
+ def group?
91
+ chat_type == 'group'
92
+ end
93
+
94
+ def meeting?
95
+ chat_type == 'meeting'
96
+ end
97
+
98
+ def channel?
99
+ chat_type == 'channel'
100
+ end
101
+
102
+ def space?
103
+ chat_type == 'space'
104
+ end
105
+
106
+ def to_s
107
+ display_name
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ DURATION_PATTERN = /\A(?:(\d+)h)?(?:(\d+)m)?\z/
6
+
7
+ # Immutable duration value object for human-friendly time input
8
+ Duration = Data.define(:seconds) do
9
+ def self.parse(input)
10
+ label = input.inspect
11
+ match = DURATION_PATTERN.match(input.to_s.strip)
12
+ raise ArgumentError, "Invalid duration: #{label}" unless match
13
+
14
+ total = (match[1].to_i * 3600) + (match[2].to_i * 60)
15
+ raise ArgumentError, "Duration must be greater than zero: #{label}" if total.zero?
16
+
17
+ new(seconds: total)
18
+ end
19
+
20
+ def to_iso8601_duration
21
+ parts = []
22
+ parts << "#{hours}H" if hours.positive?
23
+ parts << "#{minutes}M" if minutes.positive?
24
+ "PT#{parts.join}"
25
+ end
26
+
27
+ def to_expiration
28
+ (Time.now.utc + seconds).strftime('%Y-%m-%dT%H:%M:%S.0000000Z')
29
+ end
30
+
31
+ def to_s
32
+ parts = []
33
+ parts << "#{hours}h" if hours.positive?
34
+ parts << "#{minutes}m" if minutes.positive?
35
+ parts.join(' ')
36
+ end
37
+
38
+ def empty? = seconds.zero?
39
+
40
+ private
41
+
42
+ def hours = seconds / 3600
43
+ def minutes = (seconds % 3600) / 60
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ PENDING_RESPONSES = [nil, 'none'].freeze
6
+
7
+ # Represents a calendar event from Microsoft Graph API
8
+ Event = Data.define(
9
+ :id,
10
+ :subject,
11
+ :start_time,
12
+ :end_time,
13
+ :location,
14
+ :is_all_day,
15
+ :organizer,
16
+ :attendees,
17
+ :body_preview,
18
+ :online_meeting_url,
19
+ :show_as,
20
+ :importance,
21
+ :is_cancelled,
22
+ :response_status,
23
+ :sensitivity,
24
+ :event_type
25
+ ) do
26
+ extend Parsing
27
+
28
+ def self.from_api(data)
29
+ new(**event_attrs(data))
30
+ end
31
+
32
+ def self.event_attrs(data)
33
+ core_attrs(data).merge(detail_attrs(data))
34
+ end
35
+
36
+ def self.core_attrs(data)
37
+ {
38
+ id: data['id'],
39
+ subject: data['subject'] || '(No subject)',
40
+ start_time: parse_time(data.dig('start', 'dateTime')),
41
+ end_time: parse_time(data.dig('end', 'dateTime')),
42
+ location: data.dig('location', 'displayName'),
43
+ is_all_day: data['isAllDay'] || false,
44
+ organizer: parse_organizer(data['organizer']),
45
+ attendees: parse_attendees(data['attendees'])
46
+ }
47
+ end
48
+
49
+ def self.detail_attrs(data)
50
+ {
51
+ body_preview: data['bodyPreview'] || strip_html(data.dig('body', 'content')),
52
+ online_meeting_url: data.dig('onlineMeeting', 'joinUrl'),
53
+ show_as: data['showAs'],
54
+ importance: data['importance'],
55
+ is_cancelled: data['isCancelled'] || false,
56
+ response_status: data.dig('responseStatus', 'response'),
57
+ sensitivity: data['sensitivity'],
58
+ event_type: data['type']
59
+ }
60
+ end
61
+
62
+ def self.parse_organizer(organizer_data)
63
+ return nil unless organizer_data
64
+
65
+ email_data = organizer_data['emailAddress'] || {}
66
+ { name: email_data['name'], email: email_data['address'] }
67
+ end
68
+
69
+ def self.parse_attendees(attendees_data)
70
+ return [] unless attendees_data.is_a?(Array)
71
+
72
+ attendees_data.map do |att_data|
73
+ email_data = att_data['emailAddress'] || {}
74
+ {
75
+ name: email_data['name'],
76
+ email: email_data['address'],
77
+ type: att_data['type'],
78
+ response: att_data.dig('status', 'response')
79
+ }
80
+ end
81
+ end
82
+
83
+ def short_hash = Digest::SHA256.hexdigest(id.to_s)[0, 6]
84
+
85
+ def to_json_hash
86
+ to_h.merge(start_time: start_time&.iso8601, end_time: end_time&.iso8601)
87
+ end
88
+
89
+ def all_day? = is_all_day
90
+ def cancelled? = is_cancelled
91
+ def recurring? = %w[occurrence exception].include?(event_type)
92
+
93
+ def time_range_display
94
+ return 'ALL DAY' if all_day?
95
+ return '' unless start_time && end_time
96
+
97
+ "#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
98
+ end
99
+
100
+ def date_display
101
+ if all_day?
102
+ "#{start_time&.strftime('%Y-%m-%d')} (all day)"
103
+ elsif start_time && end_time
104
+ "#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
105
+ end
106
+ end
107
+
108
+ def create_summary_lines
109
+ lines = []
110
+ lines << date_display if date_display
111
+ lines << "Location: #{location}" if location && !location.empty?
112
+ lines << "Teams link: #{online_meeting_url}" if online_meeting_url
113
+ lines
114
+ end
115
+
116
+ def required_attendees = attendees.select { |att| att[:type] == 'required' }
117
+ def optional_attendees = attendees.select { |att| att[:type] == 'optional' }
118
+ def accepted_attendees = attendees.select { |att| att[:response] == 'accepted' }
119
+ def declined_attendees = attendees.select { |att| att[:response] == 'declined' }
120
+ def tentative_attendees = attendees.select { |att| att[:response] == 'tentativelyAccepted' }
121
+ def pending_attendees = attendees.select { |att| PENDING_RESPONSES.include?(att[:response]) }
122
+ end
123
+ end
124
+ end