trmnl_preview 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e965687c88e23b15e8727f9b871b6a2529bc28c388fa2e8733fb5034915ec405
4
- data.tar.gz: c1299d3ce7b27a13be79d0018535c8f1b64171f7154d9dd1f425eeacbcbc0129
3
+ metadata.gz: 51839346d4b2bd24698655d8e6a86a1a93aac73d2882a5b907072db9851ffa03
4
+ data.tar.gz: 0e54398757e865c8907f3b65b4046519dba2dced17d6d62ce3342b89ce2a5d75
5
5
  SHA512:
6
- metadata.gz: d507258ba8a315daae87b83dc5609f2aa99c9d1c674f7067cd2b67b84382a4746c8f299c7c3d0e3e9cc4f378846ca3f5696f4aa8f1dcb65c6eec8b6e44f46d74
7
- data.tar.gz: 02231d9584b0b24a4b7b2749c85cbf14af2c3b6d5321c63dc03bf6e8bb7655b03ee37d86ba69b7ac16ebb3acb367cad2bf54ec437e405a1a842eb1f9263090f3
6
+ metadata.gz: 2e17bf9dba3a94a153c0182493d7b2ee597ba70b54177793fedae27a154817de8a79eb108a50194b5b5e0f1d2c012ff0b9047b9457cbdf52ad9b5e4d6331c6ab
7
+ data.tar.gz: a66a9b16b7c3512d018bd10a2444a7cfa4ecf335e9fd580583f27ac4cf6030c810bd48ab20878e3ec704b8f0faf64b7bc6e28ad08e585d1bf51ea3a01d0575fb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Plugin Migration Strategy
6
+
7
+ The plugin directory structure has changed to better align with the [plugin archive format](https://help.usetrmnl.com/en/articles/10542599-importing-and-exporting-private-plugins#h_581fb988f0).
8
+
9
+ Here is a migration strategy for existing plugin repositories:
10
+
11
+ 1. Create `.trmnlp.yml` and bring over preview settings from `config.toml` - [see README](README.md)
12
+ 2. Rename directory `views/` to `src/`
13
+ 3. Create `src/settings.yml` and bring over plugin settings from `config.toml` - [see TRMNL docs](https://help.usetrmnl.com/en/articles/10542599-importing-and-exporting-private-plugins#h_581fb988f0)
14
+ 4. Delete `config.toml`
15
+
16
+ ### Changes
17
+
18
+ - Change plugin directory structure (see README for details)
19
+ - Add `login`, `push`, and `pull` commands
20
+ - Bring up-to-date with latest private plugin features:
21
+ - Add `static` strategy
22
+ - Add polling features: multiple URLs, new verbs, and request body
23
+ - Add settings `dark_mode`, `no_screen_padding`, `custom_fields`
24
+ - Add interpolation of custom fields in `polling\_\*` options
25
+ - Add `{{ trmnl }}` variables
26
+ - Add `watch` config
27
+ - Add interpolation of environment variables in `.trmnlp.yml` via `{{ env }}`
28
+ - Add auto-reload when `.trmnlp.yml` or `settings.yml` changes
29
+ - Add variable display
30
+ - Fix crash when #poll_data fails (#12)
31
+ - Fix git runtime error in Docker container (#12)
32
+
33
+
34
+
3
35
  ## 0.3.2
4
36
 
5
37
  - Add bitmap rendering
data/README.md CHANGED
@@ -10,20 +10,25 @@ The server watches the filesystem for changes to the Liquid templates, seamlessl
10
10
 
11
11
  ## Creating a Plugin
12
12
 
13
- This is the structure of a plugin repository.
13
+ This is the structure of a plugin project.
14
14
 
15
15
  ```
16
- views/
16
+ .trmnlp.yml
17
+ src/
17
18
  full.liquid
18
19
  half_horizontal.liquid
19
20
  half_vertical.liquid
20
21
  quadrant.liquid
21
- config.toml
22
+ settings.yml
22
23
  ```
23
24
 
24
- See [config.example.toml](config.example.toml) for an example config.
25
+ ## Syncing Plugin With TRMNL Server
25
26
 
26
- The [trmnl-hello](https://github.com/schrockwell/trmnl-hello) repository is provided as a jumping-off point for creating new plugins. Simply fork the repo, clone it, and start hacking.
27
+ ```sh
28
+ trmnl login # authenticate with TRMNL account
29
+ trmnl pull [id] # download (plugin ID required on first pull only)
30
+ trmnl push # upload
31
+ ```
27
32
 
28
33
  ## Running the Server (Docker)
29
34
 
@@ -31,7 +36,7 @@ The [trmnl-hello](https://github.com/schrockwell/trmnl-hello) repository is prov
31
36
  docker run \
32
37
  -p 4567:4567 \
33
38
  -v /path/to/plugin/on/host:/plugin \
34
- schrockwell/trmnlp
39
+ trmnl/trmnlp
35
40
  ```
36
41
 
37
42
  ## Running the Server (Local Ruby)
@@ -43,29 +48,53 @@ Prerequisites:
43
48
  - Firefox
44
49
  - ImageMagick
45
50
 
46
- In the plugin repository:
51
+ In the plugin project:
47
52
 
48
53
  ```sh
49
54
  gem install trmnl_preview
50
- trmnlp serve # Starts the server
55
+ trmnlp serve
51
56
  ```
52
57
 
53
- ## Usage Notes
58
+ ## `./.trmnlp.yml` Reference (Project Config)
59
+
60
+ The `.trmnlp.yml` file lives in the root of the plugin project, and is for configuring the local dev server.
61
+
62
+ System environment variables are made available in the `{{ env }}` Liquid varible in this file only. This can be used to safely
63
+ supply plugin secrets, like API keys.
64
+
65
+ All fields are optional.
54
66
 
55
- When the strategy is "polling", the specified URL will be fetched once, when the server starts.
67
+ ```yaml
68
+ # {{ env.VARIABLE }} interpolation is available here
69
+ ---
70
+ # auto-reload when files change (`watch: false` to disable)
71
+ watch:
72
+ - src
73
+ - .trmnlp.yml
74
+
75
+ # values of custom fields (defined in src/settings.yml)
76
+ custom_fields:
77
+ station: "{{ env.ICAO }}"
78
+
79
+ # override variables
80
+ variables:
81
+ trmnl:
82
+ user:
83
+ name: Peter Quill
84
+ plugin_settings:
85
+ instance_name: Kevin Bacon Facts
86
+
87
+ ```
56
88
 
57
- When the strategy is "webhook", payloads can be POSTed to the `/webhook` endpoint. They are saved to `tmp/data.json` for future renders.
89
+ ## `./src/settings.yml` Reference (Plugin Config)
58
90
 
59
- ## `config.toml` Reference
91
+ The `settings.yml` file is part of the plugin definition.
60
92
 
61
- - `strategy` - Either "polling" or "webhook"
62
- - `url` - The URL from which to fetch JSON data (polling strategy only)
63
- - `live_render` - Set to `false` to disable automatic rendering when Liquid templates change (default `true`)
64
- - `[polling_headers]` - A section of headers to append to the HTTP poll request (polling strategy only)
93
+ See [TRMNL documentation](https://help.usetrmnl.com/en/articles/10542599-importing-and-exporting-private-plugins#h_581fb988f0) for details on this file's contents.
65
94
 
66
95
  ## Contributing
67
96
 
68
- Bug reports and pull requests are welcome on GitHub at https://github.com/schrockwell/trmnl_preview.
97
+ Bug reports and pull requests are welcome on GitHub at https://github.com/usetrmnl/trmnlp.
69
98
 
70
99
  ## License
71
100
 
data/bin/trmnlp ADDED
@@ -0,0 +1,14 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require "thor"
4
+
5
+ require_relative '../lib/trmnlp/cli'
6
+
7
+ begin
8
+ TRMNLP::CLI.start
9
+ rescue TRMNLP::Error => e
10
+ puts "Error: #{e.message}"
11
+ exit 1
12
+ rescue Interrupt
13
+ exit 1
14
+ end
@@ -0,0 +1,62 @@
1
+ require 'faraday'
2
+ require 'faraday/multipart'
3
+
4
+ require_relative 'config'
5
+
6
+ module TRMNLP
7
+ class APIClient
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def get_plugin_setting_archive(id)
13
+ response = conn.get("plugin_settings/#{id}/archive")
14
+
15
+ if response.status == 200
16
+ temp_file = Tempfile.new(["plugin_settings_#{id}", '.zip'])
17
+ temp_file.binmode
18
+ temp_file.write(response.body)
19
+ temp_file.rewind
20
+
21
+ # return the path to the temp file
22
+ Pathname.new(temp_file.path)
23
+ else
24
+ raise Error, "failed to download plugin settings archive: #{response.status} #{response.body}"
25
+ end
26
+ end
27
+
28
+ def post_plugin_setting_archive(id, path)
29
+ payload = {
30
+ file: Faraday::Multipart::FilePart.new(path, 'application/zip')
31
+ }
32
+
33
+ response = conn.post("plugin_settings/#{id}/archive", payload)
34
+
35
+ if response.status == 200
36
+ true
37
+ else
38
+ raise Error, "failed to upload plugin settings archive: #{response.status} #{response.body}"
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :config
45
+
46
+ def api_uri = config.app.api_uri
47
+
48
+ def conn
49
+ @conn ||= Faraday.new(url: api_uri, headers:) do |f|
50
+ f.request :multipart
51
+ end
52
+ end
53
+
54
+
55
+ def headers
56
+ {
57
+ 'Authorization' => "Bearer #{config.app.api_key}",
58
+ 'User-Agent' => "trmnlp/#{VERSION}",
59
+ }
60
+ end
61
+ end
62
+ end
@@ -6,7 +6,7 @@ require 'sinatra/base'
6
6
  require_relative 'context'
7
7
  require_relative 'screen_generator'
8
8
 
9
- module TRMNLPreview
9
+ module TRMNLP
10
10
  class App < Sinatra::Base
11
11
  # Sinatra settings
12
12
  set :views, File.join(File.dirname(__FILE__), '..', '..', 'web', 'views')
@@ -15,25 +15,28 @@ module TRMNLPreview
15
15
  def initialize(*args)
16
16
  super
17
17
 
18
- begin
19
- @context = Context.new(settings.user_dir)
20
- rescue StandardError => e
21
- puts e.message
22
- exit 1
23
- end
18
+ @context = settings.context
19
+
20
+ @context.poll_data
24
21
 
25
- @context.poll_data if @context.strategy == 'polling'
22
+ @context.start_filewatcher if @context.config.project.live_render?
26
23
 
27
24
  @live_reload_clients = []
28
- @context.on_view_change do |view|
25
+ @context.on_view_change do |view, user_data|
29
26
  @live_reload_clients.each do |ws|
30
- ws.send('reload')
27
+ payload = {
28
+ 'type' => 'reload',
29
+ 'view' => view,
30
+ 'user_data' => user_data
31
+ }
32
+
33
+ ws.send(payload.to_json)
31
34
  end
32
35
  end
33
36
  end
34
37
 
35
38
  post '/webhook' do
36
- @context.set_data(request.body.read)
39
+ @context.put_webhook(request.body.read)
37
40
  "OK"
38
41
  end
39
42
 
@@ -41,6 +44,11 @@ module TRMNLPreview
41
44
  redirect '/full'
42
45
  end
43
46
 
47
+ get '/data' do
48
+ content_type :json
49
+ JSON.pretty_generate(@context.user_data)
50
+ end
51
+
44
52
  get '/live_reload' do
45
53
  ws = Faye::WebSocket.new(request.env)
46
54
 
@@ -63,12 +71,16 @@ module TRMNLPreview
63
71
  VIEWS.each do |view|
64
72
  get "/#{view}" do
65
73
  @view = view
66
- @live_reload = @context.live_render
74
+ @user_data = JSON.pretty_generate(@context.user_data)
75
+ @live_reload = @context.config.project.live_render?
76
+
67
77
  erb :index
68
78
  end
69
79
 
70
80
  get "/render/#{view}.html" do
71
81
  @view = view
82
+ @screen_classes = @context.screen_classes
83
+
72
84
  erb :render_html do
73
85
  @context.render_template(view)
74
86
  end
data/lib/trmnlp/cli.rb ADDED
@@ -0,0 +1,51 @@
1
+ require 'thor'
2
+
3
+ require_relative '../trmnlp'
4
+ require_relative '../trmnlp/commands'
5
+
6
+ module TRMNLP
7
+ class CLI < Thor
8
+ package_name 'trmnlp'
9
+
10
+ class_option :dir, type: :string, default: Dir.pwd, aliases: '-d',
11
+ desc: 'Plugin directory'
12
+
13
+ def self.exit_on_failure? = true
14
+
15
+ desc 'build', 'Generate static HTML files'
16
+ def build
17
+ Commands::Build.new(options).call
18
+ end
19
+
20
+ desc 'login', 'Authenticate with TRMNL server'
21
+ def login
22
+ Commands::Login.new(options).call
23
+ end
24
+
25
+ desc 'pull [id]', 'Download plugin settings from TRMNL server'
26
+ method_option :force, type: :boolean, default: false, aliases: '-f',
27
+ desc: 'Skip confirmation prompts'
28
+ def pull(plugin_settings_id = nil)
29
+ Commands::Pull.new(options).call(plugin_settings_id)
30
+ end
31
+
32
+ desc 'push [id]', 'Upload plugin settings to TRMNL server'
33
+ method_option :force, type: :boolean, default: false, aliases: '-f',
34
+ desc: 'Skip confirmation prompts'
35
+ def push(plugin_settings_id = nil)
36
+ Commands::Push.new(options).call(plugin_settings_id)
37
+ end
38
+
39
+ desc 'serve', 'Start a local dev server'
40
+ method_option :bind, type: :string, default: '127.0.0.1', aliases: '-b', desc: 'Bind address'
41
+ method_option :port, type: :numeric, default: 4567, aliases: '-p', desc: 'Port number'
42
+ def serve
43
+ Commands::Serve.new(options).call
44
+ end
45
+
46
+ desc 'version', 'Show version'
47
+ def version
48
+ puts VERSION
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,23 @@
1
+ require_relative '../context'
2
+
3
+ module TRMNLP
4
+ module Commands
5
+ class Base
6
+ def initialize(options)
7
+ @options = options
8
+ @context = Context.new(options.dir)
9
+ end
10
+
11
+ def call
12
+ raise NotImplementedError
13
+ end
14
+
15
+ private
16
+
17
+ attr_accessor :options, :context
18
+
19
+ def config = context.config
20
+ def paths = context.paths
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ require_relative 'base'
2
+
3
+ module TRMNLP
4
+ module Commands
5
+ class Build < Base
6
+ def call
7
+ context = Context.new(options.dir)
8
+ context.validate!
9
+ context.poll_data
10
+ context.paths.create_build_dir
11
+
12
+ VIEWS.each do |view|
13
+ output_path = context.paths.build_dir.join("#{view}.html")
14
+ puts "Writing #{output_path}..."
15
+ output_path.write(context.render_full_page(view))
16
+ end
17
+
18
+ puts "Done!"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ require_relative 'base'
2
+
3
+ module TRMNLP
4
+ module Commands
5
+ class Login < Base
6
+ def call
7
+ if config.app.logged_in?
8
+ anonymous_key = config.app.api_key[0..10] + '*' * (config.app.api_key.length - 11)
9
+ puts "Currently authenticated as: #{anonymous_key}"
10
+ end
11
+
12
+ puts "Please visit #{config.app.account_uri} to grab your API key, then paste it here."
13
+
14
+ print "API Key: "
15
+ api_key = STDIN.gets.chomp
16
+ raise Error, "API key cannot be empty" if api_key.empty?
17
+
18
+ config.app.api_key = api_key
19
+ config.app.save
20
+
21
+ puts "Saved changes to #{paths.app_config}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,45 @@
1
+ require 'zip'
2
+
3
+ require_relative 'base'
4
+ require_relative '../api_client'
5
+
6
+ module TRMNLP
7
+ module Commands
8
+ class Pull < Base
9
+ def call(plugin_settings_id)
10
+ context.validate!
11
+
12
+ raise Error, "please run `trmnlp login`" unless config.app.logged_in?
13
+
14
+ plugin_settings_id ||= config.plugin.id
15
+ raise Error, 'plugin ID must be specified' if plugin_settings_id.nil?
16
+
17
+ unless options.force
18
+ print "Local plugin files will be overwritten. Are you sure? (y/n) "
19
+ answer = $stdin.gets.chomp.downcase
20
+ raise Error, 'aborting' unless answer == 'y' || answer == 'yes'
21
+ end
22
+
23
+ api = APIClient.new(config)
24
+ temp_path = api.get_plugin_setting_archive(plugin_settings_id)
25
+ size = 0
26
+
27
+ begin
28
+ Zip::File.open(temp_path) do |zip_file|
29
+ zip_file.each do |entry|
30
+ dest_path = paths.src_dir.join(entry.name)
31
+ dest_path.dirname.mkpath
32
+ zip_file.extract(entry, dest_path) { true } # overwrite existing
33
+ end
34
+ end
35
+
36
+ size = File.size(temp_path)
37
+ ensure
38
+ temp_path.delete
39
+ end
40
+
41
+ puts "Downloaded plugin (#{size} bytes)"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
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(plugin_settings_id)
10
+ context.validate!
11
+
12
+ raise Error, "please run `trmnlp login`" unless config.app.logged_in?
13
+
14
+ plugin_settings_id ||= config.plugin.id
15
+ raise Error, 'plugin ID must be specified' if plugin_settings_id.nil?
16
+
17
+ unless options.force
18
+ print "Plugin settings on the server will be overwritten. Are you sure? (y/n) "
19
+ answer = $stdin.gets.chomp.downcase
20
+ raise Error, 'aborting' unless answer == 'y' || answer == 'yes'
21
+ end
22
+
23
+ api = APIClient.new(config)
24
+ size = 0
25
+
26
+ Tempfile.create(binmode: true) do |temp_file|
27
+ Zip::File.open(temp_file.path, Zip::File::CREATE) do |zip_file|
28
+ paths.src_files.each do |file|
29
+ zip_file.add(File.basename(file), file)
30
+ end
31
+ end
32
+
33
+ api.post_plugin_setting_archive(plugin_settings_id, temp_file.path)
34
+
35
+ size = File.size(temp_file.path)
36
+ end
37
+
38
+
39
+ puts "Uploaded plugin (#{size} bytes)"
40
+ end
41
+ end
42
+ end
43
+ 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,39 @@
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
+ private
33
+
34
+ attr_reader :paths
35
+
36
+ def read_config = paths.app_config.exist? ? YAML.safe_load(paths.app_config.read) : {}
37
+ end
38
+ end
39
+ 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