trmnl_preview 0.3.2 → 0.5.1

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +75 -28
  4. data/bin/trmnlp +14 -0
  5. data/lib/trmnlp/api_client.rb +72 -0
  6. data/lib/{trmnl_preview → trmnlp}/app.rb +24 -12
  7. data/lib/trmnlp/cli.rb +66 -0
  8. data/lib/trmnlp/commands/base.rb +40 -0
  9. data/lib/trmnlp/commands/build.rb +21 -0
  10. data/lib/trmnlp/commands/clone.rb +30 -0
  11. data/lib/trmnlp/commands/init.rb +55 -0
  12. data/lib/trmnlp/commands/login.rb +24 -0
  13. data/lib/trmnlp/commands/pull.rb +43 -0
  14. data/lib/trmnlp/commands/push.rb +61 -0
  15. data/lib/trmnlp/commands/serve.rb +25 -0
  16. data/lib/trmnlp/commands.rb +1 -0
  17. data/lib/trmnlp/config/app.rb +43 -0
  18. data/lib/trmnlp/config/plugin.rb +74 -0
  19. data/lib/trmnlp/config/project.rb +47 -0
  20. data/lib/trmnlp/config.rb +15 -0
  21. data/lib/trmnlp/context.rb +211 -0
  22. data/lib/{trmnl_preview → trmnlp}/custom_filters.rb +2 -1
  23. data/lib/trmnlp/paths.rb +56 -0
  24. data/lib/{trmnl_preview → trmnlp}/screen_generator.rb +1 -1
  25. data/lib/trmnlp/version.rb +5 -0
  26. data/lib/trmnlp.rb +14 -0
  27. data/templates/init/.trmnlp.yml +14 -0
  28. data/templates/init/bin/dev +25 -0
  29. data/templates/init/src/full.liquid +1 -0
  30. data/templates/init/src/half_horizontal.liquid +1 -0
  31. data/templates/init/src/half_vertical.liquid +1 -0
  32. data/templates/init/src/quadrant.liquid +1 -0
  33. data/templates/init/src/settings.yml +15 -0
  34. data/trmnl_preview.gemspec +22 -13
  35. data/web/public/index.js +5 -1
  36. data/web/views/index.erb +2 -0
  37. data/web/views/render_html.erb +1 -1
  38. metadata +110 -24
  39. data/.ruby-version +0 -1
  40. data/config.example.toml +0 -14
  41. data/docs/preview.png +0 -0
  42. data/exe/trmnlp +0 -14
  43. data/lib/trmnl_preview/cmd/build.rb +0 -25
  44. data/lib/trmnl_preview/cmd/serve.rb +0 -31
  45. data/lib/trmnl_preview/cmd/usage.rb +0 -11
  46. data/lib/trmnl_preview/cmd/version.rb +0 -1
  47. data/lib/trmnl_preview/context.rb +0 -135
  48. data/lib/trmnl_preview/version.rb +0 -5
  49. data/lib/trmnl_preview.rb +0 -10
