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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +48 -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/browser_pool.rb +8 -8
  37. data/lib/trmnlp/cli.rb +7 -0
  38. data/lib/trmnlp/commands/serve.rb +3 -0
  39. data/lib/trmnlp/commands/test.rb +25 -0
  40. data/lib/trmnlp/config/plugin.rb +52 -12
  41. data/lib/trmnlp/config/project.rb +23 -10
  42. data/lib/trmnlp/config.rb +2 -2
  43. data/lib/trmnlp/context.rb +29 -6
  44. data/lib/trmnlp/errors.rb +1 -0
  45. data/lib/trmnlp/firefox_driver.rb +0 -3
  46. data/lib/trmnlp/framework_version.rb +9 -0
  47. data/lib/trmnlp/image_quantizer.rb +28 -15
  48. data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
  49. data/lib/trmnlp/lint/diagnostic.rb +5 -0
  50. data/lib/trmnlp/lint/source.rb +6 -0
  51. data/lib/trmnlp/lint.rb +3 -1
  52. data/lib/trmnlp/merged_plugin_locals.rb +57 -0
  53. data/lib/trmnlp/outbound_request.rb +99 -0
  54. data/lib/trmnlp/paths.rb +15 -8
  55. data/lib/trmnlp/poller.rb +100 -47
  56. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  57. data/lib/trmnlp/rails_helpers.rb +41 -0
  58. data/lib/trmnlp/renderer.rb +40 -11
  59. data/lib/trmnlp/screen_generator.rb +14 -2
  60. data/lib/trmnlp/screenshot.rb +50 -11
  61. data/lib/trmnlp/select_field_choices.rb +25 -0
  62. data/lib/trmnlp/testing/browser.rb +87 -0
  63. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  64. data/lib/trmnlp/testing/device_models.rb +80 -0
  65. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  66. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  67. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  68. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  69. data/lib/trmnlp/testing/mock_table.rb +102 -0
  70. data/lib/trmnlp/testing/node_version.rb +27 -0
  71. data/lib/trmnlp/testing/plugin.rb +91 -0
  72. data/lib/trmnlp/testing/report.html.erb +79 -0
  73. data/lib/trmnlp/testing/report.rb +91 -0
  74. data/lib/trmnlp/testing/rspec.rb +89 -0
  75. data/lib/trmnlp/testing/run.rb +150 -0
  76. data/lib/trmnlp/testing/screen.rb +83 -0
  77. data/lib/trmnlp/testing/snapshot.rb +55 -0
  78. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  79. data/lib/trmnlp/transform_pipeline.rb +51 -9
  80. data/lib/trmnlp/transform_state.rb +51 -0
  81. data/lib/trmnlp/user_data_assembler.rb +63 -25
  82. data/lib/trmnlp/version.rb +1 -1
  83. data/lib/trmnlp/watcher.rb +2 -1
  84. data/lib/trmnlp/webhook_payload.rb +74 -0
  85. data/lib/trmnlp/webhook_receiver.rb +78 -0
  86. data/lib/trmnlp.rb +2 -1
  87. data/templates/init/.github/workflows/trmnl.yml +33 -2
  88. data/templates/init/.gitignore +3 -0
  89. data/templates/init/tests/plugin_spec.rb +32 -0
  90. data/trmnl_preview.gemspec +6 -0
  91. data/web/public/index.js +18 -6
  92. data/web/views/index.erb +7 -0
  93. data/web/views/render_html.erb +37 -1
  94. metadata +115 -1
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara'
4
+ require 'delegate'
5
+ require 'nokogiri'
6
+
7
+ module TRMNLP
8
+ module Testing
9
+ # A rendered view, as Firefox drew it. Capybara's finders and matchers (have_text, have_css, within...)
10
+ # work on the DOM after the Framework's scripts ran; #box, #evaluate and #overflowing ask the live page.
11
+ class Screen < SimpleDelegator
12
+ Box = Data.define(:left, :top, :right, :bottom, :width, :height)
13
+
14
+ # Every element drawn past the screen's edge or cut off inside a box that hides its overflow.
15
+ OVERFLOW_SCRIPT = <<~JS
16
+ const screen = document.documentElement.getBoundingClientRect();
17
+ const describe = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
18
+ (el.classList.length ? '.' + [...el.classList].join('.') : '');
19
+ return [...document.querySelectorAll('.view *')].filter((el) => {
20
+ const box = el.getBoundingClientRect();
21
+ if (box.width === 0 && box.height === 0) return false;
22
+ const outside = box.right > screen.right + 1 || box.bottom > screen.bottom + 1 || box.left < -1 || box.top < -1;
23
+ const style = getComputedStyle(el);
24
+ const clips = ['hidden', 'clip'].includes(style.overflowX) || ['hidden', 'clip'].includes(style.overflowY);
25
+ const cut = clips && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1);
26
+ return outside || cut;
27
+ }).map(describe);
28
+ JS
29
+
30
+ HIDDEN_MARK = 'data-trmnlp-hidden'
31
+ # The DOM with every element Firefox does not draw marked, so Capybara's visible text is what is on screen.
32
+ DRAWN_DOM_SCRIPT = <<~JS.freeze
33
+ const hidden = [...document.querySelectorAll('body *')]
34
+ .filter((el) => !el.checkVisibility({ opacityProperty: true, visibilityProperty: true }));
35
+ hidden.forEach((el) => el.setAttribute('#{HIDDEN_MARK}', ''));
36
+ const copy = document.documentElement.cloneNode(true);
37
+ hidden.forEach((el) => el.removeAttribute('#{HIDDEN_MARK}'));
38
+ copy.querySelectorAll('script, style, noscript, template').forEach((el) => el.remove());
39
+ return copy.outerHTML;
40
+ JS
41
+
42
+ attr_reader :html, :device, :view, :png_path, :result, :wait_for, :wait_for_timeout
43
+
44
+ # rubocop:disable-next Metrics/ParameterLists -- what a render produced and how to wait for it
45
+ def initialize(html:, device:, view:, browser:, result:, wait_for: nil, wait_for_timeout: 5)
46
+ @html = html
47
+ @wait_for = wait_for
48
+ @wait_for_timeout = wait_for_timeout
49
+ @device = device
50
+ @view = view
51
+ @browser = browser
52
+ @result = result
53
+ @png_path = browser.capture(self)
54
+ super(Capybara.string(drawn_dom(browser.on(self) { it.execute_script(DRAWN_DOM_SCRIPT) })))
55
+ end
56
+
57
+ def box(selector)
58
+ rect = evaluate("(() => { const el = document.querySelector(#{selector.to_json}); " \
59
+ 'return el && el.getBoundingClientRect().toJSON(); })()')
60
+ raise TestingError, "No element matches #{selector.inspect} on the #{view} view" unless rect
61
+
62
+ Box.new(**rect.slice('left', 'top', 'right', 'bottom', 'width', 'height').transform_keys(&:to_sym))
63
+ end
64
+
65
+ def evaluate(expression) = @browser.on(self) { it.execute_script("return (#{expression});") }
66
+
67
+ def overflowing = @browser.on(self) { it.execute_script(OVERFLOW_SCRIPT) }
68
+
69
+ # Script errors, unhandled rejections, console.error lines and files that failed to load.
70
+ def problems = evaluate('window.__trmnlpProblems || []')
71
+
72
+ def png_bytes = File.binread(png_path)
73
+
74
+ def drawn_dom(source)
75
+ document = Nokogiri::HTML(source)
76
+ document.css("[#{HIDDEN_MARK}]").each { it['hidden'] = 'hidden' }
77
+ document.to_html
78
+ end
79
+
80
+ def inspect = "#<#{self.class.name} #{device.name} #{view}>"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'mini_magick'
5
+ require 'rbconfig'
6
+
7
+ module TRMNLP
8
+ module Testing
9
+ # A screen's PNG compared with the one stored for it. Fonts render differently per operating system,
10
+ # so each keeps its own copy; run tests in the trmnlp Docker image for snapshots CI can share.
11
+ class Snapshot
12
+ UPDATE_ENV_KEY = 'TRMNLP_UPDATE_SNAPSHOTS'
13
+
14
+ attr_reader :path
15
+
16
+ def initialize(screen, name:, dir:)
17
+ @screen = screen
18
+ @path = File.join(dir, operating_system, "#{name}.png")
19
+ end
20
+
21
+ # nil when they match (or the stored copy was just written), otherwise why not.
22
+ def mismatch
23
+ return store if ENV[UPDATE_ENV_KEY] || (!File.exist?(path) && !ENV['CI'])
24
+ return "no snapshot at #{path}; run `trmnlp test --update` to create it" unless File.exist?(path)
25
+
26
+ differing = differing_pixels
27
+ "#{differing} pixels differ from #{path}; run `trmnlp test --update` if the change is intended" if differing
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :screen
33
+
34
+ def store
35
+ FileUtils.mkdir_p(File.dirname(path))
36
+ FileUtils.cp(screen.png_path, path)
37
+ nil
38
+ end
39
+
40
+ def differing_pixels
41
+ expected = MiniMagick::Image.open(path)
42
+ actual = MiniMagick::Image.open(screen.png_path)
43
+ return 'all' unless expected.dimensions == actual.dimensions
44
+
45
+ tool = MiniMagick::Tool.new('compare', errors: false)
46
+ tool << '-metric' << 'AE' << path << screen.png_path << 'null:'
47
+ count = nil
48
+ tool.call { |_stdout, stderr, _status| count = stderr.to_f.round }
49
+ count.positive? ? count : nil
50
+ end
51
+
52
+ def operating_system = RbConfig::CONFIG['host_os'][/darwin|linux|mswin|mingw/] || 'other'
53
+ end
54
+ end
55
+ end
@@ -32,6 +32,12 @@ module TRMNLP
32
32
  'php' => { cmds: %w[php], ext: 'php' }
