trmnl_preview 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +48 -0
- data/db/data/and_x_more.yml +86 -0
- data/db/data/locales/cs.yml +339 -0
- data/db/data/locales/da.yml +339 -0
- data/db/data/locales/de-AT.yml +341 -0
- data/db/data/locales/de.yml +341 -0
- data/db/data/locales/en-AU.yml +79 -0
- data/db/data/locales/en-GB.yml +79 -0
- data/db/data/locales/en.yml +341 -0
- data/db/data/locales/es-ES.yml +341 -0
- data/db/data/locales/fr.yml +339 -0
- data/db/data/locales/he.yml +339 -0
- data/db/data/locales/hu.yml +339 -0
- data/db/data/locales/id.yml +339 -0
- data/db/data/locales/is.yml +339 -0
- data/db/data/locales/it.yml +341 -0
- data/db/data/locales/ja.yml +342 -0
- data/db/data/locales/ko.yml +337 -0
- data/db/data/locales/lt.yml +340 -0
- data/db/data/locales/nl.yml +339 -0
- data/db/data/locales/no.yml +4 -0
- data/db/data/locales/pl.yml +342 -0
- data/db/data/locales/pt-BR.yml +340 -0
- data/db/data/locales/raw.yml +256 -0
- data/db/data/locales/ro.yml +339 -0
- data/db/data/locales/ru.yml +342 -0
- data/db/data/locales/sk.yml +339 -0
- data/db/data/locales/sv.yml +340 -0
- data/db/data/locales/uk.yml +339 -0
- data/db/data/locales/zh-CN.yml +338 -0
- data/db/data/locales/zh-HK.yml +338 -0
- data/lib/trmnlp/app.rb +5 -4
- data/lib/trmnlp/async_callback.rb +75 -0
- data/lib/trmnlp/browser_pool.rb +8 -8
- data/lib/trmnlp/cli.rb +7 -0
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/commands/test.rb +25 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +23 -10
- data/lib/trmnlp/config.rb +2 -2
- data/lib/trmnlp/context.rb +29 -6
- data/lib/trmnlp/errors.rb +1 -0
- data/lib/trmnlp/firefox_driver.rb +0 -3
- data/lib/trmnlp/framework_version.rb +9 -0
- data/lib/trmnlp/image_quantizer.rb +28 -15
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +5 -0
- data/lib/trmnlp/lint/source.rb +6 -0
- data/lib/trmnlp/lint.rb +3 -1
- data/lib/trmnlp/merged_plugin_locals.rb +57 -0
- data/lib/trmnlp/outbound_request.rb +99 -0
- data/lib/trmnlp/paths.rb +15 -8
- data/lib/trmnlp/poller.rb +100 -47
- data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +40 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +50 -11
- data/lib/trmnlp/select_field_choices.rb +25 -0
- data/lib/trmnlp/testing/browser.rb +87 -0
- data/lib/trmnlp/testing/certificate_authority.rb +84 -0
- data/lib/trmnlp/testing/device_models.rb +80 -0
- data/lib/trmnlp/testing/frozen_clock.rb +86 -0
- data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
- data/lib/trmnlp/testing/memory_sampler.rb +44 -0
- data/lib/trmnlp/testing/mock_proxy.rb +124 -0
- data/lib/trmnlp/testing/mock_table.rb +102 -0
- data/lib/trmnlp/testing/node_version.rb +27 -0
- data/lib/trmnlp/testing/plugin.rb +91 -0
- data/lib/trmnlp/testing/report.html.erb +79 -0
- data/lib/trmnlp/testing/report.rb +91 -0
- data/lib/trmnlp/testing/rspec.rb +89 -0
- data/lib/trmnlp/testing/run.rb +150 -0
- data/lib/trmnlp/testing/screen.rb +83 -0
- data/lib/trmnlp/testing/snapshot.rb +55 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
- data/lib/trmnlp/transform_pipeline.rb +51 -9
- data/lib/trmnlp/transform_state.rb +51 -0
- data/lib/trmnlp/user_data_assembler.rb +63 -25
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +2 -1
- data/lib/trmnlp/webhook_payload.rb +74 -0
- data/lib/trmnlp/webhook_receiver.rb +78 -0
- data/lib/trmnlp.rb +2 -1
- data/templates/init/.github/workflows/trmnl.yml +33 -2
- data/templates/init/.gitignore +3 -0
- data/templates/init/tests/plugin_spec.rb +32 -0
- data/trmnl_preview.gemspec +6 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +37 -1
- metadata +115 -1
|
@@ -0,0 +1,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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
result = transform_pipeline.call(transform_input(merged))
|
|
27
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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' => '
|
|
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
|
-
'
|
|
104
|
-
|
|
105
|
-
|
|
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
|
data/lib/trmnlp/version.rb
CHANGED
data/lib/trmnlp/watcher.rb
CHANGED
|
@@ -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
|
-
|
|
8
|
+
require_relative 'trmnlp/rails_helpers'
|
|
8
9
|
require_relative 'trmnlp/errors'
|
|
9
10
|
require_relative 'trmnlp/oauth'
|
|
10
11
|
require_relative 'trmnlp/config'
|