@@ -0,0 +1,61 @@
1
+ require 'zip'
2
+
3
+ require_relative 'base'
4
+ require_relative '../api_client'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ class Push < Base
9
+ def call
10
+ context.validate!
11
+ authenticate!
12
+
13
+ is_new = false
14
+
15
+ api = APIClient.new(config)
16
+
17
+ plugin_settings_id = options.id || config.plugin.id
18
+ if plugin_settings_id.nil?
19
+ output 'Creating a new plugin on the server...'
20
+ response = api.post_plugin_setting(name: 'New TRMNLP Plugin', plugin_id: 37) # hardcoded id for private_plugin
21
+ plugin_settings_id = response.dig('data', 'id')
22
+ is_new = true
23
+ end
24
+
25
+ unless is_new || options.force
26
+ answer = prompt("Plugin settings on the server will be overwritten. Are you sure? (y/n) ").downcase
27
+ raise Error, 'aborting' unless answer == 'y' || answer == 'yes'
28
+ end
29
+
30
+ size = 0
31
+
32
+ Tempfile.create(binmode: true) do |temp_file|
33
+ Zip::File.open(temp_file.path, Zip::File::CREATE) do |zip_file|
34
+ paths.src_files.each do |file|
35
+ zip_file.add(File.basename(file), file)
36
+ end
37
+ end
38
+
39
+ response = api.post_plugin_setting_archive(plugin_settings_id, temp_file.path)
40
+ paths.plugin_config.write(response.dig('data', 'settings_yaml'))
41
+
42
+ size = File.size(temp_file.path)
43
+ end
44
+
45
+ output <<~HEREDOC
46
+ Uploaded plugin (#{size} bytes)
47
+ Dashboard: #{config.app.edit_plugin_settings_uri(plugin_settings_id)}
48
+ HEREDOC
49
+
50
+ if is_new
51
+ output <<~HEREDOC
52
+
53
+ IMPORTANT! Don't forget to add it to your device playlist!
54
+
55
+ #{config.app.playlists_uri}
56
+ HEREDOC
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ require 'zip'
2
+
3
+ require_relative 'base'
4
+ require_relative '../api_client'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ class Serve < Base
9
+ def call
10
+ context.validate!
11
+
12
+ # Must come AFTER parsing options
13
+ require_relative '../app'
14
+
15
+ # Now we can configure things
16
+ App.set(:context, context)
17
+ App.set(:bind, options.bind)
18
+ App.set(:port, options.port)
19
+
20
+ # Finally, start the app!
21
+ App.run!
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1 @@
1
+ Dir[File.join(__dir__, 'commands', '*.rb')].each { |file| require file }
@@ -0,0 +1,43 @@
1
+ require 'yaml'
2
+
3
+ module TRMNLP
4
+ class Config
5
+ # Stores trmnlp-wide configuration (irrespective of the current plugin)
6
+ class App
7
+ def initialize(paths)
8
+ @paths = paths
9
+ @config = read_config
10
+ end
11
+
12
+ def save
13
+ paths.app_config_dir.mkpath
14
+ paths.app_config.write(YAML.dump(@config))
15
+ end
16
+
17
+ def logged_in? = api_key && !api_key.empty?
18
+ def logged_out? = !logged_in?
19
+
20
+ def api_key = @config['api_key']
21
+
22
+ def api_key=(key)
23
+ @config['api_key'] = key
24
+ end
25
+
26
+ def base_uri = URI.parse(@config['base_url'] || 'https://usetrmnl.com')
27
+
28
+ def api_uri = URI.join(base_uri, '/api')
29
+
30
+ def account_uri = URI.join(base_uri, '/account')
31
+
32
+ def edit_plugin_settings_uri(id) = URI.join(base_uri, "/plugin_settings/#{id.to_s}/edit")
33
+
34
+ def playlists_uri = URI.join(base_uri, '/playlists')
35
+
36
+ private
37
+
38
+ attr_reader :paths
39
+
40
+ def read_config = paths.app_config.exist? ? YAML.safe_load(paths.app_config.read) : {}
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,74 @@
1
+ require 'yaml'
2
+
3
+ module TRMNLP
4
+ class Config
5
+ class Plugin
6
+ def initialize(paths, trmnlp_config)
7
+ @paths = paths
8
+ @trmnlp_config = trmnlp_config
9
+ reload!
10
+ end
11
+
12
+ def reload!
13
+ if paths.plugin_config.exist?
14
+ @config = YAML.load_file(paths.plugin_config)
15
+ else
16
+ @config = {}
17
+ end
18
+ end
19
+
20
+ def strategy = @config['strategy']
21
+ def polling? = strategy == 'polling'
22
+ def webhook? = strategy == 'webhook'
23
+ def static? = strategy == 'static'
24
+
25
+ def polling_urls
26
+ return [] if @config['polling_url'].nil? || @config['polling_url'].empty?
27
+
28
+ urls = @config['polling_url'].split("\n").map(&:strip)
29
+
30
+ urls.map { |url| with_custom_fields(url) }
31
+ end
32
+
33
+ def polling_url_text = polling_urls.join("\r\n") # for {{ trmnl }}
34
+
35
+ def polling_verb = @config['polling_verb'] || 'GET'
36
+
37
+ def polling_headers
38
+ string_to_hash(@config['polling_headers'] || '').transform_values { |v| with_custom_fields(v) }
39
+ end
40
+
41
+ def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&') # for {{ trmnl }}
42
+
43
+ def polling_body = with_custom_fields(@config['polling_body'] || '')
44
+
45
+ def dark_mode = @config['dark_mode'] || 'no'
46
+
47
+ def no_screen_padding = @config['no_screen_padding'] || 'no'
48
+
49
+ def id = @config['id']
50
+
51
+ def static_data
52
+ JSON.parse(@config['static_data'] || '{}')
53
+ rescue JSON::ParserError
54
+ raise Error, 'invalid JSON in static_data'
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :paths, :trmnlp_config
60
+
61
+ def with_custom_fields(value) = trmnlp_config.with_custom_fields(value)
62
+
63
+ # copied from TRMNL core
64
+ def string_to_hash(str, delimiter: '=')
65
+ str.split('&').map do |k_v|
66
+ key, value = k_v.split(delimiter)
67
+ next if value.nil?
68
+
69
+ { key => CGI.unescape_uri_component(value) }
70
+ end.compact.reduce({}, :merge)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ require 'yaml'
2
+
3
+ module TRMNLP
4
+ class Config
5
+ class Project
6
+ attr_reader :paths
7
+
8
+ def initialize(paths)
9
+ @paths = paths
10
+ reload!
11
+ end
12
+
13
+ def reload!
14
+ if paths.trmnlp_config.exist?
15
+ @config = YAML.load_file(paths.trmnlp_config)
16
+ else
17
+ @config = {}
18
+ end
19
+ end
20
+
21
+ def user_filters = @config['custom_filters'] || []
22
+
23
+ def live_render? = !watch_paths.empty?
24
+
25
+ def watch_paths
26
+ (@config['watch'] || []).map { |watch_path| paths.expand(watch_path) }.uniq
27
+ end
28
+
29
+ def custom_fields = @config['custom_fields'] || {}
30
+
31
+ def user_data_overrides = @config['variables'] || {}
32
+
33
+ # for interpolating custom_fields into polling_* options
34
+ def with_custom_fields(value)
35
+ custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
36
+ Liquid::Template.parse(value).render(custom_fields_with_env)
37
+ end
38
+
39
+ private
40
+
41
+ # for interpolating ENV vars into custom_fields
42
+ def with_env(value)
43
+ Liquid::Template.parse(value).render({ 'env' => ENV.to_h })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ require_relative 'config/app'
2
+ require_relative 'config/plugin'
3
+ require_relative 'config/project'
4
+
5
+ module TRMNLP
6
+ class Config
7
+ attr_reader :app, :project, :plugin
8
+
9
+ def initialize(path)
10
+ @app = App.new(path)
11
+ @project = Project.new(path)
12
+ @plugin = Plugin.new(path, @project)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,211 @@
1
+ require 'erb'
2
+ require 'faraday'
3
+ require 'filewatcher'
4
+ require 'json'
5
+ require 'liquid'
6
+
7
+ require_relative 'config'
8
+ require_relative 'custom_filters'
9
+ require_relative 'paths'
10
+
11
+ module TRMNLP
12
+ class Context
13
+ attr_reader :config, :paths
14
+
15
+ def initialize(root_dir)
16
+ @paths = Paths.new(root_dir)
17
+ @config = Config.new(paths)
18
+ end
19
+
20
+ def validate!
21
+ raise Error, "not a plugin directory (did not find #{paths.trmnlp_config})" unless paths.valid?
22
+ end
23
+
24
+ def start_filewatcher
25
+ @filewatcher_thread ||= Thread.new do
26
+ loop do
27
+ begin
28
+ Filewatcher.new(config.project.watch_paths).watch do |changes|
29
+ config.project.reload!
30
+ config.plugin.reload!
31
+ new_user_data = user_data
32
+
33
+ views = changes.map { |path, _change| File.basename(path, '.liquid') }
34
+ views.each do |view|
35
+ @view_change_callback.call(view, new_user_data) if @view_change_callback
36
+ end
37
+ end
38
+ rescue => e
39
+ puts "Error during live render: #{e}"
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ def on_view_change(&block)
46
+ @view_change_callback = block
47
+ end
48
+
49
+ def user_data
50
+ merged_data = base_trmnl_data
51
+
52
+ if config.plugin.static?
53
+ merged_data.merge!(config.plugin.static_data)
54
+ elsif paths.user_data.exist?
55
+ merged_data.merge!(JSON.parse(paths.user_data.read))
56
+ end
57
+
58
+ # Praise be to ActiveSupport
59
+ merged_data.deep_merge!(config.project.user_data_overrides)
60
+ end
61
+
62
+ def poll_data
63
+ return unless config.plugin.polling?
64
+
65
+ data = {}
66
+
67
+ if config.plugin.polling_urls.empty?
68
+ raise Error, "config must specify polling_url or polling_urls"
69
+ end
70
+
71
+ config.plugin.polling_urls.each.with_index do |url, i|
72
+ verb = config.plugin.polling_verb.upcase
73
+
74
+ print "#{verb} #{url}... "
75
+
76
+ conn = Faraday.new(url:, headers: config.plugin.polling_headers)
77
+
78
+ case verb
79
+ when 'GET'
80
+ response = conn.get
81
+ when 'POST'
82
+ response = conn.post do |req|
83
+ req.body = config.plugin.polling_body
84
+ end
85
+ end
86
+
87
+ puts "received #{response.body.length} bytes (#{response.status} status)"
88
+ if response.status == 200
89
+ json = wrap_array(JSON.parse(response.body))
90
+ else
91
+ json = {}
92
+ puts response.body
93
+ end
94
+
95
+ if config.plugin.polling_urls.count == 1
96
+ # For a single polling URL, we just return the JSON directly
97
+ data = json
98
+ break
99
+ else
100
+ # Multiple URLs are namespaced by index
101
+ data["IDX_#{i}"] = json
102
+ end
103
+ end
104
+
105
+ write_user_data(data)
106
+
107
+ data
108
+ rescue StandardError => e
109
+ puts "error: #{e.message}"
110
+ {}
111
+ end
112
+
113
+ def put_webhook(payload)
114
+ data = wrap_array(JSON.parse(payload))
115
+ write_user_data(data)
116
+ rescue
117
+ puts "webhook error: #{e.message}"
118
+ end
119
+
120
+ def render_template(view)
121
+ template_path = paths.template(view)
122
+ return "Missing template: #{template_path}" unless template_path.exist?
123
+
124
+ user_template = Liquid::Template.parse(template_path.read, environment: liquid_environment)
125
+ user_template.render(user_data)
126
+ rescue StandardError => e
127
+ e.message
128
+ end
129
+
130
+ def render_full_page(view)
131
+ template = paths.render_template.read
132
+
133
+ ERB.new(template).result(TemplateBinding.new(self, view).get_binding do
134
+ render_template(view)
135
+ end)
136
+ end
137
+
138
+ def screen_classes
139
+ classes = 'screen'
140
+ classes << ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
141
+ classes << ' dark-mode' if config.plugin.dark_mode == 'yes'
142
+ classes
143
+ end
144
+
145
+ private
146
+
147
+ # bindings must match the `GET /render/{view}.html` route in app.rb
148
+ class TemplateBinding
149
+ def initialize(context, view)
150
+ @screen_classes = context.screen_classes
151
+ @view = view
152
+ end
153
+
154
+ def get_binding = binding
155
+ end
156
+
157
+ def wrap_array(json)
158
+ json.is_a?(Array) ? { data: json } : json
159
+ end
160
+
161
+ def base_trmnl_data
162
+ {
163
+ 'trmnl' => {
164
+ 'user' => {
165
+ 'name' => 'name',
166
+ 'first_name' => 'first_name',
167
+ 'last_name' => 'last_name',
168
+ 'locale' => 'en',
169
+ 'time_zone' => 'Eastern Time (US & Canada)',
170
+ 'time_zone_iana' => 'America/New_York',
171
+ 'utc_offset' => -14400
172
+ },
173
+ 'device' => {
174
+ 'friendly_id' => 'ABC123',
175
+ 'percent_charged' => 85.0,
176
+ 'wifi_strength' => 90,
177
+ 'height' => 480,
178
+ 'width' => 800
179
+ },
180
+ 'system' => {
181
+ 'timestamp_utc' => Time.now.utc.to_i,
182
+ },
183
+ 'plugin_settings' => {
184
+ 'instance_name' => 'instance_name',
185
+ 'strategy' => config.plugin.strategy,
186
+ 'dark_mode' => config.plugin.dark_mode,
187
+ 'polling_headers' => config.plugin.polling_headers_encoded,
188
+ 'polling_url' => config.plugin.polling_url_text,
189
+ 'custom_fields_values' => config.project.custom_fields
190
+ }
191
+ }
192
+ }
193
+ end
194
+
195
+ def liquid_environment
196
+ @liquid_environment ||= Liquid::Environment.build do |env|
197
+ env.register_filter(CustomFilters)
198
+
199
+ config.project.user_filters.each do |module_name, relative_path|
200
+ require paths.root_dir.join(relative_path)
201
+ env.register_filter(Object.const_get(module_name))
202
+ end
203
+ end
204
+ end
205
+
206
+ def write_user_data(data)
207
+ paths.create_cache_dir
208
+ paths.user_data.write(JSON.generate(data))
209
+ end
210
+ end
211
+ end
@@ -1,7 +1,8 @@
1
1
  require 'active_support'
2
2
 
3
- module TRMNLPreview
3
+ module TRMNLP
4
4
  module CustomFilters
5
+ # TODO: sync up with core
5
6
  def number_with_delimiter(*args)
6
7
  ActiveSupport::NumberHelper.number_to_delimited(*args)
7
8
  end
@@ -0,0 +1,56 @@
1
+ require 'xdg'
2
+
3
+ module TRMNLP
4
+ class Paths
5
+ attr_reader :root_dir
6
+
7
+ def initialize(root_dir)
8
+ @root_dir = Pathname.new(root_dir)
9
+ @xdg = XDG.new
10
+ end
11
+
12
+ # --- trmnlp library ---
13
+
14
+ def gem_dir = Pathname.new(__dir__).join('..', '..').expand_path
15
+
16
+ def templates_dir = gem_dir.join('templates')
17
+
18
+ # --- directories ---
19
+
20
+ def src_dir = root_dir.join('src')
21
+
22
+ def build_dir = root_dir.join('_build')
23
+ def create_build_dir = build_dir.mkpath
24
+
25
+ def app_config_dir = xdg.config_home.join('trmnlp')
26
+
27
+ def cache_dir = xdg.cache_home.join('trmnl')
28
+ def create_cache_dir = cache_dir.mkpath
29
+
30
+ def valid? = trmnlp_config.exist?
31
+
32
+ # --- files ---
33
+
34
+ def trmnlp_config = root_dir.join('.trmnlp.yml')
35
+
36
+ def plugin_config = src_dir.join('settings.yml')
37
+
38
+ def template(view) = src_dir.join("#{view}.liquid")
39
+
40
+ def app_config = app_config_dir.join('config.yml')
41
+
42
+ def user_data = cache_dir.join('data.json')
43
+
44
+ def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
45
+
46
+ def src_files = src_dir.glob('*').select(&:file?)
47
+
48
+ # --- utilities ---
49
+
50
+ def expand(path) = Pathname.new(path).expand_path(root_dir)
51
+
52
+ private
53
+
54
+ attr_reader :xdg
55
+ end
56
+ end
@@ -3,7 +3,7 @@ require 'mini_magick'
3
3
  require 'puppeteer-ruby'
4
4
  require 'base64'
5
5
 
6
- module TRMNLPreview
6
+ module TRMNLP
7
7
  class ScreenGenerator
8
8
 
9
9
  def initialize(html, opts = {})
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ VERSION = "0.5.1".freeze
5
+ end
data/lib/trmnlp.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP; end
4
+ require 'oj'
5
+ Oj.mimic_JSON()
6
+ require_relative "trmnlp/config"
7
+ require_relative "trmnlp/context"
8
+ require_relative "trmnlp/version"
9
+
10
+ module TRMNLP
11
+ VIEWS = %w{full half_horizontal half_vertical quadrant}
12
+
13
+ class Error < StandardError; end
14
+ end
@@ -0,0 +1,14 @@
1
+ # TRMNLP configuration
2
+ # {{ env.VARIABLE }} interpolation is available here
3
+ ---
4
+ # auto-reload when files change (`watch: false` to disable)
5
+ watch:
6
+ - .trmnlp.yml
7
+ - src
8
+
9
+ # values of custom fields (defined in src/settings.yml)
10
+ custom_fields: {}
11
+
12
+ # override variables
13
+ variables:
14
+ trmnl: {}
@@ -0,0 +1,25 @@
1
+ #! /bin/bash
2
+
3
+ if command -v trmnlp &> /dev/null
4
+ then
5
+ echo "Starting trmnlp..."
6
+ trmnlp serve
7
+ exit
8
+ fi
9
+
10
+ if command -v docker &> /dev/null
11
+ then
12
+ echo "Running trmnl/trmnlp container..."
13
+ docker run -p 4567:4567 -v .:/plugin trmnl/trmnlp
14
+ exit
15
+ fi
16
+
17
+ echo "Install the trmnl_preview gem:
18
+
19
+ gem install trmnl_preview
20
+
21
+ Or install Docker:
22
+
23
+ https://docs.docker.com/get-docker/"
24
+
25
+ exit 1
@@ -0,0 +1 @@
1
+ full!
@@ -0,0 +1 @@
1
+ half horizontal!
@@ -0,0 +1 @@
1
+ half vertical!
@@ -0,0 +1 @@
1
+ quadrant!
@@ -0,0 +1,15 @@
1
+ #
2
+ # Changes to this file will be overwritten by `trmnlp pull`.
3
+ #
4
+ # Docs: https://help.usetrmnl.com/en/articles/10542599-importing-and-exporting-private-plugins#h_581fb988f0
5
+ #
6
+ ---
7
+ strategy: polling
8
+ no_screen_padding: 'no'
9
+ dark_mode: 'no'
10
+ static_data: ''
11
+ polling_verb: get
12
+ polling_url: ''
13
+ polling_headers: ''
14
+ name: My Plugin
15
+ refresh_interval: 1440