33
33
  }.freeze
34
34
 
35
+ # on_spawn is called with the transform's process id, as soon as it starts.
36
+ def initialize(environment: {}, on_spawn: nil)
37
+ @environment = environment
38
+ @on_spawn = on_spawn
39
+ end
40
+
35
41
  def execute(code:, language:, stdin: '', timeout_seconds: DEFAULT_TIMEOUT)
36
42
  spec = INTERPRETERS[language.to_s]
37
43
  return failure("unsupported language: #{language}") unless spec
@@ -55,7 +61,8 @@ module TRMNLP
55
61
  def run_process(cmd, src_path, stdin, timeout_seconds, output_path)
56
62
  started = monotonic_ms
57
63
 
58
- Open3.popen3(cmd, src_path) do |stdin_io, stdout_io, stderr_io, wait_thr|
64
+ Open3.popen3(@environment, cmd, src_path) do |stdin_io, stdout_io, stderr_io, wait_thr|
65
+ @on_spawn&.call(wait_thr.pid)
59
66
  stdin_io.write(stdin)
60
67
  stdin_io.close
61
68
 
@@ -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,17 @@ 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
- def initialize(config:, paths:, reporter: Reporter.new)
22
+ def initialize(config:, paths:, reporter: Reporter.new, client: nil)
23
+ @given_client = client
19
24
  @config = config
