Module: Tempest::REPL::Formatter

Defined in:
lib/tempest/repl/formatter.rb

Overview

Renders posts and Jetstream events as terminal lines, earthquake-style:

[$AA] [HH:MM] @handle: text

The leading [$AA] is only emitted when a Registry is supplied (and the event is something that can be replied to — a post, not a delete or a like/repost record). URLs found in the body are annotated inline with their own ($LA) ids when a registry is supplied. ANSI colors are emitted only when Formatter.color is true (set by the CLI when stdout is a TTY); tests run with color disabled.

Constant Summary collapse

RESET =
"\e[0m".freeze
CYAN =
"\e[36m".freeze
GREEN =
"\e[32m".freeze
DIM =
"\e[2m".freeze
HASHTAG_BLUE =
"\e[38;5;110m".freeze
HASHTAG_PATTERN =
/#[[:alnum:]_]+/.freeze
URL_PATTERN =
%r{https?://[^\s]+}.freeze
DECORATE_PATTERN =
Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
MEDIA_EMOJI =

Visible hint that a post carries an image / video embed. Only kinds that aren’t already surfaced by other UI (link cards become URLs, quote posts inline their record) get a marker.

{
  images: "📷",
  video:  "🎥",
}.freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.colorObject

Returns the value of attribute color.



37
38
39
# File 'lib/tempest/repl/formatter.rb', line 37

def color
  @color
end

Class Method Details

.annotate_urls(text, registry, facets: nil) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/tempest/repl/formatter.rb', line 151

def annotate_urls(text, registry, facets: nil)
  return text unless registry
  text = text.to_s
  if facets && !facets.empty?
    return annotate_urls_with_facets(text, registry, facets)
  end

  urls = URI.extract(text, ["http", "https"]).uniq
  urls.each do |url|
    var = registry.assign_url(url)
    text = text.sub(url, "#{url} (#{var})")
  end
  text
end

.annotate_urls_with_facets(text, registry, facets) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/tempest/repl/formatter.rb', line 166

def annotate_urls_with_facets(text, registry, facets)
  text = text.dup.force_encoding(Encoding::UTF_8)
  bytesize = text.bytesize
  valid = facets
    .select { |f| f.byte_start.is_a?(Integer) && f.byte_end.is_a?(Integer) }
    .select { |f| f.byte_start >= 0 && f.byte_end <= bytesize && f.byte_start < f.byte_end }
    .sort_by(&:byte_start)

  # Assign vars in reading order so earlier facets get earlier ids.
  replacements = valid.map do |facet|
    var = registry.assign_url(facet.uri)
    domain = host_of(facet.uri) || facet.uri
    [facet, "[#{domain} #{var}]"]
  end

  # Apply substitutions in reverse byte order so earlier ranges remain valid.
  replacements.reverse_each do |facet, replacement|
    head = text.byteslice(0, facet.byte_start) || ""
    tail = text.byteslice(facet.byte_end, text.bytesize - facet.byte_end) || ""
    text = (head + replacement + tail).force_encoding(Encoding::UTF_8)
  end
  text
end

.avatar_icon(did, avatar_store) ⇒ Object

Returns the Kitty graphics escape for this DID’s avatar, or nil when avatars are off, the store has nothing yet, or colors are disabled (test mode mirrors today’s color suppression so snapshots stay clean).



228
229
230
231
232
233
234
235
# File 'lib/tempest/repl/formatter.rb', line 228

def avatar_icon(did, avatar_store)
  return nil unless avatar_store
  return nil unless Formatter.color
  return nil if did.nil? || did.empty?
  path = avatar_store.path_for(did)
  return nil if path.nil?
  Kitty.inline(path)
end

.bracket(time) ⇒ Object



237
238
239
# File 'lib/tempest/repl/formatter.rb', line 237

def bracket(time)
  Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
end

.compose(var, time, handle, did, text, icon: nil) ⇒ Object



209
210
211
212
213
214
215
216
# File 'lib/tempest/repl/formatter.rb', line 209

def compose(var, time, handle, did, text, icon: nil)
  prefix = ""
  prefix += id_label(var) if var
  prefix += bracket(time) if time
  identity = handle ? handle_label(handle) : did_label(did)
  identity = "#{icon}  #{identity}" if icon
  "#{prefix}#{identity}: #{text}"
end

.decorate_body(text) ⇒ Object



54
55
56
57
58
59
60
61
62
# File 'lib/tempest/repl/formatter.rb', line 54

def decorate_body(text)
  return text unless Formatter.color
  return text if text.nil? || text.empty?

  text.gsub(DECORATE_PATTERN) do |match|
    color = match.start_with?("http") ? DIM : HASHTAG_BLUE
    "#{color}#{match}#{RESET}"
  end
end

.did_label(did) ⇒ Object



249
250
251
# File 'lib/tempest/repl/formatter.rb', line 249

def did_label(did)
  Formatter.color ? "#{DIM}<#{did}>#{RESET}" : "<#{did}>"
end

.embed_kind_of(record) ⇒ Object



140
141
142
# File 'lib/tempest/repl/formatter.rb', line 140

def embed_kind_of(record)
  record.respond_to?(:embed_kind) ? record.embed_kind : nil
end

.event_line(event, registry: nil, resolver: nil, avatar_store: nil) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/tempest/repl/formatter.rb', line 84

def event_line(event, registry: nil, resolver: nil, avatar_store: nil)
  handle = resolver&.resolve(event.did)
  if event.operation == :delete
    body = "(deleted #{event.collection}/#{event.rkey})"
    var = nil
  elsif event.respond_to?(:like?) && event.like?
    body = "liked #{subject_owner_label(event.subject_uri, resolver, registry)}"
    var = nil
  elsif event.respond_to?(:repost?) && event.repost?
    body = "reposted #{subject_owner_label(event.subject_uri, resolver, registry)}"
    var = nil
  else
    facets = event.respond_to?(:facets) ? event.facets : nil
    body = annotate_urls(squeeze(event.text), registry, facets: facets)
    body = decorate_body(body)
    body = prepend_media_marker(body, embed_kind_of(event))
    body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
    var = registry&.assign_post(event)
  end
  icon = avatar_icon(event.did, avatar_store)
  compose(var, format_time(event.created_at), handle, event.did, body, icon: icon)
end

.format_status_time(time) ⇒ Object



80
81
82
# File 'lib/tempest/repl/formatter.rb', line 80

def format_status_time(time)
  time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
end

.format_time(iso) ⇒ Object



202
203
204
205
206
207
# File 'lib/tempest/repl/formatter.rb', line 202

def format_time(iso)
  return nil if iso.nil? || iso.empty?
  Time.iso8601(iso).localtime.strftime("%H:%M")
rescue ArgumentError
  nil
end

.handle_label(handle) ⇒ Object



245
246
247
# File 'lib/tempest/repl/formatter.rb', line 245

def handle_label(handle)
  Formatter.color ? "#{GREEN}@#{handle}#{RESET}" : "@#{handle}"
end

.host_of(uri) ⇒ Object



190
191
192
193
194
195
196
# File 'lib/tempest/repl/formatter.rb', line 190

def host_of(uri)
  parsed = URI.parse(uri)
  host = parsed.host
  host && !host.empty? ? host : nil
rescue URI::InvalidURIError
  nil
end

.id_label(var) ⇒ Object



241
242
243
# File 'lib/tempest/repl/formatter.rb', line 241

def id_label(var)
  Formatter.color ? "#{DIM}[#{var}]#{RESET} " : "[#{var}] "
end

.post_did(post) ⇒ Object

DID for a Post is extracted from its at:// URI, since Post itself only carries handle. AvatarStore is keyed by DID, so we have to derive it.



220
221
222
223
# File 'lib/tempest/repl/formatter.rb', line 220

def post_did(post)
  return nil unless post.respond_to?(:uri)
  subject_did(post.uri)
end

.post_line(post, registry: nil, avatar_store: nil) ⇒ Object



43
44
45
46
47
48
49
50
51
52
# File 'lib/tempest/repl/formatter.rb', line 43

def post_line(post, registry: nil, avatar_store: nil)
  var = registry&.assign_post(post)
  facets = post.respond_to?(:facets) ? post.facets : nil
  body = annotate_urls(squeeze(post.text), registry, facets: facets)
  body = decorate_body(body)
  body = prepend_media_marker(body, embed_kind_of(post))
  body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
  icon = avatar_icon(post_did(post), avatar_store)
  compose(var, format_time(post.created_at), post.handle, nil, body, icon: icon)
end

.prepend_media_marker(body, embed_kind) ⇒ Object



144
145
146
147
148
149
# File 'lib/tempest/repl/formatter.rb', line 144

def prepend_media_marker(body, embed_kind)
  emoji = MEDIA_EMOJI[embed_kind]
  return body unless emoji
  body = body.to_s
  body.empty? ? emoji : "#{emoji} #{body}"
end

.prepend_reply_marker(body, reply_parent_uri, registry) ⇒ Object



131
132
133
134
135
136
137
138
# File 'lib/tempest/repl/formatter.rb', line 131

def prepend_reply_marker(body, reply_parent_uri, registry)
  return body if reply_parent_uri.nil? || reply_parent_uri.empty?
  return body unless registry

  parent_var = registry.var_for_uri(reply_parent_uri)
  marker = parent_var ? "#{parent_var} " : ""
  "#{marker}#{body}"
end

.reply_parent_uri_of(record) ⇒ Object



127
128
129
# File 'lib/tempest/repl/formatter.rb', line 127

def reply_parent_uri_of(record)
  record.respond_to?(:reply_parent_uri) ? record.reply_parent_uri : nil
end

.squeeze(text) ⇒ Object



198
199
200
# File 'lib/tempest/repl/formatter.rb', line 198

def squeeze(text)
  text.to_s.gsub(/\s*\n\s*/, " ")
end

.status_line(status) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/tempest/repl/formatter.rb', line 64

def status_line(status)
  body = case status.state
  when :disconnected
    status.reason == :error && status.error ? "disconnected: #{status.error.message}" : "disconnected"
  when :reconnecting
    "reconnecting..."
  when :live
    "live"
  when :gapped
    "fetching timeline (offline since #{format_status_time(status.since)})"
  else
    status.state.to_s
  end
  Formatter.color ? "#{DIM}-- #{body}#{RESET}" : "-- #{body}"
end

.subject_did(subject_uri) ⇒ Object



121
122
123
124
125
# File 'lib/tempest/repl/formatter.rb', line 121

def subject_did(subject_uri)
  return nil if subject_uri.nil? || subject_uri.empty?
  match = subject_uri.match(%r{\Aat://([^/]+)/})
  match && match[1]
end

.subject_owner_label(subject_uri, resolver, registry = nil) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/tempest/repl/formatter.rb', line 107

def subject_owner_label(subject_uri, resolver, registry = nil)
  did = subject_did(subject_uri)
  return "a post" unless did

  handle = resolver&.resolve(did)
  owner = handle ? handle_label(handle) : did_label(did)
  label = "#{owner}'s post"
  var = registry&.var_for_uri(subject_uri)
  return label unless var

  bracket = Formatter.color ? "#{DIM}[#{var}]#{RESET}" : "[#{var}]"
  "#{label} #{bracket}"
end