trmnl_preview 0.14.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +31 -0
- data/db/data/and_x_more.yml +86 -0
- data/db/data/locales/cs.yml +339 -0
- data/db/data/locales/da.yml +339 -0
- data/db/data/locales/de-AT.yml +341 -0
- data/db/data/locales/de.yml +341 -0
- data/db/data/locales/en-AU.yml +79 -0
- data/db/data/locales/en-GB.yml +79 -0
- data/db/data/locales/en.yml +341 -0
- data/db/data/locales/es-ES.yml +341 -0
- data/db/data/locales/fr.yml +339 -0
- data/db/data/locales/he.yml +339 -0
- data/db/data/locales/hu.yml +339 -0
- data/db/data/locales/id.yml +339 -0
- data/db/data/locales/is.yml +339 -0
- data/db/data/locales/it.yml +341 -0
- data/db/data/locales/ja.yml +342 -0
- data/db/data/locales/ko.yml +337 -0
- data/db/data/locales/lt.yml +340 -0
- data/db/data/locales/nl.yml +339 -0
- data/db/data/locales/no.yml +4 -0
- data/db/data/locales/pl.yml +342 -0
- data/db/data/locales/pt-BR.yml +340 -0
- data/db/data/locales/raw.yml +256 -0
- data/db/data/locales/ro.yml +339 -0
- data/db/data/locales/ru.yml +342 -0
- data/db/data/locales/sk.yml +339 -0
- data/db/data/locales/sv.yml +340 -0
- data/db/data/locales/uk.yml +339 -0
- data/db/data/locales/zh-CN.yml +338 -0
- data/db/data/locales/zh-HK.yml +338 -0
- data/lib/trmnlp/app.rb +5 -4
- data/lib/trmnlp/async_callback.rb +75 -0
- data/lib/trmnlp/cli.rb +2 -0
- data/lib/trmnlp/commands/lint.rb +18 -3
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +14 -4
- data/lib/trmnlp/context.rb +17 -2
- data/lib/trmnlp/firefox_driver.rb +0 -3
- data/lib/trmnlp/framework_version.rb +9 -0
- data/lib/trmnlp/image_quantizer.rb +28 -15
- data/lib/trmnlp/lint/checks/image_links_reachable.rb +6 -1
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +12 -5
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +97 -0
- data/lib/trmnlp/lint/source.rb +53 -0
- data/lib/trmnlp/lint.rb +3 -1
- data/lib/trmnlp/merged_plugin_locals.rb +57 -0
- data/lib/trmnlp/outbound_request.rb +99 -0
- data/lib/trmnlp/paths.rb +11 -6
- data/lib/trmnlp/poller.rb +83 -44
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +37 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +29 -4
- data/lib/trmnlp/select_field_choices.rb +25 -0
- data/lib/trmnlp/transform_pipeline.rb +47 -6
- data/lib/trmnlp/transform_state.rb +48 -0
- data/lib/trmnlp/user_data_assembler.rb +63 -25
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +2 -1
- data/lib/trmnlp/webhook_payload.rb +74 -0
- data/lib/trmnlp/webhook_receiver.rb +78 -0
- data/lib/trmnlp.rb +1 -1
- data/trmnl_preview.gemspec +3 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +36 -0
- metadata +83 -1
data/lib/trmnlp/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
|
|
@@ -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
|
@@ -4,7 +4,7 @@ module TRMNLP; end
|
|
|
4
4
|
require 'oj'
|
|
5
5
|
require 'trmnl/liquid'
|
|
6
6
|
Oj.mimic_JSON
|
|
7
|
-
|
|
7
|
+
require_relative 'trmnlp/rails_helpers'
|
|
8
8
|
require_relative 'trmnlp/errors'
|
|
9
9
|
require_relative 'trmnlp/oauth'
|
|
10
10
|
require_relative 'trmnlp/config'
|
data/trmnl_preview.gemspec
CHANGED
|
@@ -47,6 +47,9 @@ Gem::Specification.new do |spec|
|
|
|
47
47
|
|
|
48
48
|
# HTML rendering
|
|
49
49
|
spec.add_dependency 'activesupport', '~> 8.0'
|
|
50
|
+
spec.add_dependency 'crass', '~> 1.0'
|
|
51
|
+
spec.add_dependency 'csv', '~> 3.3'
|
|
52
|
+
spec.add_dependency 'nokogiri', '~> 1.18'
|
|
50
53
|
spec.add_dependency 'trmnl-liquid', '~> 0.8.2'
|
|
51
54
|
|
|
52
55
|
# PNG rendering
|
data/web/public/index.js
CHANGED
|
@@ -23,7 +23,8 @@ trmnlp.connectLiveRender = function () {
|
|
|
23
23
|
// trmnl.device values the picked model and palette decide.
|
|
24
24
|
trmnlp.deviceParams = function (state) {
|
|
25
25
|
const bitDepth = Math.ceil(Math.log2(state.palette?.grays || 2));
|
|
26
|
-
|
|
26
|
+
const orientation = state.isPortrait ? 'portrait' : 'landscape';
|
|
27
|
+
return new URLSearchParams({ width: state.width, height: state.height, model: state.model.name, bit_depth: bitDepth, orientation });
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
trmnlp.fetchPreview = function (pickerState) {
|
|
@@ -31,6 +32,7 @@ trmnlp.fetchPreview = function (pickerState) {
|
|
|
31
32
|
const screenClasses = (state?.screenClasses || []).join(" ");
|
|
32
33
|
const encodedScreenClasses = encodeURIComponent(screenClasses);
|
|
33
34
|
let src = `/render/${trmnlp.view}.${trmnlp.formatSelect.value}?screen_classes=${encodedScreenClasses}`;
|
|
35
|
+
if (trmnlp.themeSelect.value) src += `&theme=${encodeURIComponent(trmnlp.themeSelect.value)}`;
|
|
34
36
|
|
|
35
37
|
// Pass the device for both HTML and PNG renders so trmnl.device.{width,height,model,bit_depth}
|
|
36
38
|
// in the Liquid context tracks the picker model selection.
|
|
@@ -38,9 +40,8 @@ trmnlp.fetchPreview = function (pickerState) {
|
|
|
38
40
|
src += `&${trmnlp.deviceParams(state)}`;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
// PNG-only:
|
|
43
|
+
// PNG-only: color depth from palette
|
|
42
44
|
if (trmnlp.formatSelect.value === 'png' && state) {
|
|
43
|
-
const isDarkMode = state.isDarkMode ? 1 : 0;
|
|
44
45
|
const grays = state.palette.grays || 2;
|
|
45
46
|
const colorDepth = Math.ceil(Math.log2(grays));
|
|
46
47
|
src += `&color_depth=${colorDepth}`;
|
|
@@ -214,9 +215,13 @@ trmnlp.addSelectFieldRow = function (container, key, selectedValue, options) {
|
|
|
214
215
|
const row = document.createElement('div');
|
|
215
216
|
row.className = 'custom-field-row';
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
// Saved as TRMNL saves them: "New York" as new_york, { Label: value } as its value.
|
|
219
|
+
const choices = options.map(opt => (opt && typeof opt === 'object')
|
|
220
|
+
? [String(Object.keys(opt)[0]), String(Object.values(opt)[0])]
|
|
221
|
+
: [String(opt), String(opt).toLowerCase().replaceAll(' ', '_')]);
|
|
222
|
+
const optionsHtml = choices.map(([label, value]) => {
|
|
223
|
+
const selected = [label, value].includes(String(selectedValue)) ? 'selected' : '';
|
|
224
|
+
return `<option value="${trmnlp.escapeHtml(value)}" ${selected}>${trmnlp.escapeHtml(label)}</option>`;
|
|
220
225
|
}).join('');
|
|
221
226
|
|
|
222
227
|
row.innerHTML = `
|
|
@@ -261,6 +266,7 @@ document.addEventListener("DOMContentLoaded", async function () {
|
|
|
261
266
|
trmnlp.view = document.querySelector("meta[name='trmnl-view']").content;
|
|
262
267
|
trmnlp.iframe = document.querySelector("iframe");
|
|
263
268
|
trmnlp.formatSelect = document.querySelector(".select-format");
|
|
269
|
+
trmnlp.themeSelect = document.querySelector("[data-theme-select]");
|
|
264
270
|
trmnlp.userData = document.getElementById("user-data");
|
|
265
271
|
trmnlp.spinner = document.querySelector(".spinner");
|
|
266
272
|
trmnlp.isLiveReloadEnabled =
|
|
@@ -278,6 +284,12 @@ document.addEventListener("DOMContentLoaded", async function () {
|
|
|
278
284
|
trmnlp.fetchPreview();
|
|
279
285
|
});
|
|
280
286
|
|
|
287
|
+
trmnlp.themeSelect.value = localStorage.getItem("trmnlp-theme") || "";
|
|
288
|
+
trmnlp.themeSelect.addEventListener("change", () => {
|
|
289
|
+
localStorage.setItem("trmnlp-theme", trmnlp.themeSelect.value);
|
|
290
|
+
trmnlp.fetchPreview();
|
|
291
|
+
});
|
|
292
|
+
|
|
281
293
|
trmnlp.iframe.addEventListener("load", () => {
|
|
282
294
|
trmnlp.spinner.style.display = "none";
|
|
283
295
|
});
|
data/web/views/index.erb
CHANGED
|
@@ -46,6 +46,13 @@
|
|
|
46
46
|
<span data-dark-mode-text>Light Mode</span>
|
|
47
47
|
</button>
|
|
48
48
|
|
|
49
|
+
<select data-theme-select>
|
|
50
|
+
<option value="">Default theme</option>
|
|
51
|
+
<% TRMNLP::FrameworkVersion::THEMES.each do |id, name| %>
|
|
52
|
+
<option value="<%= id %>"><%= name %></option>
|
|
53
|
+
<% end %>
|
|
54
|
+
</select>
|
|
55
|
+
|
|
49
56
|
<!-- Optional: Reset button -->
|
|
50
57
|
<button class="btn" type="button" data-reset-button>Reset</button>
|
|
51
58
|
</form>
|