20
25
  @paths = paths
21
26
  @reporter = reporter
27
+ @transform_state = TransformState.new(paths:, reporter:)
22
28
  end
23
29
 
24
30
  def call(data)
@@ -29,17 +35,27 @@ module TRMNLP
29
35
  run(transform_path, inferred_language, data)
30
36
  end
31
37
 
32
- def reset! = @client = nil
38
+ def configured? = !paths.transform_file.first.nil? && !client.nil?
39
+
40
+ # What the last run stored for the markup, passed to the next run as trmnl.previous_merge_variables.
41
+ def previous_output
42
+ paths.transform_output.exist? ? JSON.parse(paths.transform_output.read) : {}
43
+ rescue JSON::ParserError
44
+ {}
45
+ end
46
+
47
+ def reset! = @client = @given_client
33
48
 
34
49
  private
35
50
 
36
- attr_reader :config, :paths, :reporter
51
+ attr_reader :config, :paths, :reporter, :transform_state
37
52
 
38
- def client = @client ||= TransformClient.from_config(config.project)
53
+ def client = @client ||= @given_client || TransformClient.from_config(config.project)
39
54
 
40
55
  def run(path, inferred_language, data)
41
56
  language = config.plugin.serverless_language || inferred_language
42
- result = client.execute(code: path.read, stdin: JSON.generate(data), language:)
57
+ result = client.execute(code: path.read, stdin: JSON.generate(data), language:,
58
+ timeout_seconds: EXECUTION_TIMEOUT_SECONDS)
43
59
  report_printed_output(result)
