trmnl_preview 0.15.0 → 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 +16 -0
- data/README.md +11 -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/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/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 +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 +1 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +36 -0
- metadata +54 -1
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
|
|
data/lib/trmnlp/screenshot.rb
CHANGED
|
@@ -7,6 +7,26 @@ require_relative 'errors'
|
|
|
7
7
|
|
|
8
8
|
module TRMNLP
|
|
9
9
|
class Screenshot
|
|
10
|
+
# TRMNL's Converter::Html waits for these flags, longer for a map, then captures what is drawn.
|
|
11
|
+
READINESS_TIMEOUT_SECONDS = 5
|
|
12
|
+
READINESS_TIMEOUT_WITH_MAPS_SECONDS = 12
|
|
13
|
+
MAPS_DOCUMENT_PATTERN = %r{TRMNLMaps\.[a-z]|maplibre-gl(?:\.js|\.css|[/@]\d)}
|
|
14
|
+
READINESS_CHECK_SCRIPT = <<~JS
|
|
15
|
+
return document.readyState === 'complete' && window.TRMNL_PLUGINS_READY === true &&
|
|
16
|
+
window.TRMNL_HIGHCHARTS_DONE === true && window.TRMNL_CHILD_DITHER_DONE !== false
|
|
17
|
+
JS
|
|
18
|
+
# Stops timers and animation callbacks from changing the page mid-capture.
|
|
19
|
+
FREEZE_TIMERS = <<~JS
|
|
20
|
+
const noop = () => 0;
|
|
21
|
+
window.setTimeout = window.setInterval = noop;
|
|
22
|
+
window.requestAnimationFrame = noop;
|
|
23
|
+
if (window.requestIdleCallback) window.requestIdleCallback = noop;
|
|
24
|
+
for (let i = 100000; i >= 0; i--) { window.clearTimeout(i); window.clearInterval(i); }
|
|
25
|
+
if (window.ResizeObserver) window.ResizeObserver.prototype.observe = noop;
|
|
26
|
+
if (window.MutationObserver) window.MutationObserver.prototype.observe = noop;
|
|
27
|
+
window.onresize = null;
|
|
28
|
+
JS
|
|
29
|
+
|
|
10
30
|
def initialize(pool:, viewport_timeout: 5)
|
|
11
31
|
@pool = pool
|
|
12
32
|
@viewport_timeout = viewport_timeout
|
|
@@ -87,16 +107,21 @@ module TRMNLP
|
|
|
87
107
|
document.close();
|
|
88
108
|
JS
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
driver.execute_script('return document.readyState') == 'complete'
|
|
92
|
-
end
|
|
93
|
-
|
|
110
|
+
wait_until_ready(driver, html)
|
|
94
111
|
driver.execute_script('return document.fonts && document.fonts.ready')
|
|
95
112
|
|
|
96
113
|
driver.execute_script(<<~JS)
|
|
97
114
|
document.documentElement.style.overflow = 'hidden';
|
|
98
115
|
document.body.style.overflow = 'hidden';
|
|
99
116
|
JS
|
|
117
|
+
driver.execute_script(FREEZE_TIMERS)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def wait_until_ready(driver, html)
|
|
121
|
+
timeout = html.match?(MAPS_DOCUMENT_PATTERN) ? READINESS_TIMEOUT_WITH_MAPS_SECONDS : READINESS_TIMEOUT_SECONDS
|
|
122
|
+
Selenium::WebDriver::Wait.new(timeout:, interval: 0.05).until { driver.execute_script(READINESS_CHECK_SCRIPT) }
|
|
123
|
+
rescue Selenium::WebDriver::Error::TimeoutError
|
|
124
|
+
nil
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
def capture(driver)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TRMNLP
|
|
4
|
+
# A select field's options as TRMNL saves them: "New York" as new_york, { Label: value } as its value.
|
|
5
|
+
class SelectFieldChoices
|
|
6
|
+
def initialize(field)
|
|
7
|
+
options = field['options']
|
|
8
|
+
@options = options.is_a?(Array) ? options.compact : []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pairs
|
|
12
|
+
@pairs ||= @options.map do |option|
|
|
13
|
+
option.is_a?(Hash) ? [option.keys.first, option.values.first] : [option, option.to_s.downcase.tr(' ', '_')]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A label becomes its value; a value, or anything the options do not list, is kept as it is.
|
|
18
|
+
def stored_value(input)
|
|
19
|
+
return input.map { stored_value(it) } if input.is_a?(Array)
|
|
20
|
+
return input if input.to_s.empty? || pairs.any? { |_, value| value.to_s == input.to_s }
|
|
21
|
+
|
|
22
|
+
pairs.find { |label, _| label.to_s == input.to_s }&.last&.to_s || input
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
|
|
5
5
|
require_relative 'reporter'
|
|
6
6
|
require_relative 'transform_client'
|
|
7
|
+
require_relative 'transform_state'
|
|
7
8
|
|
|
8
9
|
module TRMNLP
|
|
9
10
|
# Pipes assembled merge_variables through src/transform.{py,rb,php,js}
|
|
@@ -13,12 +14,16 @@ module TRMNLP
|
|
|
13
14
|
# Failure modes surface via #error (rendered in the preview UI), not
|
|
14
15
|
# raised.
|
|
15
16
|
class TransformPipeline
|
|
17
|
+
# TRMNL's serverless runtime stops a transform after this long.
|
|
18
|
+
EXECUTION_TIMEOUT_SECONDS = 5
|
|
19
|
+
|
|
16
20
|
attr_reader :error
|
|
17
21
|
|
|
18
22
|
def initialize(config:, paths:, reporter: Reporter.new)
|
|
19
23
|
@config = config
|
|
20
24
|
@paths = paths
|
|
21
25
|
@reporter = reporter
|
|
26
|
+
@transform_state = TransformState.new(paths:, reporter:)
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def call(data)
|
|
@@ -29,17 +34,27 @@ module TRMNLP
|
|
|
29
34
|
run(transform_path, inferred_language, data)
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
def configured? = !paths.transform_file.first.nil? && !client.nil?
|
|
38
|
+
|
|
39
|
+
# What the last run stored for the markup, passed to the next run as trmnl.previous_merge_variables.
|
|
40
|
+
def previous_output
|
|
41
|
+
paths.transform_output.exist? ? JSON.parse(paths.transform_output.read) : {}
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
{}
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
def reset! = @client = nil
|
|
33
47
|
|
|
34
48
|
private
|
|
35
49
|
|
|
36
|
-
attr_reader :config, :paths, :reporter
|
|
50
|
+
attr_reader :config, :paths, :reporter, :transform_state
|
|
37
51
|
|
|
38
52
|
def client = @client ||= TransformClient.from_config(config.project)
|
|
39
53
|
|
|
40
54
|
def run(path, inferred_language, data)
|
|
41
55
|
language = config.plugin.serverless_language || inferred_language
|
|
42
|
-
result = client.execute(code: path.read, stdin: JSON.generate(data), language
|
|
56
|
+
result = client.execute(code: path.read, stdin: JSON.generate(data), language:,
|
|
57
|
+
timeout_seconds: EXECUTION_TIMEOUT_SECONDS)
|
|
43
58
|
report_printed_output(result)
|
|
44
59
|
return record_failure(result, data) unless result.success?
|
|
45
60
|
|
|
@@ -56,18 +71,44 @@ module TRMNLP
|
|
|
56
71
|
def record_failure(result, fallback)
|
|
57
72
|
@error = result.error || "transform exited #{result.exit_code}: #{result.stderr.strip}"
|
|
58
73
|
reporter.info("transform failed: #{result.error || "exited #{result.exit_code}"}")
|
|
59
|
-
fallback
|
|
74
|
+
last_good_output(fallback)
|
|
60
75
|
end
|
|
61
76
|
|
|
77
|
+
# TRMNL keeps the last good screen when a transform fails.
|
|
78
|
+
def last_good_output(fallback) = paths.transform_output.exist? ? previous_output : fallback
|
|
79
|
+
|
|
62
80
|
def parse_output(output, fallback)
|
|
63
81
|
transformed = JSON.parse(output)
|
|
64
|
-
transformed.is_a?(Hash)
|
|
82
|
+
return reject_non_object_output unless transformed.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
transform_state.extract!(transformed)
|
|
85
|
+
store_output(without_previous_merge_variables(transformed))
|
|
65
86
|
rescue JSON::ParserError => e
|
|
66
87
|
@error = "transform produced non-JSON output: #{e.message}"
|
|
67
88
|
reporter.info(@error)
|
|
68
|
-
fallback
|
|
89
|
+
last_good_output(fallback)
|
|
69
90
|
end
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
# TRMNL renders nothing from an array or a scalar, and refuses one from a webhook post.
|
|
93
|
+
def reject_non_object_output
|
|
94
|
+
@error = 'Transform output must be a JSON object'
|
|
95
|
+
reporter.info(@error)
|
|
96
|
+
{}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def store_output(output)
|
|
100
|
+
paths.transform_output.dirname.mkpath
|
|
101
|
+
paths.transform_output.write(JSON.generate(output))
|
|
102
|
+
output
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A transform that echoes its input at any depth (`{ data: input }`) would store its history and double every run.
|
|
106
|
+
def without_previous_merge_variables(value)
|
|
107
|
+
case value
|
|
108
|
+
when Hash then value.except('previous_merge_variables').transform_values { without_previous_merge_variables(it) }
|
|
109
|
+
when Array then value.map { without_previous_merge_variables(it) }
|
|
110
|
+
else value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
72
113
|
end
|
|
73
114
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
require_relative 'reporter'
|
|
6
|
+
|
|
7
|
+
module TRMNLP
|
|
8
|
+
# The `trmnl_state` key a transform returns is kept for its next run as trmnl.state instead of rendered.
|
|
9
|
+
class TransformState
|
|
10
|
+
KEY = 'trmnl_state'
|
|
11
|
+
MAX_BYTES = 8 * 1024
|
|
12
|
+
|
|
13
|
+
def initialize(paths:, reporter: Reporter.new)
|
|
14
|
+
@paths = paths
|
|
15
|
+
@reporter = reporter
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read
|
|
19
|
+
paths.transform_state.exist? ? JSON.parse(paths.transform_state.read) : {}
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def extract!(output)
|
|
25
|
+
return unless output.is_a?(Hash) && output.key?(KEY)
|
|
26
|
+
|
|
27
|
+
state = output.delete(KEY)
|
|
28
|
+
rejection = rejection_reason(state)
|
|
29
|
+
rejection ? reporter.info(reporter.yellow("Ignored #{KEY}: #{rejection}")) : write(state)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :paths, :reporter
|
|
35
|
+
|
|
36
|
+
def rejection_reason(state)
|
|
37
|
+
return "expected an object, got #{state.class}" unless state.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
size = JSON.generate(state).bytesize
|
|
40
|
+
"#{size} bytes exceeds the #{MAX_BYTES} byte limit" if size > MAX_BYTES
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def write(state)
|
|
44
|
+
paths.transform_state.dirname.mkpath
|
|
45
|
+
paths.transform_state.write(JSON.generate(state))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|