trmnl_preview 0.15.0 → 0.17.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +48 -0
- data/db/data/and_x_more.yml +86 -0
- data/db/data/locales/cs.yml +339 -0
- data/db/data/locales/da.yml +339 -0
- data/db/data/locales/de-AT.yml +341 -0
- data/db/data/locales/de.yml +341 -0
- data/db/data/locales/en-AU.yml +79 -0
- data/db/data/locales/en-GB.yml +79 -0
- data/db/data/locales/en.yml +341 -0
- data/db/data/locales/es-ES.yml +341 -0
- data/db/data/locales/fr.yml +339 -0
- data/db/data/locales/he.yml +339 -0
- data/db/data/locales/hu.yml +339 -0
- data/db/data/locales/id.yml +339 -0
- data/db/data/locales/is.yml +339 -0
- data/db/data/locales/it.yml +341 -0
- data/db/data/locales/ja.yml +342 -0
- data/db/data/locales/ko.yml +337 -0
- data/db/data/locales/lt.yml +340 -0
- data/db/data/locales/nl.yml +339 -0
- data/db/data/locales/no.yml +4 -0
- data/db/data/locales/pl.yml +342 -0
- data/db/data/locales/pt-BR.yml +340 -0
- data/db/data/locales/raw.yml +256 -0
- data/db/data/locales/ro.yml +339 -0
- data/db/data/locales/ru.yml +342 -0
- data/db/data/locales/sk.yml +339 -0
- data/db/data/locales/sv.yml +340 -0
- data/db/data/locales/uk.yml +339 -0
- data/db/data/locales/zh-CN.yml +338 -0
- data/db/data/locales/zh-HK.yml +338 -0
- data/lib/trmnlp/app.rb +5 -4
- data/lib/trmnlp/async_callback.rb +75 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +23 -10
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +29 -6
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/firefox_driver.rb +0 -3
- data/lib/trmnlp/framework_version.rb +9 -0
- data/lib/trmnlp/image_quantizer.rb +28 -15
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +5 -0
- data/lib/trmnlp/lint/source.rb +6 -0
- data/lib/trmnlp/lint.rb +3 -1
- data/lib/trmnlp/merged_plugin_locals.rb +57 -0
- data/lib/trmnlp/outbound_request.rb +99 -0
- data/lib/trmnlp/paths.rb +15 -8
- data/lib/trmnlp/poller.rb +100 -47
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +40 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +50 -11
- data/lib/trmnlp/select_field_choices.rb +25 -0
- data/lib/trmnlp/testing/browser.rb +87 -0
- data/lib/trmnlp/testing/certificate_authority.rb +84 -0
- data/lib/trmnlp/testing/device_models.rb +80 -0
- data/lib/trmnlp/testing/frozen_clock.rb +86 -0
- data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
- data/lib/trmnlp/testing/memory_sampler.rb +44 -0
- data/lib/trmnlp/testing/mock_proxy.rb +124 -0
- data/lib/trmnlp/testing/mock_table.rb +102 -0
- data/lib/trmnlp/testing/node_version.rb +27 -0
- data/lib/trmnlp/testing/plugin.rb +91 -0
- data/lib/trmnlp/testing/report.html.erb +79 -0
- data/lib/trmnlp/testing/report.rb +91 -0
- data/lib/trmnlp/testing/rspec.rb +89 -0
- data/lib/trmnlp/testing/run.rb +150 -0
- data/lib/trmnlp/testing/screen.rb +83 -0
- data/lib/trmnlp/testing/snapshot.rb +55 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
- data/lib/trmnlp/transform_pipeline.rb +51 -9
- data/lib/trmnlp/transform_state.rb +51 -0
- data/lib/trmnlp/user_data_assembler.rb +63 -25
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +2 -1
- data/lib/trmnlp/webhook_payload.rb +74 -0
- data/lib/trmnlp/webhook_receiver.rb +78 -0
- data/lib/trmnlp.rb +2 -1
- data/templates/init/.github/workflows/trmnl.yml +33 -2
- data/templates/init/.gitignore +3 -0
- data/templates/init/tests/plugin_spec.rb +32 -0
- data/trmnl_preview.gemspec +6 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +37 -1
- metadata +115 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
# A plugin_merge plugin's data as TRMNL builds it: each referenced plugin's locals under "<keyname>_<id>".
|
|
5
|
+
# Locally each reference names another trmnlp project, listed under merged_plugins in .trmnlp.yml.
|
|
6
|
+
class MergedPluginLocals
|
|
7
|
+
SENSITIVE_KEYNAMES = %w[polling_headers headers polling_body oauth_token_headers oauth_token_params].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(config:, paths:)
|
|
10
|
+
@config = config
|
|
11
|
+
@paths = paths
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
# A project met again is a cycle and merges as empty.
|
|
16
|
+
root_dirs_being_merged = (Thread.current[:trmnlp_merged_plugin_dirs] ||= [])
|
|
17
|
+
return {} if root_dirs_being_merged.include?(paths.root_dir)
|
|
18
|
+
|
|
19
|
+
root_dirs_being_merged.push(paths.root_dir)
|
|
20
|
+
begin
|
|
21
|
+
merged_locals
|
|
22
|
+
ensure
|
|
23
|
+
root_dirs_being_merged.pop
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :config, :paths
|
|
30
|
+
|
|
31
|
+
def merged_locals
|
|
32
|
+
locals = config.project.merged_plugins.to_h { |reference, dir| [reference, plugin_locals(paths.expand(dir))] }
|
|
33
|
+
instance_selections.each { |keyname, selected| locals[keyname] = locals[selected] if locals.key?(selected) }
|
|
34
|
+
locals
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def plugin_locals(dir)
|
|
38
|
+
context = Context.new(dir)
|
|
39
|
+
context.validate!
|
|
40
|
+
locals = { 'merge_variables' => context.user_data_assembler.call.except('trmnl') }
|
|
41
|
+
values = shareable_custom_fields_values(context.config.plugin)
|
|
42
|
+
values.empty? ? locals : locals.merge('custom_fields_values' => values)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shareable_custom_fields_values(plugin)
|
|
46
|
+
secret = plugin.custom_field_definitions.select do |field|
|
|
47
|
+
field['field_type'] == 'password' || SENSITIVE_KEYNAMES.include?(field['keyname'])
|
|
48
|
+
end
|
|
49
|
+
plugin.custom_fields_values.except(*secret.map { it['keyname'] })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def instance_selections
|
|
53
|
+
fields = config.plugin.custom_field_definitions.select { it['field_type'] == 'plugin_instance_select' }
|
|
54
|
+
config.plugin.custom_fields_values.slice(*fields.map { it['keyname'] }).reject { |_, selected| selected.empty? }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'timeout'
|
|
8
|
+
|
|
9
|
+
module TRMNLP
|
|
10
|
+
# One polling request as TRMNL makes it; answers [response, nil] or [nil, failure reason].
|
|
11
|
+
class OutboundRequest
|
|
12
|
+
TIMEOUT_SECONDS = 10
|
|
13
|
+
MAX_REDIRECTS = 5
|
|
14
|
+
REDIRECT_STATUSES = (301..308)
|
|
15
|
+
METHOD_PRESERVING_REDIRECTS = [307, 308].freeze
|
|
16
|
+
PRIVATE_ADDRESS_RANGES = [
|
|
17
|
+
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
|
|
18
|
+
'172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15',
|
|
19
|
+
'::1/128', 'fc00::/7', 'fe80::/10'
|
|
20
|
+
].map { IPAddr.new(it) }.freeze
|
|
21
|
+
|
|
22
|
+
# TRMNL refuses these hosts; a self-hosted server may not, so trmnlp only warns.
|
|
23
|
+
def self.private_address?(url)
|
|
24
|
+
host = URI.parse(url).hostname
|
|
25
|
+
addresses = begin
|
|
26
|
+
[IPAddr.new(host).to_s]
|
|
27
|
+
rescue IPAddr::InvalidAddressError
|
|
28
|
+
Socket.getaddrinfo(host, nil).map { it[3] }.uniq
|
|
29
|
+
end
|
|
30
|
+
addresses.any? { |ip| PRIVATE_ADDRESS_RANGES.any? { it.include?(IPAddr.new(ip)) } }
|
|
31
|
+
rescue URI::Error, SocketError, ArgumentError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(on_private_address: ->(_url) {})
|
|
36
|
+
@on_private_address = on_private_address
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(verb, url, headers:, body:)
|
|
40
|
+
attempts = 0
|
|
41
|
+
begin
|
|
42
|
+
attempts += 1
|
|
43
|
+
response = Timeout.timeout(TIMEOUT_SECONDS * 2, Faraday::TimeoutError) do
|
|
44
|
+
follow_redirects(verb, encoded(url), headers, body)
|
|
45
|
+
end
|
|
46
|
+
reason = failure_reason(response)
|
|
47
|
+
reason ? [nil, reason] : [response, nil]
|
|
48
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
49
|
+
timed_out = e.is_a?(Faraday::TimeoutError) || e.wrapped_exception.is_a?(Net::OpenTimeout)
|
|
50
|
+
retry if timed_out && attempts < 2
|
|
51
|
+
[nil, timed_out ? "no response within #{TIMEOUT_SECONDS}s" : "could not connect (#{e.message})"]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def follow_redirects(verb, url, headers, body)
|
|
58
|
+
hops = 0
|
|
59
|
+
loop do
|
|
60
|
+
@on_private_address.call(url) if self.class.private_address?(url)
|
|
61
|
+
response = perform(verb, url, headers, body)
|
|
62
|
+
location = response.headers['location']
|
|
63
|
+
return response unless REDIRECT_STATUSES.cover?(response.status) && location && hops < MAX_REDIRECTS
|
|
64
|
+
|
|
65
|
+
hops += 1
|
|
66
|
+
next_url = URI.join(url, location).to_s
|
|
67
|
+
unless METHOD_PRESERVING_REDIRECTS.include?(response.status)
|
|
68
|
+
verb = 'GET'
|
|
69
|
+
body = nil
|
|
70
|
+
end
|
|
71
|
+
headers = headers.reject { |name, _| name.to_s.casecmp?('authorization') } if host(url) != host(next_url)
|
|
72
|
+
url = next_url
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A GET sends the polling body as its query string, as TRMNL does.
|
|
77
|
+
def perform(verb, url, headers, body)
|
|
78
|
+
connection = Faraday.new(url:, headers:, request: { timeout: TIMEOUT_SECONDS, open_timeout: TIMEOUT_SECONDS })
|
|
79
|
+
return connection.post { |request| request.body = body } if verb == 'POST'
|
|
80
|
+
return connection.get if body.to_s.empty?
|
|
81
|
+
|
|
82
|
+
connection.get("#{url}#{url.include?('?') ? '&' : '?'}#{body}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def failure_reason(response)
|
|
86
|
+
return 'the host replied 429 Too Many Requests' if response.status == 429
|
|
87
|
+
|
|
88
|
+
"the host replied #{response.status}" if response.status >= 500
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def host(url) = URI.parse(url).host&.downcase
|
|
92
|
+
|
|
93
|
+
# The host stays as typed; only what follows it is percent-encoded.
|
|
94
|
+
def encoded(url)
|
|
95
|
+
scheme_and_host, rest = url.match(%r{\A([a-z][a-z0-9+.-]*://[^/?#]*)(.*)\z}im)&.captures
|
|
96
|
+
scheme_and_host ? scheme_and_host + rest.gsub(/[^\x00-\x7F]/) { CGI.escape(it) } : url
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/trmnlp/paths.rb
CHANGED
|
@@ -7,8 +7,9 @@ module TRMNLP
|
|
|
7
7
|
class Paths
|
|
8
8
|
attr_reader :root_dir
|
|
9
9
|
|
|
10
|
-
def initialize(root_dir)
|
|
11
|
-
@root_dir = Pathname.new(root_dir)
|
|
10
|
+
def initialize(root_dir, cache_dir: nil)
|
|
11
|
+
@root_dir = Pathname.new(root_dir).expand_path
|
|
12
|
+
@cache_dir = cache_dir && Pathname.new(cache_dir)
|
|
12
13
|
@xdg = XDG.new
|
|
13
14
|
end
|
|
14
15
|
|
|
@@ -27,8 +28,7 @@ module TRMNLP
|
|
|
27
28
|
|
|
28
29
|
def app_config_dir = xdg.config_home.join('trmnlp')
|
|
29
30
|
|
|
30
|
-
def cache_dir = xdg.cache_home.join('trmnl')
|
|
31
|
-
def create_cache_dir = cache_dir.mkpath
|
|
31
|
+
def cache_dir = @cache_dir || xdg.cache_home.join('trmnl')
|
|
32
32
|
|
|
33
33
|
def valid? = trmnlp_config.exist?
|
|
34
34
|
|
|
@@ -44,11 +44,16 @@ module TRMNLP
|
|
|
44
44
|
|
|
45
45
|
def app_config = app_config_dir.join('config.yml')
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# Cached files are keyed per project root so working on two plugins does not
|
|
48
|
+
# clobber a shared file.
|
|
49
|
+
def user_data = cache_dir.join('data', "#{project_key}.json")
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def
|
|
51
|
+
def oauth_tokens = cache_dir.join('oauth', "#{project_key}.json")
|
|
52
|
+
|
|
53
|
+
def transform_state = cache_dir.join('state', "#{project_key}.json")
|
|
54
|
+
def fetch_failed_marker = cache_dir.join('state', "#{project_key}.fetch_failed")
|
|
55
|
+
|
|
56
|
+
def transform_output = cache_dir.join('transform_output', "#{project_key}.json")
|
|
52
57
|
|
|
53
58
|
def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
|
|
54
59
|
|
|
@@ -79,5 +84,7 @@ module TRMNLP
|
|
|
79
84
|
private
|
|
80
85
|
|
|
81
86
|
attr_reader :xdg
|
|
87
|
+
|
|
88
|
+
def project_key = Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]
|
|
82
89
|
end
|
|
83
90
|
end
|
data/lib/trmnlp/poller.rb
CHANGED
|
@@ -1,52 +1,83 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_support/core_ext/hash/conversions'
|
|
4
|
-
require '
|
|
4
|
+
require 'csv'
|
|
5
5
|
require 'json'
|
|
6
6
|
|
|
7
|
+
require_relative 'outbound_request'
|
|
7
8
|
require_relative 'reporter'
|
|
8
9
|
|
|
9
10
|
module TRMNLP
|
|
10
11
|
class Poller
|
|
11
|
-
|
|
12
|
+
BYTE_ORDER_MARK = "\xEF\xBB\xBF".b
|
|
13
|
+
|
|
14
|
+
# trmnl_variables answers the trmnl namespace TRMNL renders polling urls, headers and bodies with.
|
|
15
|
+
# rubocop:disable-next Metrics/ParameterLists -- keyword collaborators Context wires once
|
|
16
|
+
def initialize(config:, paths:, oauth_session:, trmnl_variables: -> { {} }, reporter: Reporter.new,
|
|
17
|
+
async_callback: nil, outbound_request: nil)
|
|
12
18
|
@config = config
|
|
19
|
+
@given_outbound_request = outbound_request
|
|
13
20
|
@paths = paths
|
|
14
21
|
@oauth_session = oauth_session
|
|
22
|
+
@trmnl_variables = trmnl_variables
|
|
15
23
|
@reporter = reporter
|
|
24
|
+
@async_callback = async_callback
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
def poll_data
|
|
28
|
+
return request_async_data if config.plugin.async_polling?
|
|
19
29
|
return unless config.plugin.polling?
|
|
20
|
-
raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
missing = config.plugin.missing_required_fields
|
|
32
|
+
return report_warning("Plugin is not configured — fill in: #{missing.join(', ')}") if missing.any?
|
|
33
|
+
|
|
34
|
+
@fetch_failed = false
|
|
35
|
+
# Like TRMNL: no url is nothing to fetch, and the transform runs on {}.
|
|
36
|
+
data = config.plugin.polling_urls.empty? ? {} : aggregate_responses
|
|
23
37
|
write_user_data(data)
|
|
38
|
+
record_fetch_outcome
|
|
24
39
|
data
|
|
25
40
|
# NOTE: trmnlp is a dev tool — a flaky upstream API should surface a warning
|
|
26
41
|
# and keep the preview server alive, not crash the user's session. We
|
|
27
42
|
# deliberately swallow here and return {} so the renderer keeps rendering.
|
|
28
43
|
rescue StandardError => e
|
|
29
|
-
|
|
44
|
+
report_warning(e.message)
|
|
45
|
+
record_fetch_outcome
|
|
30
46
|
{}
|
|
31
47
|
end
|
|
32
48
|
|
|
33
|
-
def put_webhook(payload)
|
|
34
|
-
write_user_data(wrap_array(JSON.parse(payload)))
|
|
35
|
-
# NOTE: Same rationale as #poll_data — a bad webhook payload shouldn't take
|
|
36
|
-
# down the dev server. Report a warning and keep serving.
|
|
37
|
-
rescue StandardError => e
|
|
38
|
-
reporter.info(reporter.yellow("webhook warning: #{e.message}"))
|
|
39
|
-
end
|
|
40
|
-
|
|
41
49
|
private
|
|
42
50
|
|
|
43
|
-
attr_reader :config, :paths, :oauth_session, :reporter
|
|
51
|
+
attr_reader :config, :paths, :oauth_session, :trmnl_variables, :reporter, :async_callback
|
|
52
|
+
|
|
53
|
+
# Like TRMNL: the API answers 202 and posts the data to callback_url later; the last post renders meanwhile.
|
|
54
|
+
def request_async_data
|
|
55
|
+
unless async_callback&.server_url
|
|
56
|
+
return report_warning('async_polling needs `trmnlp serve` to receive the callback')
|
|
57
|
+
end
|
|
58
|
+
return report_warning('Waiting for the async callback; the last data posted renders') if async_callback.awaiting?
|
|
59
|
+
return if config.plugin.polling_urls.empty?
|
|
60
|
+
|
|
61
|
+
report_warning('Async callback not received within 15 minutes') if async_callback.expired?
|
|
62
|
+
response, url = send_async_request(async_callback.start)
|
|
63
|
+
return if response&.status == 202
|
|
64
|
+
|
|
65
|
+
async_callback.cancel
|
|
66
|
+
report_warning("Async polling failed: HTTP #{response.status} from #{without_query(url)}") if response
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def send_async_request(callback_url)
|
|
70
|
+
variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
|
|
71
|
+
url = config.plugin.polling_urls(extra_variables: variables.merge('callback_url' => callback_url)).first
|
|
72
|
+
headers = config.plugin.polling_headers(extra_variables: variables)
|
|
73
|
+
[fetch_one(url, headers, config.plugin.polling_body(extra_variables: variables)), url]
|
|
74
|
+
end
|
|
44
75
|
|
|
45
76
|
def aggregate_responses
|
|
46
77
|
fetched = fetch_all
|
|
47
78
|
# Like the hosted service: a token rejected before its expiry gets one refresh and retry.
|
|
48
|
-
fetched = fetch_all if fetched.any? { |_url, response| response
|
|
49
|
-
responses = fetched.map { |url, response| parse_response(response, url) }
|
|
79
|
+
fetched = fetch_all if fetched.any? { |_url, response| response&.status == 401 } && oauth_session.force_refresh!
|
|
80
|
+
responses = fetched.map { |url, response| response ? parse_response(response, url) : {} }
|
|
50
81
|
return responses.first if responses.size == 1
|
|
51
82
|
|
|
52
83
|
responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
|
|
@@ -55,62 +86,84 @@ module TRMNLP
|
|
|
55
86
|
def fetch_all
|
|
56
87
|
# Resolve once per attempt (it refreshes as a side effect), then share across
|
|
57
88
|
# every URL, header, and body render.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
|
|
90
|
+
headers = config.plugin.polling_headers(extra_variables: variables)
|
|
91
|
+
body = config.plugin.polling_body(extra_variables: variables)
|
|
92
|
+
config.plugin.polling_urls(extra_variables: variables).map { |url| [url, fetch_one(url, headers, body)] }
|
|
61
93
|
end
|
|
62
94
|
|
|
63
|
-
def fetch_one(url,
|
|
95
|
+
def fetch_one(url, headers, body)
|
|
64
96
|
verb = config.plugin.polling_verb.upcase
|
|
65
|
-
response =
|
|
66
|
-
|
|
97
|
+
response, failure = outbound_request.call(verb, url, headers:, body:)
|
|
98
|
+
return report_warning("Unable to fetch data from url: #{without_query(url)} — #{failure}") if failure
|
|
99
|
+
|
|
100
|
+
size = response.body.length
|
|
101
|
+
reporter.info("#{verb} #{without_query(url)} — received #{size} bytes (#{response.status} status)")
|
|
67
102
|
response
|
|
68
103
|
end
|
|
69
104
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
else
|
|
75
|
-
conn.get
|
|
76
|
-
end
|
|
105
|
+
def outbound_request
|
|
106
|
+
@given_outbound_request || OutboundRequest.new(on_private_address: lambda { |url|
|
|
107
|
+
report_warning("TRMNL refuses #{without_query(url)}: the url resolves to a private address")
|
|
108
|
+
})
|
|
77
109
|
end
|
|
78
110
|
|
|
79
|
-
# Like the hosted service:
|
|
111
|
+
# Like the hosted service: a client error is reported, but its body still reaches the data.
|
|
80
112
|
def parse_response(response, url)
|
|
81
113
|
success = (200..299).cover?(response.status)
|
|
82
|
-
|
|
83
|
-
parse_body(response.body, response.headers['content-type'])
|
|
114
|
+
report_warning("HTTP #{response.status} from #{url}") unless success
|
|
115
|
+
parse_body(without_byte_order_mark(response.body.to_s), response.headers['content-type'])
|
|
116
|
+
rescue JSON::ParserError, CSV::MalformedCSVError => e
|
|
117
|
+
report_warning("Malformed #{e.is_a?(CSV::MalformedCSVError) ? 'CSV' : 'JSON'} from url: #{url}")
|
|
118
|
+
{}
|
|
84
119
|
end
|
|
85
120
|
|
|
121
|
+
# Unlisted and missing content types are read as JSON, as TRMNL does.
|
|
86
122
|
def parse_body(body, content_type_header)
|
|
87
|
-
|
|
88
|
-
case content_type
|
|
89
|
-
when 'application/json', %r{^application/.+\+json} then wrap_array(JSON.parse(body))
|
|
123
|
+
case content_type_header&.split(';')&.first&.strip
|
|
90
124
|
when 'text/xml', 'application/xml', %r{^application/.+\+xml} then wrap_array(Hash.from_xml(body))
|
|
91
|
-
when 'text/
|
|
92
|
-
|
|
125
|
+
when 'text/csv' then wrap_array(CSV.parse(body))
|
|
126
|
+
when 'text/plain', 'text/html', 'text/markdown', 'text/x-markdown' then parse_textual_body(body)
|
|
127
|
+
else wrap_array(JSON.parse(body))
|
|
93
128
|
end
|
|
94
129
|
end
|
|
95
130
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
131
|
+
# Some endpoints serve JSON under a textual content type; anything else passes through under `data`.
|
|
132
|
+
def parse_textual_body(body)
|
|
133
|
+
parsed = JSON.parse(body)
|
|
134
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? wrap_array(parsed) : { 'data' => body }
|
|
135
|
+
rescue JSON::ParserError
|
|
136
|
+
{ 'data' => body }
|
|
99
137
|
end
|
|
100
138
|
|
|
101
|
-
def
|
|
139
|
+
def without_byte_order_mark(body)
|
|
140
|
+
return body unless body.byteslice(0, BYTE_ORDER_MARK.bytesize)&.b == BYTE_ORDER_MARK
|
|
102
141
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return nil unless trimmed.start_with?('{', '[')
|
|
142
|
+
body.byteslice(BYTE_ORDER_MARK.bytesize..)
|
|
143
|
+
end
|
|
106
144
|
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
# A polling url often carries its api key in the query string.
|
|
146
|
+
def without_query(url) = url.split('?').first
|
|
147
|
+
|
|
148
|
+
def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
|
|
149
|
+
|
|
150
|
+
def report_warning(message)
|
|
151
|
+
@fetch_failed = true
|
|
152
|
+
reporter.info(reporter.yellow("warning: #{message}"))
|
|
109
153
|
nil
|
|
110
154
|
end
|
|
111
155
|
|
|
156
|
+
# The transform runs later, from the cached data, so whether this poll failed is kept beside it.
|
|
157
|
+
def record_fetch_outcome
|
|
158
|
+
marker = paths.fetch_failed_marker
|
|
159
|
+
return marker.delete if !@fetch_failed && marker.exist?
|
|
160
|
+
|
|
161
|
+
marker.dirname.mkpath
|
|
162
|
+
marker.write('') if @fetch_failed
|
|
163
|
+
end
|
|
164
|
+
|
|
112
165
|
def write_user_data(data)
|
|
113
|
-
paths.
|
|
166
|
+
paths.user_data.dirname.mkpath
|
|
114
167
|
paths.user_data.write(JSON.generate(data))
|
|
115
168
|
end
|
|
116
169
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'trmnl/liquid'
|
|
4
|
+
|
|
5
|
+
module TRMNLP
|
|
6
|
+
# Mirrors core's config/initializers/trmnl_liquid_qr_code_intrinsic_size.rb: a view box alone
|
|
7
|
+
# gives the code no size of its own, so it renders at zero width in a shrink-to-fit container.
|
|
8
|
+
module QrCodeIntrinsicSize
|
|
9
|
+
def qr_code(data, size = 11, level = '', view = 'responsive')
|
|
10
|
+
svg = super
|
|
11
|
+
root_element = svg[/<svg[^>]*>/].to_s
|
|
12
|
+
return svg if root_element.include?('width=')
|
|
13
|
+
|
|
14
|
+
side_length = root_element[/viewBox="0 0 (\d+)/, 1]
|
|
15
|
+
return svg unless side_length
|
|
16
|
+
|
|
17
|
+
svg.sub('<svg ', %(<svg width="#{side_length}" height="#{side_length}" style="max-width:100%;height:auto" ))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
TRMNL::Liquid::Filters.prepend(TRMNLP::QrCodeIntrinsicSize)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'i18n'
|
|
5
|
+
require 'active_support'
|
|
6
|
+
require 'active_support/number_helper'
|
|
7
|
+
require 'active_support/core_ext/string/inflections'
|
|
8
|
+
|
|
9
|
+
module TRMNL
|
|
10
|
+
module Liquid
|
|
11
|
+
# The ActionView helpers TRMNL::Liquid.load(:rails) gives the filters, without Rails.
|
|
12
|
+
module RailsHelpers
|
|
13
|
+
HTML_ESCAPED_OPTIONS = %i[unit separator delimiter].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def number_to_currency(number, **options)
|
|
18
|
+
number && ActiveSupport::NumberHelper.number_to_currency(number, html_escape_options(options))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def number_with_delimiter(number, **options)
|
|
22
|
+
number && ActiveSupport::NumberHelper.number_to_delimited(number, html_escape_options(options))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def pluralize(count, singular, plural: nil, locale: ::I18n.locale)
|
|
26
|
+
word = count == 1 || count.to_s.match?(/^1(\.0+)?$/) ? singular : plural || singular.pluralize(locale)
|
|
27
|
+
"#{count || 0} #{word}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def html_escape_options(options)
|
|
31
|
+
options.to_h { |key, value| [key, HTML_ESCAPED_OPTIONS.include?(key) && value ? ERB::Util.html_escape(value) : value] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
locale_files = Dir[File.expand_path('../../db/data/locales/*.yml', __dir__)]
|
|
38
|
+
I18n.load_path += locale_files
|
|
39
|
+
I18n.available_locales = locale_files.map { |path| File.basename(path, '.yml') }
|
|
40
|
+
I18n.backend.class.include I18n::Backend::Fallbacks
|
|
41
|
+
I18n.fallbacks = I18n::Locale::Fallbacks.new(:en)
|
data/lib/trmnlp/renderer.rb
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'erb'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
4
6
|
require 'trmnl/liquid'
|
|
5
7
|
|
|
6
8
|
require_relative 'screen'
|
|
7
9
|
|
|
8
10
|
module TRMNLP
|
|
9
11
|
class Renderer
|
|
12
|
+
# TRMNL refuses to render merge variables past this size.
|
|
13
|
+
MAX_MERGE_VARIABLES_KB = 100
|
|
14
|
+
AND_X_MORE = YAML.load_file(File.expand_path('../../db/data/and_x_more.yml', __dir__)).freeze
|
|
15
|
+
|
|
10
16
|
def initialize(config:, paths:, user_data_assembler:)
|
|
11
17
|
@config = config
|
|
12
18
|
@paths = paths
|
|
@@ -23,10 +29,27 @@ module TRMNLP
|
|
|
23
29
|
|
|
24
30
|
def framework = config.plugin.framework_version
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
# The data the last page was rendered from, for a test to read back.
|
|
33
|
+
attr_reader :last_data
|
|
34
|
+
|
|
35
|
+
def screen_classes(classes = 'screen', theme: nil)
|
|
36
|
+
classes = (classes || 'screen').split # an explicit nil (omitted screen_classes param) still needs a base
|
|
37
|
+
# The picker marks every render 1x, which sets the dither ratio; TRMNL's device render has no such class.
|
|
38
|
+
classes.delete('screen--1x')
|
|
39
|
+
classes << 'screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
|
|
40
|
+
classes << 'screen--dark-mode' if config.plugin.dark_mode == 'yes'
|
|
41
|
+
# Framework 1.x inverts only on the bare dark-mode class.
|
|
42
|
+
classes << 'dark-mode' if classes.include?('screen--dark-mode')
|
|
43
|
+
# A new device renders in the TRMNL font unless its owner picks another.
|
|
44
|
+
classes << 'screen--fonts-trmnl' if classes.none? { it.start_with?('screen--fonts-') }
|
|
45
|
+
classes << "screen--theme-#{theme}" if FrameworkVersion::THEMES.key?(theme)
|
|
46
|
+
classes.uniq.join(' ')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# TRMNL's window.I18n.andXMore phrase for the user's locale, as [prefix, suffix].
|
|
50
|
+
def and_x_more
|
|
51
|
+
locale = config.project.user_data_overrides.dig('trmnl', 'user', 'locale').to_s
|
|
52
|
+
AND_X_MORE[locale] || AND_X_MORE[locale.split('-').first] || AND_X_MORE['en']
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
private
|
|
@@ -46,8 +69,15 @@ module TRMNLP
|
|
|
46
69
|
end
|
|
47
70
|
|
|
48
71
|
def parse_and_render(template_path, device:)
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
data = @last_data = user_data_assembler.call(device:)
|
|
73
|
+
size = JSON.generate(data.except('trmnl')).bytesize
|
|
74
|
+
if size > MAX_MERGE_VARIABLES_KB * 1024
|
|
75
|
+
raise RenderError, "Large payload received (#{size} bytes), should be less than #{MAX_MERGE_VARIABLES_KB}kb."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Liquid::Template.parse(full_markup(template_path), environment: liquid_environment).render(data)
|
|
79
|
+
rescue RenderError
|
|
80
|
+
raise
|
|
51
81
|
rescue StandardError => e
|
|
52
82
|
raise RenderError, e.message
|
|
53
83
|
end
|
|
@@ -65,10 +95,7 @@ module TRMNLP
|
|
|
65
95
|
|
|
66
96
|
def liquid_environment
|
|
67
97
|
@liquid_environment ||= TRMNL::Liquid.new do |env|
|
|
68
|
-
config.project.
|
|
69
|
-
require paths.root_dir.join(relative_path)
|
|
70
|
-
env.register_filter(Object.const_get(module_name))
|
|
71
|
-
end
|
|
98
|
+
config.project.user_filter_modules.each { |filter_module| env.register_filter(filter_module) }
|
|
72
99
|
end
|
|
73
100
|
end
|
|
74
101
|
|
|
@@ -76,8 +103,10 @@ module TRMNLP
|
|
|
76
103
|
class TemplateBinding
|
|
77
104
|
def initialize(renderer, view, params)
|
|
78
105
|
@view = view
|
|
79
|
-
@screen_classes = renderer.screen_classes(params[:screen_classes])
|
|
106
|
+
@screen_classes = renderer.screen_classes(params[:screen_classes], theme: params[:theme])
|
|
80
107
|
@framework = renderer.framework
|
|
108
|
+
@theme_css_url = @framework.theme_css_url(params[:theme])
|
|
109
|
+
@and_x_more = renderer.and_x_more
|
|
81
110
|
@mashup_classes = Screen.find(view)&.mashup_classes
|
|
82
111
|
end
|
|
83
112
|
|
|
@@ -5,6 +5,17 @@ require_relative 'image_quantizer'
|
|
|
5
5
|
|
|
6
6
|
module TRMNLP
|
|
7
7
|
class ScreenGenerator
|
|
8
|
+
# TRMNL's own copies (Converter::Preprocessor); the CDN refuses a page with no Referer.
|
|
9
|
+
CHART_LIBRARIES = {
|
|
10
|
+
'https://code.highcharts.com/highcharts.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts.js',
|
|
11
|
+
'https://code.highcharts.com/12.3.0/highcharts.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts.js',
|
|
12
|
+
'https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js' => 'https://trmnl.com/js/chartkick/5.0.1/chartkick.min.js',
|
|
13
|
+
'https://code.highcharts.com/highcharts-more.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts-more.js',
|
|
14
|
+
'https://code.highcharts.com/12.3.0/highcharts-more.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts-more.js',
|
|
15
|
+
'https://code.highcharts.com/modules/pattern-fill.js' => 'https://trmnl.com/js/highcharts/12.3.0/pattern-fill.js',
|
|
16
|
+
'https://code.highcharts.com/12.3.0/modules/pattern-fill.js' => 'https://trmnl.com/js/highcharts/12.3.0/pattern-fill.js'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
8
19
|
def initialize(html, opts = {})
|
|
9
20
|
@input = html
|
|
10
21
|
@screenshot = opts[:screenshot]
|
|
@@ -14,8 +25,9 @@ module TRMNLP
|
|
|
14
25
|
end
|
|
15
26
|
|
|
16
27
|
def process
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
|
|
29
|
+
output = @screenshot.call(html:, width:, height:)
|
|
30
|
+
ImageQuantizer.new(depth: color_depth, dither: @input.include?('image-dither')).call(output.path)
|
|
19
31
|
output
|
|
20
32
|
end
|
|
21
33
|
|