44
60
  return record_failure(result, data) unless result.success?
45
61
 
@@ -56,18 +72,44 @@ module TRMNLP
56
72
  def record_failure(result, fallback)
57
73
  @error = result.error || "transform exited #{result.exit_code}: #{result.stderr.strip}"
58
74
  reporter.info("transform failed: #{result.error || "exited #{result.exit_code}"}")
59
- fallback
75
+ last_good_output(fallback)
60
76
  end
61
77
 
78
+ # TRMNL keeps the last good screen when a transform fails.
79
+ def last_good_output(fallback) = paths.transform_output.exist? ? previous_output : fallback
80
+
62
81
  def parse_output(output, fallback)
63
82
  transformed = JSON.parse(output)
64
- transformed.is_a?(Hash) ? transformed : wrap_array(transformed)
83
+ return reject_non_object_output unless transformed.is_a?(Hash)
84
+
85
+ transform_state.extract!(transformed, fetch_failed: config.plugin.polling? && paths.fetch_failed_marker.exist?)
86
+ store_output(without_previous_merge_variables(transformed))
65
87
  rescue JSON::ParserError => e
66
88
  @error = "transform produced non-JSON output: #{e.message}"
67
89
  reporter.info(@error)
68
- fallback
90
+ last_good_output(fallback)
69
91
  end
70
92
 
71
- def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
93
+ # TRMNL renders nothing from an array or a scalar, and refuses one from a webhook post.
94
+ def reject_non_object_output
95
+ @error = 'Transform output must be a JSON object'
96
+ reporter.info(@error)
97
+ {}
98
+ end
99
+
100
+ def store_output(output)
101
+ paths.transform_output.dirname.mkpath
102
+ paths.transform_output.write(JSON.generate(output))
103
+ output
104
+ end
105
+
106
+ # A transform that echoes its input at any depth (`{ data: input }`) would store its history and double every run.
107
+ def without_previous_merge_variables(value)
108
+ case value
109
+ when Hash then value.except('previous_merge_variables').transform_values { without_previous_merge_variables(it) }
110
+ when Array then value.map { without_previous_merge_variables(it) }
111
+ else value
112
+ end
113
+ end
72
114
  end
73
115
  end
@@ -0,0 +1,51 @@
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, fetch_failed: false)
25
+ return unless output.is_a?(Hash) && output.key?(KEY)
26
+
27
+ state = output.delete(KEY)
28
+ # Like TRMNL: a state built from a failed fetch would replace the last good one.
29
+ return if fetch_failed
30
+
31
+ rejection = rejection_reason(state)
32
+ rejection ? reporter.info(reporter.yellow("Ignored #{KEY}: #{rejection}")) : write(state)
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :paths, :reporter
38
+
39
+ def rejection_reason(state)
40
+ return "expected an object, got #{state.class}" unless state.is_a?(Hash)
41
+
42
+ size = JSON.generate(state).bytesize
43
+ "#{size} bytes exceeds the #{MAX_BYTES} byte limit" if size > MAX_BYTES
44
+ end
45
+
46
+ def write(state)
47
+ paths.transform_state.dirname.mkpath
48
+ paths.transform_state.write(JSON.generate(state))
49
+ end
50
+ end
51
+ end
@@ -4,15 +4,20 @@ require 'active_support'
4
4
  require 'active_support/time'
