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

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.colorObject

Returns the value of attribute color.



29
30
31
# File 'lib/tempest/repl/formatter.rb', line 29

def color
  @color
end

Class Method Details

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



130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/tempest/repl/formatter.rb', line 130

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



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/tempest/repl/formatter.rb', line 145

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).



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

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



216
217
218
# File 'lib/tempest/repl/formatter.rb', line 216

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

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



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

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



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

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



228
229
230
# File 'lib/tempest/repl/formatter.rb', line 228

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

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



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/tempest/repl/formatter.rb', line 75

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_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



71
72
73
# File 'lib/tempest/repl/formatter.rb', line 71

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

.format_time(iso) ⇒ Object



181
182
183
184
185
186
# File 'lib/tempest/repl/formatter.rb', line 181

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



224
225
226
# File 'lib/tempest/repl/formatter.rb', line 224

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

.host_of(uri) ⇒ Object



169
170
171
172
173
174
175
# File 'lib/tempest/repl/formatter.rb', line 169

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



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

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.



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

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



35
36
37
38
39
40
41
42
43
# File 'lib/tempest/repl/formatter.rb', line 35

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_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_reply_marker(body, reply_parent_uri, registry) ⇒ Object



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

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



117
118
119
# File 'lib/tempest/repl/formatter.rb', line 117

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

.squeeze(text) ⇒ Object



177
178
179
# File 'lib/tempest/repl/formatter.rb', line 177

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

.status_line(status) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/tempest/repl/formatter.rb', line 55

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



111
112
113
114
115
# File 'lib/tempest/repl/formatter.rb', line 111

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



97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/tempest/repl/formatter.rb', line 97

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