trmnl_preview 0.14.2 → 0.16.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 +21 -0
- data/README.md +31 -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/cli.rb +2 -0
- data/lib/trmnlp/commands/lint.rb +18 -3
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +14 -4
- data/lib/trmnlp/context.rb +17 -2
- 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/image_links_reachable.rb +6 -1
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +12 -5
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +97 -0
- data/lib/trmnlp/lint/source.rb +53 -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 +11 -6
- data/lib/trmnlp/poller.rb +83 -44
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +37 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +29 -4
- data/lib/trmnlp/select_field_choices.rb +25 -0
- data/lib/trmnlp/transform_pipeline.rb +47 -6
- data/lib/trmnlp/transform_state.rb +48 -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 +1 -1
- data/trmnl_preview.gemspec +3 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +36 -0
- metadata +83 -1
data/lib/trmnlp/lint/source.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'trmnl/liquid'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
3
6
|
module TRMNLP
|
|
4
7
|
module Lint
|
|
5
8
|
# The plugin data every lint check examines: markup per view, shared
|
|
@@ -23,6 +26,11 @@ module TRMNLP
|
|
|
23
26
|
def custom_field_values = config.project.custom_fields
|
|
24
27
|
def custom_field_definitions = config.plugin.custom_field_definitions
|
|
25
28
|
|
|
29
|
+
def local_only_filter_names
|
|
30
|
+
config.project.user_filter_modules.flat_map(&:public_instance_methods).map(&:to_s).uniq -
|
|
31
|
+
TRMNL::Liquid.new.filter_method_names
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
def view_markup
|
|
27
35
|
@view_markup ||= VIEWS.to_h { |view| [view, read(paths.template(view))] }
|
|
28
36
|
end
|
|
@@ -44,10 +52,55 @@ module TRMNLP
|
|
|
44
52
|
end
|
|
45
53
|
end
|
|
46
54
|
|
|
55
|
+
# Keep the original whitespace here: stripped templates cannot provide
|
|
56
|
+
# accurate line/column numbers. All paths in reports are project-relative.
|
|
57
|
+
def markup_files
|
|
58
|
+
@markup_files ||= (VIEWS + ['shared']).to_h do |view|
|
|
59
|
+
["src/#{view}.liquid", raw_read(paths.template(view))]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def locations(pattern, files: markup_files)
|
|
64
|
+
files.flat_map do |path, contents|
|
|
65
|
+
contents.to_enum(:scan, pattern).map do
|
|
66
|
+
offset = Regexp.last_match.begin(0)
|
|
67
|
+
prefix = contents[0...offset]
|
|
68
|
+
line = prefix.count("\n") + 1
|
|
69
|
+
{ path:, line:, column: offset - (prefix.rindex("\n") || -1),
|
|
70
|
+
snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
|
|
71
|
+
end
|
|
72
|
+
end.uniq
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def yaml_location(file, *keys)
|
|
76
|
+
path = file == '.trmnlp.yml' ? paths.trmnlp_config : paths.plugin_config
|
|
77
|
+
contents = raw_read(path)
|
|
78
|
+
node = Psych.parse_stream(contents).children.first&.root
|
|
79
|
+
keys.each do |key|
|
|
80
|
+
node = if key.is_a?(Integer) && node.is_a?(Psych::Nodes::Sequence)
|
|
81
|
+
node.children[key]
|
|
82
|
+
else
|
|
83
|
+
mapping_value(node, key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
return [] unless node
|
|
87
|
+
|
|
88
|
+
[{ path: file, line: node.start_line + 1, column: node.start_column + 1,
|
|
89
|
+
snippet: contents.lines[node.start_line].to_s.chomp[0, 240] }]
|
|
90
|
+
end
|
|
91
|
+
|
|
47
92
|
private
|
|
48
93
|
|
|
49
94
|
attr_reader :config, :paths
|
|
50
95
|
|
|
96
|
+
def raw_read(path) = path.exist? ? path.read : ''
|
|
97
|
+
|
|
98
|
+
def mapping_value(node, key)
|
|
99
|
+
return unless node.is_a?(Psych::Nodes::Mapping)
|
|
100
|
+
|
|
101
|
+
node.children.each_slice(2).to_a.rfind { |name, _| name.value == key.to_s }&.last
|
|
102
|
+
end
|
|
103
|
+
|
|
51
104
|
def read(path) = path.exist? ? path.read.strip : ''
|
|
52
105
|
end
|
|
53
106
|
end
|
data/lib/trmnlp/lint.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative 'lint/checks/highcharts_elements_unique'
|
|
|
18
18
|
require_relative 'lint/checks/image_links_reachable'
|
|
19
19
|
require_relative 'lint/checks/custom_fields_used'
|
|
20
20
|
require_relative 'lint/checks/form_fields_valid'
|
|
21
|
+
require_relative 'lint/checks/no_custom_filters'
|
|
21
22
|
|
|
22
23
|
module TRMNLP
|
|
23
24
|
# Markup best-practice checks behind `trmnlp lint`.
|
|
@@ -39,7 +40,8 @@ module TRMNLP
|
|
|
39
40
|
Checks::HighchartsElementsUnique,
|
|
40
41
|
Checks::ImageLinksReachable,
|
|
41
42
|
Checks::CustomFieldsUsed,
|
|
42
|
-
Checks::FormFieldsValid
|
|
43
|
+
Checks::FormFieldsValid,
|
|
44
|
+
Checks::NoCustomFilters
|
|
43
45
|
].freeze
|
|
44
46
|
end
|
|
45
47
|
end
|
|
@@ -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
|
@@ -8,7 +8,7 @@ module TRMNLP
|
|
|
8
8
|
attr_reader :root_dir
|
|
9
9
|
|
|
10
10
|
def initialize(root_dir)
|
|
11
|
-
@root_dir = Pathname.new(root_dir)
|
|
11
|
+
@root_dir = Pathname.new(root_dir).expand_path
|
|
12
12
|
@xdg = XDG.new
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -28,7 +28,6 @@ module TRMNLP
|
|
|
28
28
|
def app_config_dir = xdg.config_home.join('trmnlp')
|
|
29
29
|
|
|
30
30
|
def cache_dir = xdg.cache_home.join('trmnl')
|
|
31
|
-
def create_cache_dir = cache_dir.mkpath
|
|
32
31
|
|
|
33
32
|
def valid? = trmnlp_config.exist?
|
|
34
33
|
|
|
@@ -44,11 +43,15 @@ module TRMNLP
|
|
|
44
43
|
|
|
45
44
|
def app_config = app_config_dir.join('config.yml')
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
# Cached files are keyed per project root so working on two plugins does not
|
|
47
|
+
# clobber a shared file.
|
|
48
|
+
def user_data = cache_dir.join('data', "#{project_key}.json")
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def
|
|
50
|
+
def oauth_tokens = cache_dir.join('oauth', "#{project_key}.json")
|
|
51
|
+
|
|
52
|
+
def transform_state = cache_dir.join('state', "#{project_key}.json")
|
|
53
|
+
|
|
54
|
+
def transform_output = cache_dir.join('transform_output', "#{project_key}.json")
|
|
52
55
|
|
|
53
56
|
def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
|
|
54
57
|
|
|
@@ -79,5 +82,7 @@ module TRMNLP
|
|
|
79
82
|
private
|
|
80
83
|
|
|
81
84
|
attr_reader :xdg
|
|
85
|
+
|
|
86
|
+
def project_key = Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]
|
|
82
87
|
end
|
|
83
88
|
end
|
data/lib/trmnlp/poller.rb
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
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)
|
|
12
18
|
@config = config
|
|
13
19
|
@paths = paths
|
|
14
20
|
@oauth_session = oauth_session
|
|
21
|
+
@trmnl_variables = trmnl_variables
|
|
15
22
|
@reporter = reporter
|
|
23
|
+
@async_callback = async_callback
|
|
16
24
|
end
|
|
17
25
|
|
|
18
26
|
def poll_data
|
|
27
|
+
return request_async_data if config.plugin.async_polling?
|
|
19
28
|
return unless config.plugin.polling?
|
|
29
|
+
|
|
30
|
+
missing = config.plugin.missing_required_fields
|
|
31
|
+
return report_warning("Plugin is not configured — fill in: #{missing.join(', ')}") if missing.any?
|
|
20
32
|
raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
|
|
21
33
|
|
|
22
34
|
data = aggregate_responses
|
|
@@ -26,27 +38,42 @@ module TRMNLP
|
|
|
26
38
|
# and keep the preview server alive, not crash the user's session. We
|
|
27
39
|
# deliberately swallow here and return {} so the renderer keeps rendering.
|
|
28
40
|
rescue StandardError => e
|
|
29
|
-
|
|
41
|
+
report_warning(e.message)
|
|
30
42
|
{}
|
|
31
43
|
end
|
|
32
44
|
|
|
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
45
|
private
|
|
42
46
|
|
|
43
|
-
attr_reader :config, :paths, :oauth_session, :reporter
|
|
47
|
+
attr_reader :config, :paths, :oauth_session, :trmnl_variables, :reporter, :async_callback
|
|
48
|
+
|
|
49
|
+
# Like TRMNL: the API answers 202 and posts the data to callback_url later; the last post renders meanwhile.
|
|
50
|
+
def request_async_data
|
|
51
|
+
unless async_callback&.server_url
|
|
52
|
+
return report_warning('async_polling needs `trmnlp serve` to receive the callback')
|
|
53
|
+
end
|
|
54
|
+
return report_warning('Waiting for the async callback; the last data posted renders') if async_callback.awaiting?
|
|
55
|
+
raise InvalidConfig, 'config must specify polling_url' if config.plugin.polling_urls.empty?
|
|
56
|
+
|
|
57
|
+
report_warning('Async callback not received within 15 minutes') if async_callback.expired?
|
|
58
|
+
response, url = send_async_request(async_callback.start)
|
|
59
|
+
return if response&.status == 202
|
|
60
|
+
|
|
61
|
+
async_callback.cancel
|
|
62
|
+
report_warning("Async polling failed: HTTP #{response.status} from #{without_query(url)}") if response
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def send_async_request(callback_url)
|
|
66
|
+
variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
|
|
67
|
+
url = config.plugin.polling_urls(extra_variables: variables.merge('callback_url' => callback_url)).first
|
|
68
|
+
headers = config.plugin.polling_headers(extra_variables: variables)
|
|
69
|
+
[fetch_one(url, headers, config.plugin.polling_body(extra_variables: variables)), url]
|
|
70
|
+
end
|
|
44
71
|
|
|
45
72
|
def aggregate_responses
|
|
46
73
|
fetched = fetch_all
|
|
47
74
|
# 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) }
|
|
75
|
+
fetched = fetch_all if fetched.any? { |_url, response| response&.status == 401 } && oauth_session.force_refresh!
|
|
76
|
+
responses = fetched.map { |url, response| response ? parse_response(response, url) : {} }
|
|
50
77
|
return responses.first if responses.size == 1
|
|
51
78
|
|
|
52
79
|
responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
|
|
@@ -55,62 +82,74 @@ module TRMNLP
|
|
|
55
82
|
def fetch_all
|
|
56
83
|
# Resolve once per attempt (it refreshes as a side effect), then share across
|
|
57
84
|
# every URL, header, and body render.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
|
|
86
|
+
headers = config.plugin.polling_headers(extra_variables: variables)
|
|
87
|
+
body = config.plugin.polling_body(extra_variables: variables)
|
|
88
|
+
config.plugin.polling_urls(extra_variables: variables).map { |url| [url, fetch_one(url, headers, body)] }
|
|
61
89
|
end
|
|
62
90
|
|
|
63
|
-
def fetch_one(url,
|
|
91
|
+
def fetch_one(url, headers, body)
|
|
64
92
|
verb = config.plugin.polling_verb.upcase
|
|
65
|
-
response =
|
|
66
|
-
|
|
93
|
+
response, failure = outbound_request.call(verb, url, headers:, body:)
|
|
94
|
+
return report_warning("Unable to fetch data from url: #{without_query(url)} — #{failure}") if failure
|
|
95
|
+
|
|
96
|
+
size = response.body.length
|
|
97
|
+
reporter.info("#{verb} #{without_query(url)} — received #{size} bytes (#{response.status} status)")
|
|
67
98
|
response
|
|
68
99
|
end
|
|
69
100
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
else
|
|
75
|
-
conn.get
|
|
76
|
-
end
|
|
101
|
+
def outbound_request
|
|
102
|
+
OutboundRequest.new(on_private_address: lambda { |url|
|
|
103
|
+
report_warning("TRMNL refuses #{without_query(url)}: the url resolves to a private address")
|
|
104
|
+
})
|
|
77
105
|
end
|
|
78
106
|
|
|
79
|
-
# Like the hosted service:
|
|
107
|
+
# Like the hosted service: a client error is reported, but its body still reaches the data.
|
|
80
108
|
def parse_response(response, url)
|
|
81
109
|
success = (200..299).cover?(response.status)
|
|
82
110
|
reporter.info(reporter.yellow("warning: HTTP #{response.status} from #{url}")) unless success
|
|
83
|
-
parse_body(response.body, response.headers['content-type'])
|
|
111
|
+
parse_body(without_byte_order_mark(response.body.to_s), response.headers['content-type'])
|
|
112
|
+
rescue JSON::ParserError, CSV::MalformedCSVError => e
|
|
113
|
+
report_warning("Malformed #{e.is_a?(CSV::MalformedCSVError) ? 'CSV' : 'JSON'} from url: #{url}")
|
|
114
|
+
{}
|
|
84
115
|
end
|
|
85
116
|
|
|
117
|
+
# Unlisted and missing content types are read as JSON, as TRMNL does.
|
|
86
118
|
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))
|
|
119
|
+
case content_type_header&.split(';')&.first&.strip
|
|
90
120
|
when 'text/xml', 'application/xml', %r{^application/.+\+xml} then wrap_array(Hash.from_xml(body))
|
|
91
|
-
when 'text/
|
|
92
|
-
|
|
121
|
+
when 'text/csv' then wrap_array(CSV.parse(body))
|
|
122
|
+
when 'text/plain', 'text/html', 'text/markdown', 'text/x-markdown' then parse_textual_body(body)
|
|
123
|
+
else wrap_array(JSON.parse(body))
|
|
93
124
|
end
|
|
94
125
|
end
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
# Some endpoints serve JSON under a textual content type; anything else passes through under `data`.
|
|
128
|
+
def parse_textual_body(body)
|
|
129
|
+
parsed = JSON.parse(body)
|
|
130
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? wrap_array(parsed) : { 'data' => body }
|
|
131
|
+
rescue JSON::ParserError
|
|
132
|
+
{ 'data' => body }
|
|
99
133
|
end
|
|
100
134
|
|
|
101
|
-
def
|
|
135
|
+
def without_byte_order_mark(body)
|
|
136
|
+
return body unless body.byteslice(0, BYTE_ORDER_MARK.bytesize)&.b == BYTE_ORDER_MARK
|
|
102
137
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return nil unless trimmed.start_with?('{', '[')
|
|
138
|
+
body.byteslice(BYTE_ORDER_MARK.bytesize..)
|
|
139
|
+
end
|
|
106
140
|
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
# A polling url often carries its api key in the query string.
|
|
142
|
+
def without_query(url) = url.split('?').first
|
|
143
|
+
|
|
144
|
+
def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
|
|
145
|
+
|
|
146
|
+
def report_warning(message)
|
|
147
|
+
reporter.info(reporter.yellow("warning: #{message}"))
|
|
109
148
|
nil
|
|
110
149
|
end
|
|
111
150
|
|
|
112
151
|
def write_user_data(data)
|
|
113
|
-
paths.
|
|
152
|
+
paths.user_data.dirname.mkpath
|
|
114
153
|
paths.user_data.write(JSON.generate(data))
|
|
115
154
|
end
|
|
116
155
|
end
|
|
@@ -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,24 @@ module TRMNLP
|
|
|
23
29
|
|
|
24
30
|
def framework = config.plugin.framework_version
|
|
25
31
|
|
|
26
|
-
def screen_classes(classes = 'screen')
|
|
27
|
-
classes
|
|
28
|
-
|
|
29
|
-
classes
|
|
32
|
+
def screen_classes(classes = 'screen', theme: nil)
|
|
33
|
+
classes = (classes || 'screen').split # an explicit nil (omitted screen_classes param) still needs a base
|
|
34
|
+
# The picker marks every render 1x, which sets the dither ratio; TRMNL's device render has no such class.
|
|
35
|
+
classes.delete('screen--1x')
|
|
36
|
+
classes << 'screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
|
|
37
|
+
classes << 'screen--dark-mode' if config.plugin.dark_mode == 'yes'
|
|
38
|
+
# Framework 1.x inverts only on the bare dark-mode class.
|
|
39
|
+
classes << 'dark-mode' if classes.include?('screen--dark-mode')
|
|
40
|
+
# A new device renders in the TRMNL font unless its owner picks another.
|
|
41
|
+
classes << 'screen--fonts-trmnl' if classes.none? { it.start_with?('screen--fonts-') }
|
|
42
|
+
classes << "screen--theme-#{theme}" if FrameworkVersion::THEMES.key?(theme)
|
|
43
|
+
classes.uniq.join(' ')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# TRMNL's window.I18n.andXMore phrase for the user's locale, as [prefix, suffix].
|
|
47
|
+
def and_x_more
|
|
48
|
+
locale = config.project.user_data_overrides.dig('trmnl', 'user', 'locale').to_s
|
|
49
|
+
AND_X_MORE[locale] || AND_X_MORE[locale.split('-').first] || AND_X_MORE['en']
|
|
30
50
|
end
|
|
31
51
|
|
|
32
52
|
private
|
|
@@ -46,8 +66,15 @@ module TRMNLP
|
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
def parse_and_render(template_path, device:)
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
data = user_data_assembler.call(device:)
|
|
70
|
+
size = JSON.generate(data.except('trmnl')).bytesize
|
|
71
|
+
if size > MAX_MERGE_VARIABLES_KB * 1024
|
|
72
|
+
raise RenderError, "Large payload received (#{size} bytes), should be less than #{MAX_MERGE_VARIABLES_KB}kb."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Liquid::Template.parse(full_markup(template_path), environment: liquid_environment).render(data)
|
|
76
|
+
rescue RenderError
|
|
77
|
+
raise
|
|
51
78
|
rescue StandardError => e
|
|
52
79
|
raise RenderError, e.message
|
|
53
80
|
end
|
|
@@ -65,10 +92,7 @@ module TRMNLP
|
|
|
65
92
|
|
|
66
93
|
def liquid_environment
|
|
67
94
|
@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
|
|
95
|
+
config.project.user_filter_modules.each { |filter_module| env.register_filter(filter_module) }
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
98
|
|
|
@@ -76,8 +100,10 @@ module TRMNLP
|
|
|
76
100
|
class TemplateBinding
|
|
77
101
|
def initialize(renderer, view, params)
|
|
78
102
|
@view = view
|
|
79
|
-
@screen_classes = renderer.screen_classes(params[:screen_classes])
|
|
103
|
+
@screen_classes = renderer.screen_classes(params[:screen_classes], theme: params[:theme])
|
|
80
104
|
@framework = renderer.framework
|
|
105
|
+
@theme_css_url = @framework.theme_css_url(params[:theme])
|
|
106
|
+
@and_x_more = renderer.and_x_more
|
|
81
107
|
@mashup_classes = Screen.find(view)&.mashup_classes
|
|
82
108
|
end
|
|
83
109
|
|
|
@@ -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
|
|