5
5
  require 'json'
6
6
 
7
+ require_relative 'merged_plugin_locals'
8
+ require_relative 'transform_state'
9
+
7
10
  module TRMNLP
8
11
  class UserDataAssembler
9
12
  DEFAULT_DEVICE_WIDTH = 800
10
13
  DEFAULT_DEVICE_HEIGHT = 480
11
14
 
12
- def initialize(config:, paths:, transform_pipeline:)
15
+ def initialize(config:, paths:, transform_pipeline:, oauth_session: nil)
13
16
  @config = config
14
17
  @paths = paths
15
18
  @transform_pipeline = transform_pipeline
19
+ @oauth_session = oauth_session
20
+ @transform_state = TransformState.new(paths:)
16
21
  end
17
22
 
18
23
  # Assembles the merged data hash. The trmnl namespace is built first,
@@ -21,41 +26,61 @@ module TRMNLP
21
26
  # (overrides included) is re-applied after the transform so it
22
27
  # survives even when the transform doesn't pass it through.
23
28
  def call(device: {})
24
- namespace = base_trmnl_data(device:)
25
- merged = assemble(namespace)
26
- result = transform_pipeline.call(transform_input(merged))
27
- result['trmnl'] = merged['trmnl']
29
+ merged = assemble(base_trmnl_data(device:), source_data)
30
+ runs_transform = config.plugin.transforms_on_render? && transform_pipeline.configured?
31
+ result = runs_transform ? transform_pipeline.call(transform_input(merged)) : merged.except('trmnl')
32
+ # The markup renders the state this run's transform kept, as on TRMNL.
33
+ result['trmnl'] = merged['trmnl'].merge('state' => transform_state.read)
28
34
  result
29
35
  end
30
36
 
37
+ def transform_webhook_post(merge_variables)
38
+ transform_pipeline.call(transform_input(assemble(base_trmnl_data(device: {}), merge_variables)))
39
+ end
40
+
41
+ # What a polling url, its headers and its body render with on TRMNL.
42
+ def polling_variables = assemble(base_trmnl_data(device: {}), {}).slice('trmnl')
43
+
31
44
  def device_from_params(params)
32
- { 'width' => params[:width]&.to_i, 'height' => params[:height]&.to_i,
33
- 'model' => params[:model], 'bit_depth' => params[:bit_depth]&.to_i }.compact
45
+ { 'width' => params[:width]&.to_i, 'height' => params[:height]&.to_i, 'model' => params[:model],
46
+ 'bit_depth' => params[:bit_depth]&.to_i, 'orientation' => params[:orientation] }.compact
34
47
  end
35
48
 
36
49
  private
37
50
 
38
- attr_reader :config, :paths, :transform_pipeline
51
+ attr_reader :config, :paths, :transform_pipeline, :transform_state, :oauth_session
39
52
 
40
- def assemble(namespace)
41
- data = namespace.dup
42
- merge_source_data!(data)
43
- data.deep_merge!(config.project.user_data_overrides)
44
- data
53
+ # The trmnl namespace wins over a trmnl key in the fetched data, as on TRMNL; only .trmnlp.yml overrides it.
54
+ def assemble(namespace, source)
55
+ source.merge(namespace).deep_merge(config.project.user_data_overrides)
45
56
  end
46
57
 
