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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -0
  4. data/db/data/and_x_more.yml +86 -0
  5. data/db/data/locales/cs.yml +339 -0
  6. data/db/data/locales/da.yml +339 -0
  7. data/db/data/locales/de-AT.yml +341 -0
  8. data/db/data/locales/de.yml +341 -0
  9. data/db/data/locales/en-AU.yml +79 -0
  10. data/db/data/locales/en-GB.yml +79 -0
  11. data/db/data/locales/en.yml +341 -0
  12. data/db/data/locales/es-ES.yml +341 -0
  13. data/db/data/locales/fr.yml +339 -0
  14. data/db/data/locales/he.yml +339 -0
  15. data/db/data/locales/hu.yml +339 -0
  16. data/db/data/locales/id.yml +339 -0
  17. data/db/data/locales/is.yml +339 -0
  18. data/db/data/locales/it.yml +341 -0
  19. data/db/data/locales/ja.yml +342 -0
  20. data/db/data/locales/ko.yml +337 -0
  21. data/db/data/locales/lt.yml +340 -0
  22. data/db/data/locales/nl.yml +339 -0
  23. data/db/data/locales/no.yml +4 -0
  24. data/db/data/locales/pl.yml +342 -0
  25. data/db/data/locales/pt-BR.yml +340 -0
  26. data/db/data/locales/raw.yml +256 -0
  27. data/db/data/locales/ro.yml +339 -0
  28. data/db/data/locales/ru.yml +342 -0
  29. data/db/data/locales/sk.yml +339 -0
  30. data/db/data/locales/sv.yml +340 -0
  31. data/db/data/locales/uk.yml +339 -0
  32. data/db/data/locales/zh-CN.yml +338 -0
  33. data/db/data/locales/zh-HK.yml +338 -0
  34. data/lib/trmnlp/app.rb +5 -4
  35. data/lib/trmnlp/async_callback.rb +75 -0
  36. data/lib/trmnlp/commands/serve.rb +3 -0
  37. data/lib/trmnlp/config/plugin.rb +52 -12
  38. data/lib/trmnlp/config/project.rb +14 -4
  39. data/lib/trmnlp/context.rb +17 -2
  40. data/lib/trmnlp/firefox_driver.rb +0 -3
  41. data/lib/trmnlp/framework_version.rb +9 -0
  42. data/lib/trmnlp/image_quantizer.rb +28 -15
  43. data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
  44. data/lib/trmnlp/lint/diagnostic.rb +5 -0
  45. data/lib/trmnlp/lint/source.rb +6 -0
  46. data/lib/trmnlp/lint.rb +3 -1
  47. data/lib/trmnlp/merged_plugin_locals.rb +57 -0
  48. data/lib/trmnlp/outbound_request.rb +99 -0
  49. data/lib/trmnlp/paths.rb +11 -6
  50. data/lib/trmnlp/poller.rb +83 -44
  51. data/lib/trmnlp/rails_helpers.rb +41 -0
  52. data/lib/trmnlp/renderer.rb +37 -11
  53. data/lib/trmnlp/screen_generator.rb +14 -2
  54. data/lib/trmnlp/screenshot.rb +29 -4
  55. data/lib/trmnlp/select_field_choices.rb +25 -0
  56. data/lib/trmnlp/transform_pipeline.rb +47 -6
  57. data/lib/trmnlp/transform_state.rb +48 -0
  58. data/lib/trmnlp/user_data_assembler.rb +63 -25
  59. data/lib/trmnlp/version.rb +1 -1
  60. data/lib/trmnlp/watcher.rb +2 -1
  61. data/lib/trmnlp/webhook_payload.rb +74 -0
  62. data/lib/trmnlp/webhook_receiver.rb +78 -0
  63. data/lib/trmnlp.rb +1 -1
  64. data/trmnl_preview.gemspec +1 -0
  65. data/web/public/index.js +18 -6
  66. data/web/views/index.erb +7 -0
  67. data/web/views/render_html.erb +36 -0
  68. 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 'faraday'
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
- def initialize(config:, paths:, oauth_session:, reporter: Reporter.new)
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
- reporter.info(reporter.yellow("warning: #{e.message}"))
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.status == 401 } && oauth_session.force_refresh!
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
- oauth_variables = oauth_session.liquid_variables
59
- urls = config.plugin.polling_urls(extra_variables: oauth_variables)
60
- urls.map { |url| [url, fetch_one(url, oauth_variables)] }
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, oauth_variables)
91
+ def fetch_one(url, headers, body)
64
92
  verb = config.plugin.polling_verb.upcase
65
- response = perform_request(url, verb, oauth_variables)
66
- reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
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 perform_request(url, verb, oauth_variables)
71
- conn = Faraday.new(url:, headers: config.plugin.polling_headers(extra_variables: oauth_variables))
72
- if verb == 'POST'
73
- conn.post { |req| req.body = config.plugin.polling_body(extra_variables: oauth_variables) }
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: an error status is reported, but its body still reaches the data.
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
- content_type = content_type_header&.split(';')&.first&.strip
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/html', 'text/plain' then sniff_json(body) || { 'data' => body }
92
- else log_unknown_type(content_type_header)
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
- def log_unknown_type(header)
97
- reporter.info("unknown content type received: #{header}")
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 wrap_array(json) = json.is_a?(Array) ? { data: json } : json
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
- def sniff_json(body)
104
- trimmed = body.to_s.strip
105
- return nil unless trimmed.start_with?('{', '[')
138
+ body.byteslice(BYTE_ORDER_MARK.bytesize..)
139
+ end
106
140
 
107
- wrap_array(JSON.parse(trimmed))
108
- rescue JSON::ParserError
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.create_cache_dir
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)
@@ -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 ||= 'screen' # an explicit nil (omitted screen_classes param) still needs a base
28
- classes += ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
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
- Liquid::Template.parse(full_markup(template_path), environment: liquid_environment)
50
- .render(user_data_assembler.call(device:))
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.user_filters.each do |module_name, relative_path|
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
- output = @screenshot.call(html: @input, width:, height:)
18
- ImageQuantizer.new(depth: color_depth).call(output.path)
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
 
@@ -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
- Selenium::WebDriver::Wait.new(timeout: 5).until do
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) ? transformed : wrap_array(transformed)
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
- def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
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