tempest-rb 0.0.1
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/exe/tempest +4 -0
- data/lib/tempest/cli.rb +305 -0
- data/lib/tempest/config.rb +30 -0
- data/lib/tempest/cursor_store.rb +52 -0
- data/lib/tempest/debug_log.rb +59 -0
- data/lib/tempest/facet.rb +40 -0
- data/lib/tempest/follows.rb +36 -0
- data/lib/tempest/handle_resolver.rb +42 -0
- data/lib/tempest/http.rb +88 -0
- data/lib/tempest/id_var.rb +44 -0
- data/lib/tempest/jetstream/client.rb +69 -0
- data/lib/tempest/jetstream/decoder.rb +81 -0
- data/lib/tempest/jetstream/stream_manager.rb +262 -0
- data/lib/tempest/jetstream/subscription.rb +34 -0
- data/lib/tempest/jetstream/watchdog.rb +92 -0
- data/lib/tempest/post.rb +56 -0
- data/lib/tempest/repl/async_output.rb +62 -0
- data/lib/tempest/repl/dispatcher.rb +36 -0
- data/lib/tempest/repl/formatter.rb +205 -0
- data/lib/tempest/repl/registry.rb +57 -0
- data/lib/tempest/repl/runner.rb +218 -0
- data/lib/tempest/repl/screen.rb +119 -0
- data/lib/tempest/session.rb +91 -0
- data/lib/tempest/session_store.rb +66 -0
- data/lib/tempest/timeline.rb +15 -0
- data/lib/tempest/timeline_store.rb +108 -0
- data/lib/tempest/version.rb +3 -0
- data/lib/tempest/xrpc_client.rb +53 -0
- data/lib/tempest.rb +24 -0
- metadata +142 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require_relative "../../tempest"
|
|
2
|
+
require_relative "../debug_log"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
module Jetstream
|
|
6
|
+
# Detects stalled Jetstream connections and force-reconnects them.
|
|
7
|
+
#
|
|
8
|
+
# Background: after macOS sleep/wake the kernel may still consider the
|
|
9
|
+
# WebSocket's TCP socket alive, so Jetstream::Client#each_event blocks in
|
|
10
|
+
# `recv` indefinitely instead of raising. StreamManager's reconnect loop
|
|
11
|
+
# therefore never runs. The watchdog periodically inspects
|
|
12
|
+
# `stream_manager.last_event_at` and, if no event has arrived within
|
|
13
|
+
# `threshold_seconds`, calls `force_reconnect` to break the stalled call.
|
|
14
|
+
class Watchdog
|
|
15
|
+
DEFAULT_THRESHOLD_SECONDS = 90
|
|
16
|
+
DEFAULT_INTERVAL_SECONDS = 30
|
|
17
|
+
|
|
18
|
+
def initialize(stream_manager:,
|
|
19
|
+
threshold_seconds: DEFAULT_THRESHOLD_SECONDS,
|
|
20
|
+
interval_seconds: DEFAULT_INTERVAL_SECONDS,
|
|
21
|
+
clock: -> { Time.now },
|
|
22
|
+
sleeper: ->(s) { sleep(s) },
|
|
23
|
+
logger: nil)
|
|
24
|
+
@stream_manager = stream_manager
|
|
25
|
+
@threshold_seconds = threshold_seconds
|
|
26
|
+
@interval_seconds = interval_seconds
|
|
27
|
+
@clock = clock
|
|
28
|
+
@sleeper = sleeper
|
|
29
|
+
@logger = logger || Tempest::DebugLog.build_null_logger
|
|
30
|
+
@thread = nil
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@stopping = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
return if @thread&.alive?
|
|
38
|
+
@stopping = false
|
|
39
|
+
@thread = Thread.new { run }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
thread = @mutex.synchronize do
|
|
45
|
+
@stopping = true
|
|
46
|
+
t = @thread
|
|
47
|
+
@thread = nil
|
|
48
|
+
t
|
|
49
|
+
end
|
|
50
|
+
return unless thread
|
|
51
|
+
thread.kill
|
|
52
|
+
thread.join
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
Thread.current.report_on_exception = false
|
|
59
|
+
until stopping?
|
|
60
|
+
@sleeper.call(@interval_seconds)
|
|
61
|
+
break if stopping?
|
|
62
|
+
tick
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tick
|
|
67
|
+
last = @stream_manager.last_event_at
|
|
68
|
+
return unless @stream_manager.running?
|
|
69
|
+
return if last.nil?
|
|
70
|
+
|
|
71
|
+
elapsed = @clock.call - last
|
|
72
|
+
return unless elapsed > @threshold_seconds
|
|
73
|
+
|
|
74
|
+
@logger.warn("watchdog") do
|
|
75
|
+
"stalled stream elapsed_seconds=#{elapsed.round(1)} threshold=#{@threshold_seconds} — forcing reconnect"
|
|
76
|
+
end
|
|
77
|
+
@stream_manager.force_reconnect
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
# Never let a bad clock, logger, or stream_manager bug kill the thread.
|
|
80
|
+
# Best-effort log; if logger also raises, swallow.
|
|
81
|
+
begin
|
|
82
|
+
@logger.error("watchdog") { "tick error=#{e.class}: #{e.message}" }
|
|
83
|
+
rescue StandardError
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stopping?
|
|
88
|
+
@mutex.synchronize { @stopping }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/tempest/post.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require_relative "../tempest"
|
|
2
|
+
require_relative "facet"
|
|
3
|
+
|
|
4
|
+
module Tempest
|
|
5
|
+
Post = Data.define(:uri, :cid, :handle, :display_name, :text, :created_at, :facets, :reply_parent_uri) do
|
|
6
|
+
def initialize(uri:, cid:, handle:, display_name:, text:, created_at:,
|
|
7
|
+
facets: [], reply_parent_uri: nil)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.from_feed_view(post)
|
|
12
|
+
post = post || {}
|
|
13
|
+
author = post["author"] || {}
|
|
14
|
+
record = post["record"] || {}
|
|
15
|
+
reply = record["reply"]
|
|
16
|
+
reply_parent = reply.is_a?(Hash) ? reply["parent"] : nil
|
|
17
|
+
new(
|
|
18
|
+
uri: post["uri"],
|
|
19
|
+
cid: post["cid"],
|
|
20
|
+
handle: author["handle"],
|
|
21
|
+
display_name: author["displayName"],
|
|
22
|
+
text: record["text"],
|
|
23
|
+
created_at: record["createdAt"],
|
|
24
|
+
facets: Facet.parse(record["facets"]),
|
|
25
|
+
reply_parent_uri: reply_parent.is_a?(Hash) ? reply_parent["uri"] : nil,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compose a record for com.atproto.repo.createRecord (app.bsky.feed.post).
|
|
30
|
+
# When `reply` is provided, both root and parent are set to the same
|
|
31
|
+
# target. This is correct for top-level replies and a known v1 trade-off
|
|
32
|
+
# for replies deeper in a thread (AppView will nest the reply under
|
|
33
|
+
# `parent` instead of the original conversation root).
|
|
34
|
+
def self.create(client, did:, text:, reply: nil,
|
|
35
|
+
created_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"))
|
|
36
|
+
record = {
|
|
37
|
+
"$type" => "app.bsky.feed.post",
|
|
38
|
+
"text" => text,
|
|
39
|
+
"createdAt" => created_at,
|
|
40
|
+
}
|
|
41
|
+
if reply
|
|
42
|
+
ref = { "uri" => reply[:uri], "cid" => reply[:cid] }
|
|
43
|
+
record["reply"] = { "root" => ref, "parent" => ref }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
client.post(
|
|
47
|
+
"com.atproto.repo.createRecord",
|
|
48
|
+
body: {
|
|
49
|
+
repo: did,
|
|
50
|
+
collection: "app.bsky.feed.post",
|
|
51
|
+
record: record,
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "../../tempest"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module REPL
|
|
5
|
+
# Wraps an IO so writes from background threads (Jetstream events) don't
|
|
6
|
+
# smash Reline's prompt. Each puts clears the current terminal line and
|
|
7
|
+
# then asks Reline to re-render the prompt and the user's in-flight input
|
|
8
|
+
# buffer. Best-effort: if Reline isn't loaded or the rerender hook isn't
|
|
9
|
+
# available, we just degrade to a normal write.
|
|
10
|
+
class AsyncOutput
|
|
11
|
+
def initialize(io)
|
|
12
|
+
@io = io
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def puts(*lines)
|
|
16
|
+
if lines.empty?
|
|
17
|
+
@io.print "\r\e[2K"
|
|
18
|
+
@io.puts
|
|
19
|
+
else
|
|
20
|
+
lines.each do |line|
|
|
21
|
+
@io.print "\r\e[2K"
|
|
22
|
+
@io.puts line
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
rerender_prompt
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def print(*args)
|
|
29
|
+
@io.print(*args)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write(*args)
|
|
33
|
+
@io.write(*args)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def flush
|
|
37
|
+
@io.flush if @io.respond_to?(:flush)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def tty?
|
|
41
|
+
@io.respond_to?(:tty?) ? @io.tty? : false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def respond_to_missing?(name, include_private = false)
|
|
45
|
+
@io.respond_to?(name, include_private)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
49
|
+
@io.send(name, *args, **kwargs, &block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def rerender_prompt
|
|
55
|
+
return unless defined?(Reline)
|
|
56
|
+
Reline.line_editor&.rerender
|
|
57
|
+
rescue StandardError
|
|
58
|
+
# Reline's private APIs may move; never let a redraw failure surface.
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require_relative "../../tempest"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module REPL
|
|
5
|
+
Command = Data.define(:name, :args)
|
|
6
|
+
|
|
7
|
+
class Dispatcher
|
|
8
|
+
KNOWN_COMMANDS = %i[timeline quit help stream open].freeze
|
|
9
|
+
DOLLAR_ID = /\A\$[A-Z]{2}\z/.freeze
|
|
10
|
+
|
|
11
|
+
def dispatch(input)
|
|
12
|
+
return Command.new(name: :quit, args: []) if input.nil?
|
|
13
|
+
|
|
14
|
+
stripped = input.strip
|
|
15
|
+
return Command.new(name: :noop, args: []) if stripped.empty?
|
|
16
|
+
|
|
17
|
+
if stripped.start_with?(":")
|
|
18
|
+
name, *rest = stripped[1..].split(/\s+/)
|
|
19
|
+
symbol = name.to_sym
|
|
20
|
+
if KNOWN_COMMANDS.include?(symbol)
|
|
21
|
+
Command.new(name: symbol, args: rest)
|
|
22
|
+
else
|
|
23
|
+
Command.new(name: :unknown, args: [name])
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
head, tail = stripped.split(/\s+/, 2)
|
|
27
|
+
if DOLLAR_ID.match?(head)
|
|
28
|
+
Command.new(name: :reply, args: [head, tail.to_s])
|
|
29
|
+
else
|
|
30
|
+
Command.new(name: :post, args: [stripped])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
require_relative "../../tempest"
|
|
5
|
+
|
|
6
|
+
module Tempest
|
|
7
|
+
module REPL
|
|
8
|
+
# Renders posts and Jetstream events as terminal lines, earthquake-style:
|
|
9
|
+
# [$AA] [HH:MM] @handle: text
|
|
10
|
+
# The leading [$AA] is only emitted when a Registry is supplied (and the
|
|
11
|
+
# event is something that can be replied to — a post, not a delete or a
|
|
12
|
+
# like/repost record). URLs found in the body are annotated inline with
|
|
13
|
+
# their own ($LA) ids when a registry is supplied.
|
|
14
|
+
# ANSI colors are emitted only when Formatter.color is true (set by the
|
|
15
|
+
# CLI when stdout is a TTY); tests run with color disabled.
|
|
16
|
+
module Formatter
|
|
17
|
+
RESET = "\e[0m".freeze
|
|
18
|
+
CYAN = "\e[36m".freeze
|
|
19
|
+
GREEN = "\e[32m".freeze
|
|
20
|
+
DIM = "\e[2m".freeze
|
|
21
|
+
HASHTAG_BLUE = "\e[38;5;110m".freeze
|
|
22
|
+
|
|
23
|
+
HASHTAG_PATTERN = /#[[:alnum:]_]+/.freeze
|
|
24
|
+
URL_PATTERN = %r{https?://[^\s]+}.freeze
|
|
25
|
+
DECORATE_PATTERN = Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
attr_accessor :color
|
|
29
|
+
end
|
|
30
|
+
self.color = false
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def post_line(post, registry: nil)
|
|
35
|
+
var = registry&.assign_post(post)
|
|
36
|
+
facets = post.respond_to?(:facets) ? post.facets : nil
|
|
37
|
+
body = annotate_urls(squeeze(post.text), registry, facets: facets)
|
|
38
|
+
body = decorate_body(body)
|
|
39
|
+
body = prepend_reply_marker(body, reply_parent_uri_of(post), registry)
|
|
40
|
+
compose(var, format_time(post.created_at), post.handle, nil, body)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def decorate_body(text)
|
|
44
|
+
return text unless Formatter.color
|
|
45
|
+
return text if text.nil? || text.empty?
|
|
46
|
+
|
|
47
|
+
text.gsub(DECORATE_PATTERN) do |match|
|
|
48
|
+
color = match.start_with?("http") ? DIM : HASHTAG_BLUE
|
|
49
|
+
"#{color}#{match}#{RESET}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status_line(status)
|
|
54
|
+
body = case status.state
|
|
55
|
+
when :disconnected
|
|
56
|
+
status.reason == :error && status.error ? "disconnected: #{status.error.message}" : "disconnected"
|
|
57
|
+
when :reconnecting
|
|
58
|
+
"reconnecting..."
|
|
59
|
+
when :live
|
|
60
|
+
"live"
|
|
61
|
+
when :gapped
|
|
62
|
+
"fetching timeline (offline since #{format_status_time(status.since)})"
|
|
63
|
+
else
|
|
64
|
+
status.state.to_s
|
|
65
|
+
end
|
|
66
|
+
Formatter.color ? "#{DIM}-- #{body}#{RESET}" : "-- #{body}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_status_time(time)
|
|
70
|
+
time.respond_to?(:localtime) ? time.localtime.strftime("%H:%M") : time.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def event_line(event, registry: nil, resolver: nil)
|
|
74
|
+
handle = resolver&.resolve(event.did)
|
|
75
|
+
if event.operation == :delete
|
|
76
|
+
body = "(deleted #{event.collection}/#{event.rkey})"
|
|
77
|
+
var = nil
|
|
78
|
+
elsif event.respond_to?(:like?) && event.like?
|
|
79
|
+
body = "liked #{subject_owner_label(event.subject_uri, resolver)}"
|
|
80
|
+
var = nil
|
|
81
|
+
elsif event.respond_to?(:repost?) && event.repost?
|
|
82
|
+
body = "reposted #{subject_owner_label(event.subject_uri, resolver)}"
|
|
83
|
+
var = nil
|
|
84
|
+
else
|
|
85
|
+
facets = event.respond_to?(:facets) ? event.facets : nil
|
|
86
|
+
body = annotate_urls(squeeze(event.text), registry, facets: facets)
|
|
87
|
+
body = decorate_body(body)
|
|
88
|
+
body = prepend_reply_marker(body, reply_parent_uri_of(event), registry)
|
|
89
|
+
var = registry&.assign_post(event)
|
|
90
|
+
end
|
|
91
|
+
compose(var, format_time(event.created_at), handle, event.did, body)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def subject_owner_label(subject_uri, resolver)
|
|
95
|
+
did = subject_did(subject_uri)
|
|
96
|
+
return "a post" unless did
|
|
97
|
+
|
|
98
|
+
handle = resolver&.resolve(did)
|
|
99
|
+
owner = handle ? handle_label(handle) : did_label(did)
|
|
100
|
+
"#{owner}'s post"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def subject_did(subject_uri)
|
|
104
|
+
return nil if subject_uri.nil? || subject_uri.empty?
|
|
105
|
+
match = subject_uri.match(%r{\Aat://([^/]+)/})
|
|
106
|
+
match && match[1]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def reply_parent_uri_of(record)
|
|
110
|
+
record.respond_to?(:reply_parent_uri) ? record.reply_parent_uri : nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def prepend_reply_marker(body, reply_parent_uri, registry)
|
|
114
|
+
return body if reply_parent_uri.nil? || reply_parent_uri.empty?
|
|
115
|
+
return body unless registry
|
|
116
|
+
|
|
117
|
+
parent_var = registry.var_for_uri(reply_parent_uri)
|
|
118
|
+
marker = parent_var ? "↪#{parent_var} " : "↪ "
|
|
119
|
+
"#{marker}#{body}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def annotate_urls(text, registry, facets: nil)
|
|
123
|
+
return text unless registry
|
|
124
|
+
text = text.to_s
|
|
125
|
+
if facets && !facets.empty?
|
|
126
|
+
return annotate_urls_with_facets(text, registry, facets)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
urls = URI.extract(text, ["http", "https"]).uniq
|
|
130
|
+
urls.each do |url|
|
|
131
|
+
var = registry.assign_url(url)
|
|
132
|
+
text = text.sub(url, "#{url} (#{var})")
|
|
133
|
+
end
|
|
134
|
+
text
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def annotate_urls_with_facets(text, registry, facets)
|
|
138
|
+
text = text.dup.force_encoding(Encoding::UTF_8)
|
|
139
|
+
bytesize = text.bytesize
|
|
140
|
+
valid = facets
|
|
141
|
+
.select { |f| f.byte_start.is_a?(Integer) && f.byte_end.is_a?(Integer) }
|
|
142
|
+
.select { |f| f.byte_start >= 0 && f.byte_end <= bytesize && f.byte_start < f.byte_end }
|
|
143
|
+
.sort_by(&:byte_start)
|
|
144
|
+
|
|
145
|
+
# Assign vars in reading order so earlier facets get earlier ids.
|
|
146
|
+
replacements = valid.map do |facet|
|
|
147
|
+
var = registry.assign_url(facet.uri)
|
|
148
|
+
domain = host_of(facet.uri) || facet.uri
|
|
149
|
+
[facet, "[#{domain} #{var}]"]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Apply substitutions in reverse byte order so earlier ranges remain valid.
|
|
153
|
+
replacements.reverse_each do |facet, replacement|
|
|
154
|
+
head = text.byteslice(0, facet.byte_start) || ""
|
|
155
|
+
tail = text.byteslice(facet.byte_end, text.bytesize - facet.byte_end) || ""
|
|
156
|
+
text = (head + replacement + tail).force_encoding(Encoding::UTF_8)
|
|
157
|
+
end
|
|
158
|
+
text
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def host_of(uri)
|
|
162
|
+
parsed = URI.parse(uri)
|
|
163
|
+
host = parsed.host
|
|
164
|
+
host && !host.empty? ? host : nil
|
|
165
|
+
rescue URI::InvalidURIError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def squeeze(text)
|
|
170
|
+
text.to_s.gsub(/\s*\n\s*/, " ")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def format_time(iso)
|
|
174
|
+
return nil if iso.nil? || iso.empty?
|
|
175
|
+
Time.iso8601(iso).localtime.strftime("%H:%M")
|
|
176
|
+
rescue ArgumentError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def compose(var, time, handle, did, text)
|
|
181
|
+
prefix = ""
|
|
182
|
+
prefix += id_label(var) if var
|
|
183
|
+
prefix += bracket(time) if time
|
|
184
|
+
identity = handle ? handle_label(handle) : did_label(did)
|
|
185
|
+
"#{prefix}#{identity}: #{text}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def bracket(time)
|
|
189
|
+
Formatter.color ? "#{CYAN}[#{time}]#{RESET} " : "[#{time}] "
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def id_label(var)
|
|
193
|
+
Formatter.color ? "#{DIM}[#{var}]#{RESET} " : "[#{var}] "
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def handle_label(handle)
|
|
197
|
+
Formatter.color ? "#{GREEN}@#{handle}#{RESET}" : "@#{handle}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def did_label(did)
|
|
201
|
+
Formatter.color ? "#{DIM}<#{did}>#{RESET}" : "<#{did}>"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "../id_var"
|
|
2
|
+
|
|
3
|
+
module Tempest
|
|
4
|
+
module REPL
|
|
5
|
+
# Holds two IdVar rings (post + link) plus side tables that resolve a
|
|
6
|
+
# var string back to the original Post / URL. Lifetime is the
|
|
7
|
+
# current REPL session; nothing is persisted.
|
|
8
|
+
class Registry
|
|
9
|
+
def initialize
|
|
10
|
+
@post_ids = Tempest::IdVar.new(range: "AA".."ZZ")
|
|
11
|
+
@link_ids = Tempest::IdVar.new(range: "LA".."LZ")
|
|
12
|
+
@posts = {}
|
|
13
|
+
@urls = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def assign_post(post)
|
|
17
|
+
var = @post_ids.generate(post_key(post))
|
|
18
|
+
@posts[var] = post
|
|
19
|
+
var
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def assign_url(url)
|
|
23
|
+
var = @link_ids.generate(url)
|
|
24
|
+
@urls[var] = url
|
|
25
|
+
var
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def find_post(var)
|
|
29
|
+
@posts[var]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_url(var)
|
|
33
|
+
@urls[var]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Reverse lookup: returns the var currently mapped to the given
|
|
37
|
+
# post URI, or nil if the URI has never been assigned or its slot
|
|
38
|
+
# has been recycled to a different post.
|
|
39
|
+
def var_for_uri(uri)
|
|
40
|
+
@posts.each do |var, post|
|
|
41
|
+
return var if post_key(post) == uri
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def post_key(post)
|
|
49
|
+
if post.respond_to?(:uri) && post.uri
|
|
50
|
+
post.uri
|
|
51
|
+
else
|
|
52
|
+
post.at_uri
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|