47
- # The hosted service exposes only user/device/plugin_settings to the
58
+ # The hosted service exposes only user/device/plugin_settings/state to the
48
59
  # transform; the system namespace is added afterward. Mirror that slice
49
60
  # so transforms behave the same locally as in production.
50
61
  def transform_input(merged)
51
- merged.merge('trmnl' => merged['trmnl'].slice('user', 'device', 'plugin_settings'))
62
+ trmnl = merged['trmnl'].slice('user', 'device', 'plugin_settings', 'state')
63
+ trmnl['previous_merge_variables'] = previous_merge_variables
64
+ oauth = oauth_for_transform
65
+ trmnl['oauth'] = oauth unless oauth.empty?
66
+ merged.merge('trmnl' => trmnl)
52
67
  end
53
68
 
54
- def merge_source_data!(data)
69
+ # The connected account's token, so a transform can make its own authenticated calls.
70
+ def oauth_for_transform = (oauth_session&.liquid_variables || {}).transform_keys { it.delete_prefix('oauth_') }
71
+
72
+ # What the last run stored for the markup: a webhook's stored data, otherwise the last transform output.
73
+ def previous_merge_variables = config.plugin.webhook? ? source_data : transform_pipeline.previous_output
74
+
75
+ def source_data
55
76
  if config.plugin.static?
56
- data.merge!(config.plugin.static_data)
77
+ config.plugin.static_data
78
+ elsif config.plugin.plugin_merge?
79
+ MergedPluginLocals.new(config:, paths:).call
57
80
  elsif paths.user_data.exist?
58
- data.merge!(JSON.parse(paths.user_data.read))
81
+ JSON.parse(paths.user_data.read)
82
+ else
83
+ {}
59
84
  end
60
85
  end
61
86
 
@@ -68,7 +93,8 @@ module TRMNLP
68
93
  'user' => user_namespace,
69
94
  'device' => device_namespace(device),
70
95
  'system' => { 'timestamp_utc' => Time.now.utc.to_i },
71
- 'plugin_settings' => plugin_settings_namespace
96
+ 'plugin_settings' => plugin_settings_namespace,
97
+ 'state' => transform_state.read
72
98
  }
73
99
  end
74
100
 
@@ -89,21 +115,33 @@ module TRMNLP
89
115
  'height' => device['height'] || DEFAULT_DEVICE_HEIGHT,
90
116
  'width' => device['width'] || DEFAULT_DEVICE_WIDTH,
91
117
  'model' => device['model'] || 'og_plus', 'bit_depth' => device['bit_depth'] || 2,
92
- 'firmware_version' => '1.6.3', 'refresh_interval_seconds' => 900,
118
+ 'orientation' => device['orientation'] || 'landscape',
119
+ 'firmware_version' => '1.8.17', 'refresh_interval_seconds' => 900,
93
120
  'sleep_mode_enabled' => false, 'sleep_start_time' => 1320, 'sleep_end_time' => 480
94
121
  }
95
122
  end
96
123
 
97
124
  def plugin_settings_namespace
98
125
  {
99
- 'instance_name' => 'instance_name',
126
+ 'instance_name' => config.plugin.settings['name'],
100
127
  'refresh_interval_minutes' => config.plugin.refresh_interval,
101
128
  'strategy' => config.plugin.strategy,
102
129
  'dark_mode' => config.plugin.dark_mode,
103
- 'polling_headers' => config.plugin.polling_headers_encoded,
104
- 'polling_url' => config.plugin.polling_url_text,
105
- 'custom_fields_values' => config.project.custom_fields
106
- }
130
+ 'no_screen_padding' => config.plugin.no_screen_padding
131
+ }.merge(custom_fields_setting, polling_url_setting, data_fetched_setting)
132
+ end
133
+
134
+ def custom_fields_setting
135
+ values = config.plugin.custom_fields_values
136
+ values.empty? ? {} : { 'custom_fields_values' => values }
137
+ end
138
+
139
+ # The polling url can carry the author's API key, so TRMNL exposes it only to a polling plugin.
140
+ def polling_url_setting = config.plugin.polling? ? { 'polling_url' => config.plugin.polling_url_text } : {}
141
+
142
+ def data_fetched_setting
143
+ fetched = !config.plugin.static? && paths.user_data.exist?
144
+ fetched ? { 'data_fetched_utc' => paths.user_data.mtime.to_i } : {}
107
145
  end
108
146
  end
109
147
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.15.0'
4
+ VERSION = '0.17.0'
5
5
  end
@@ -67,7 +67,8 @@ module TRMNLP
67
67
  # NOTE: transform.* changes don't trigger a re-poll — the transform
68
68
  # runs inside user_data on every render against the cached polled
69
69
  # response (or static_data), so editing the transform updates the
70
- # preview without re-fetching the API.
70
+ # preview without re-fetching the API. A webhook plugin's transform
71
+ # runs only when a post arrives, as on TRMNL.
71
72
  def notify(changes)
72
73
  data = user_data_assembler.call
73
74
  return unless @view_change_callback
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/deep_merge'
4
+ require 'json'
5
+
6
+ module TRMNLP
7
+ # A webhook post as TRMNL takes it: merge_variables inside an object, merged into the
8
+ # stored data under merge_strategy, which the body or the query string may carry.
9
+ class WebhookPayload
10
+ MERGE_STRATEGIES = [nil, 'replace', 'deep_merge', 'stream'].freeze
11
+ STORED_SIZE_LIMIT_BYTES = 5 * 1024
12
+
13
+ def initialize(body, params = {})
14
+ @params = params.to_h.merge(body.is_a?(Hash) ? body : {})
15
+ end
16
+
17
+ def rejection
18
+ strategy = params['merge_strategy']
19
+ return "Invalid merge_strategy: '#{strategy}'" unless MERGE_STRATEGIES.include?(strategy)
20
+
21
+ case merge_variables
22
+ when Hash then nil
23
+ when nil, Array then 'Must be nested inside a merge_variables payload object'
24
+ when String then "Received string: '#{merge_variables}', expecting object"
25
+ else 'Expected an object payload'
26
+ end
27
+ end
28
+
29
+ def merge_variables
30
+ return @merge_variables if defined?(@merge_variables)
31
+
32
+ value = params['merge_variables']
33
+ @merge_variables = value.is_a?(String) ? (parsed_object(value) || value) : value
34
+ end
35
+
36
+ # A serverless webhook merges its transform output in place of the posted merge_variables.
37
+ def merged_into(stored, incoming = merge_variables)
38
+ case merge_strategy
39
+ when 'deep_merge' then stored.deep_merge(incoming)
40
+ when 'stream' then streamed_into(stored, incoming)
41
+ else incoming
42
+ end
43
+ end
44
+
45
+ # TRMNL refuses a post past its plan's limit; trmnlp cannot know the plan, so it only warns.
46
+ def size_warning(merged)
47
+ size = JSON.generate(merged).bytesize
48
+ "#{size} bytes stored; TRMNL rejects more than 5 kB (10 kB with TRMNL+)" if size > STORED_SIZE_LIMIT_BYTES
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :params
54
+
55
+ def merge_strategy = params['deep_merge'].to_s == 'true' ? 'deep_merge' : params['merge_strategy']
56
+
57
+ def parsed_object(text)
58
+ parsed = JSON.parse(text)
59
+ parsed if parsed.is_a?(Hash)
60
+ rescue JSON::ParserError
61
+ nil
62
+ end
63
+
64
+ def streamed_into(stored, incoming)
65
+ limit = params['stream_limit'].to_i
66
+ incoming.to_h do |key, value|
67
+ next [key, value] unless stored[key].is_a?(Array) && value.is_a?(Array)
68
+
69
+ combined = stored[key] + value
70
+ [key, limit.positive? ? combined.last(limit) : combined]
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'reporter'
6
+ require_relative 'webhook_payload'
7
+
8
+ module TRMNLP
9
+ # Takes a webhook post the way TRMNL does: a serverless plugin transforms the post once, as it
10
+ # arrives, and the result is merged into the stored data. Answers TRMNL's status and JSON body.
11
+ class WebhookReceiver
12
+ MAX_SERVERLESS_INCOMING_BYTES = 1024 * 1024
13
+
14
+ def initialize(paths:, transform_pipeline:, user_data_assembler:, reporter: Reporter.new)
15
+ @paths = paths
16
+ @transform_pipeline = transform_pipeline
17
+ @user_data_assembler = user_data_assembler
18
+ @reporter = reporter
19
+ end
20
+
21
+ def call(body, params = {})
22
+ payload = WebhookPayload.new(JSON.parse(body), params)
23
+ return reject(payload, payload.rejection) if payload.rejection
24
+ return store(payload, payload.merge_variables) unless transform_pipeline.configured?
25
+
26
+ transform_and_store(payload)
27
+ rescue JSON::ParserError => e
28
+ report_warning(e.message)
29
+ [400, { message: e.message }]
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :paths, :transform_pipeline, :user_data_assembler, :reporter
35
+
36
+ def transform_and_store(payload)
37
+ oversized = serverless_size_rejection(payload)
38
+ return reject(payload, oversized) if oversized
39
+
40
+ transformed = user_data_assembler.transform_webhook_post(payload.merge_variables)
41
+ # TRMNL answers before its transform runs, so a failure keeps the stored data and never reaches the poster.
42
+ if transform_pipeline.error
43
+ report_warning("Transform failed: #{transform_pipeline.error}")
44
+ else
45
+ store(payload, transformed)
46
+ end
47
+ [200, { message: nil, merge_variables: payload.merge_variables, processing: 'serverless' }]
48
+ end
49
+
50
+ def serverless_size_rejection(payload)
51
+ size = JSON.generate(payload.merge_variables).bytesize
52
+ "Large payload received (#{size} bytes), should be less than 1mb." if size > MAX_SERVERLESS_INCOMING_BYTES
53
+ end
54
+
55
+ def store(payload, incoming)
56
+ merged = payload.merged_into(stored_data, incoming)
57
+ payload.size_warning(merged)&.then { report_warning(it) }
58
+ paths.user_data.dirname.mkpath
59
+ paths.user_data.write(JSON.generate(merged))
60
+ [200, { message: nil, merge_variables: merged }]
61
+ end
62
+
63
+ def reject(payload, message)
64
+ report_warning(message)
65
+ posted = payload.merge_variables.nil? ? [] : payload.merge_variables
66
+ [422, { message:, merge_variables: stored_data.empty? ? posted : stored_data }]
67
+ end
68
+
69
+ def stored_data
70
+ stored = paths.user_data.exist? ? JSON.parse(paths.user_data.read) : {}
71
+ stored.is_a?(Hash) ? stored : {}
72
+ rescue JSON::ParserError
73
+ {}
74
+ end
75
+
76
+ def report_warning(message) = reporter.info(reporter.yellow("webhook warning: #{message}"))
77
+ end
78
+ end
data/lib/trmnlp.rb CHANGED
@@ -3,8 +3,9 @@
3
3
  module TRMNLP; end
4
4
  require 'oj'
5
5
  require 'trmnl/liquid'
6
+ require_relative 'trmnlp/qr_code_intrinsic_size'
6
7
  Oj.mimic_JSON
7
- TRMNL::Liquid::RailsHelpers = Module.new unless defined?(TRMNL::Liquid::RailsHelpers)
8
+ require_relative 'trmnlp/rails_helpers'
8
9
  require_relative 'trmnlp/errors'
9
10
  require_relative 'trmnlp/oauth'
10
11
  require_relative 'trmnlp/config'