janela 0.7.0 → 0.8.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 +24 -0
- data/README.md +5 -5
- data/UPGRADING.md +63 -0
- data/app/assets/stylesheets/janela.css +21 -0
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +1 -0
- data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
- data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
- data/docs/decisions/037-what-1-0-means.md +172 -0
- data/docs/decisions/INDEX.md +13 -8
- data/docs/roadmap.md +97 -0
- data/docs/theming.md +177 -0
- data/lib/janela/definition.rb +8 -1
- data/lib/janela/doctor.rb +121 -32
- data/lib/janela/model.rb +6 -1
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +47 -3
- metadata +21 -14
data/lib/janela/doctor.rb
CHANGED
|
@@ -134,22 +134,43 @@ module Janela
|
|
|
134
134
|
|
|
135
135
|
# Ransack's allowlist is per class, so a dimension read through an
|
|
136
136
|
# association needs the associated model to allow the attribute too.
|
|
137
|
+
#
|
|
138
|
+
# Grouped by the allowlist that has to be written rather than by the
|
|
139
|
+
# dimension that led here, because that is what the finding is about and
|
|
140
|
+
# there is one of it. Two dimensions reading through the same
|
|
141
|
+
# association used to produce two findings whose suggested lines
|
|
142
|
+
# contradicted each other, so a host pasting both kept the second and
|
|
143
|
+
# silently lost the first; an STI family produced a copy of each per
|
|
144
|
+
# class, since a subclass shares its parent's associations (#44).
|
|
137
145
|
def through_dimensions_without_an_allowlist
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
missing_allowlist_entries.map do |klass, entry|
|
|
147
|
+
allowed = klass.ransackable_attributes.map(&:to_s)
|
|
148
|
+
Finding.new(severity: :error,
|
|
149
|
+
summary: "#{klass} does not allow filtering on #{entry[:columns].sort.to_sentence}",
|
|
150
|
+
detail: " Ransack's allowlist is per class, and these read through an association:\n" \
|
|
151
|
+
"#{entry[:sources].sort.map { |source| " #{source}" }.join("\n")}\n" \
|
|
152
|
+
" Add to #{klass}:\n" \
|
|
153
|
+
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
154
|
+
"%w[#{(allowed + entry[:columns].sort).uniq.join(' ')}]")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Keyed on the associated class rather than on the declaration, because a
|
|
159
|
+
# subclass may reflect an association its parent does not: two classes
|
|
160
|
+
# sharing a declaration and an association share a fix, and two that do
|
|
161
|
+
# not have a fix each. The source list names declaring classes, so an STI
|
|
162
|
+
# family is one line rather than one per subclass.
|
|
163
|
+
def missing_allowlist_entries
|
|
164
|
+
janela_models.each_with_object({}) do |model, missing|
|
|
165
|
+
model.janela.dimensions.values.select(&:through).each do |dimension|
|
|
140
166
|
association = model.reflect_on_association(dimension.through)
|
|
141
167
|
next unless association
|
|
168
|
+
next if association.klass.ransackable_attributes.map(&:to_s).include?(dimension.column.to_s)
|
|
142
169
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
summary: "#{association.klass} does not allow filtering on #{dimension.column}",
|
|
148
|
-
detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
|
|
149
|
-
"#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
|
|
150
|
-
"#{association.klass}:\n" \
|
|
151
|
-
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
152
|
-
"%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
|
|
170
|
+
entry = missing[association.klass] ||= { columns: [], sources: [] }
|
|
171
|
+
entry[:columns] |= [ dimension.column.to_s ]
|
|
172
|
+
entry[:sources] |= [ "#{model.janela.declared_by}'s #{dimension.name.inspect} dimension, " \
|
|
173
|
+
"through #{dimension.through.inspect}" ]
|
|
153
174
|
end
|
|
154
175
|
end
|
|
155
176
|
end
|
|
@@ -158,11 +179,23 @@ module Janela
|
|
|
158
179
|
# but one written into the host's own Ruby is source like any other
|
|
159
180
|
# identifier this doctor already greps for (ADR 015, ADR 021, ADR 025).
|
|
160
181
|
def hardcoded_disallowed_predicates
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
janela_definitions.flat_map do |definition|
|
|
183
|
+
definition.dimensions.values.flat_map { |dimension| disallowed_uses(definition.declared_by, dimension) }
|
|
163
184
|
end
|
|
164
185
|
end
|
|
165
186
|
|
|
187
|
+
# One finding per declaration rather than one per class that inherits it.
|
|
188
|
+
# An STI family shares its parent's dimensions (ADR 031), so iterating
|
|
189
|
+
# models multiplied the same finding by the size of the family (#44).
|
|
190
|
+
def janela_definitions
|
|
191
|
+
janela_models.filter_map(&:janela).uniq(&:declared_by)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The match is a string in a file, and nothing here establishes that the
|
|
195
|
+
# file filters this model, or filters anything: a comment warning against
|
|
196
|
+
# the predicate reads the same as a call. So the finding says what was
|
|
197
|
+
# seen and admits the limit, and is a warning rather than an error
|
|
198
|
+
# (ADR 021, ADR 035, #51).
|
|
166
199
|
def disallowed_uses(model, dimension)
|
|
167
200
|
pattern = /\b#{Regexp.escape(dimension.ransack_name)}(_\w+)/
|
|
168
201
|
source_files.flat_map do |file|
|
|
@@ -174,10 +207,12 @@ module Janela
|
|
|
174
207
|
|
|
175
208
|
key = "#{dimension.ransack_name}_#{predicate}"
|
|
176
209
|
allowed = dimension.allowed_predicates.map { |p| "#{dimension.ransack_name}_#{p}" }
|
|
177
|
-
Finding.new(severity: :
|
|
210
|
+
Finding.new(severity: :warning,
|
|
178
211
|
summary: "#{model} does not allow #{key}",
|
|
179
|
-
detail: " #{file.relative_path_from(@root)}
|
|
180
|
-
"
|
|
212
|
+
detail: " #{file.relative_path_from(@root)} mentions #{key}, which Janela refuses on " \
|
|
213
|
+
"#{model} (ADR 025). Allowed here: #{allowed.join(', ')}.\n" \
|
|
214
|
+
" This reads your source for the name and cannot tell which model a match\n" \
|
|
215
|
+
" belongs to, so it may be another model's filter, or prose about one.")
|
|
181
216
|
end
|
|
182
217
|
end
|
|
183
218
|
end
|
|
@@ -186,14 +221,35 @@ module Janela
|
|
|
186
221
|
# and since ADR 032 every dashboard raises rather than answering with
|
|
187
222
|
# every row. Reported so it is found here rather than by a visitor.
|
|
188
223
|
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
224
|
+
# ADR 032 called this check exact, on the reasoning that the method is
|
|
225
|
+
# defined or it is not. That was wrong, and ADR 035 supersedes it: Pundit
|
|
226
|
+
# defines policy_scope the moment it is included, whether or not the
|
|
227
|
+
# model has a policy, so for the commonest authorisation library defined
|
|
228
|
+
# and works are different questions. The check makes the call Janela
|
|
229
|
+
# makes rather than reading the method table (#51).
|
|
192
230
|
def unscoped_reads
|
|
193
|
-
parent =
|
|
231
|
+
parent = parent_controller
|
|
194
232
|
return unless parent
|
|
195
|
-
return
|
|
233
|
+
return undefined_policy_scope(parent) unless answers_policy_scope?(parent)
|
|
234
|
+
return unless Janela::Frame.table_exists? && Janela::Snapshot.table_exists?
|
|
235
|
+
|
|
236
|
+
[ Janela::Frame, Janela::Snapshot ].filter_map do |model|
|
|
237
|
+
outcome, answer = ask_for_scope(model)
|
|
238
|
+
next unless outcome == :raised
|
|
239
|
+
|
|
240
|
+
Finding.new(severity: :error,
|
|
241
|
+
summary: "#{parent}'s policy_scope raises for #{model}, so every pane will too",
|
|
242
|
+
detail: " Janela makes this exact call on every dashboard request, and got:\n" \
|
|
243
|
+
" #{answer.class}: #{answer.message.to_s.lines.first.to_s.strip.truncate(140)}\n" \
|
|
244
|
+
" Janela's own records go through your policy like any other model's, so\n" \
|
|
245
|
+
" they need whatever yours need: with Pundit that is a policy class for\n" \
|
|
246
|
+
" #{model}. docs/multi-tenancy.md has the wiring.")
|
|
247
|
+
end
|
|
248
|
+
rescue StandardError
|
|
249
|
+
nil # no database to ask yet
|
|
250
|
+
end
|
|
196
251
|
|
|
252
|
+
def undefined_policy_scope(parent)
|
|
197
253
|
Finding.new(severity: :error,
|
|
198
254
|
summary: "#{parent} defines no policy_scope, so every pane will raise",
|
|
199
255
|
detail: " Janela asks your controller what may be read and refuses to guess.\n" \
|
|
@@ -204,15 +260,19 @@ module Janela
|
|
|
204
260
|
" narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
|
|
205
261
|
end
|
|
206
262
|
|
|
263
|
+
def answers_policy_scope?(parent)
|
|
264
|
+
parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
|
|
265
|
+
end
|
|
266
|
+
|
|
207
267
|
# A host whose policy filters frames by owner, but which never tells
|
|
208
268
|
# Janela what owns a new one, creates frames its own scope then hides.
|
|
209
269
|
# The failure is silent, and a typo in the method name looks the same as
|
|
210
270
|
# not defining it, which is the cost of asking by duck typing (ADR 019).
|
|
211
271
|
def frames_nobody_will_own
|
|
212
|
-
parent =
|
|
213
|
-
return unless parent
|
|
272
|
+
parent = parent_controller
|
|
273
|
+
return unless parent && answers_policy_scope?(parent)
|
|
214
274
|
return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
|
|
215
|
-
return unless Janela::Frame.table_exists? &&
|
|
275
|
+
return unless Janela::Frame.table_exists? && owner_filtering(Janela::Frame) == :filtered
|
|
216
276
|
|
|
217
277
|
Finding.new(severity: :error,
|
|
218
278
|
summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
|
|
@@ -236,9 +296,9 @@ module Janela
|
|
|
236
296
|
# times in this repository's tests and once on the live demo within an
|
|
237
297
|
# afternoon of the column landing (#49).
|
|
238
298
|
def snapshots_nobody_will_see
|
|
239
|
-
parent =
|
|
299
|
+
parent = parent_controller
|
|
240
300
|
return unless parent && Janela::Snapshot.table_exists?
|
|
241
|
-
return unless
|
|
301
|
+
return unless owner_filtering(Janela::Snapshot) == :filtered
|
|
242
302
|
|
|
243
303
|
unowned = Janela::Snapshot.where(owner_id: nil).count
|
|
244
304
|
return if unowned.zero?
|
|
@@ -256,17 +316,46 @@ module Janela
|
|
|
256
316
|
# Asking the policy rather than reading its source: a scope that narrows
|
|
257
317
|
# an owned record is one that will hide an unowned one. Shared, because
|
|
258
318
|
# a frame and a snapshot are the same question asked of two tables.
|
|
259
|
-
|
|
260
|
-
|
|
319
|
+
#
|
|
320
|
+
# Three-valued on purpose. Collapsing a raise into false is how both
|
|
321
|
+
# owner checks came to be silent for every host whose policy reaches for
|
|
322
|
+
# the signed in user: a raise is a different fact from a scope that does
|
|
323
|
+
# not filter, and unscoped_reads is the check that reports it (ADR 035).
|
|
324
|
+
def owner_filtering(model)
|
|
325
|
+
outcome, answer = ask_for_scope(model)
|
|
326
|
+
return :raised if outcome == :raised
|
|
327
|
+
|
|
328
|
+
answer.to_sql.include?("owner") ? :filtered : :unfiltered
|
|
261
329
|
rescue StandardError
|
|
262
|
-
|
|
330
|
+
:raised
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# What Janela's controllers actually inherit. Janela.parent_controller is
|
|
334
|
+
# what a host asked for, and the two differ when it was named too late,
|
|
335
|
+
# which is how a check came to print "Janela's controllers inherit X"
|
|
336
|
+
# about a class that was not in the chain (ADR 035, #38).
|
|
337
|
+
def parent_controller
|
|
338
|
+
Janela::ApplicationController.superclass
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Asks the host's policy the way a request does. On a controller with no
|
|
342
|
+
# request, session, params and current_user are all unreachable, so a
|
|
343
|
+
# policy that touches any of them raises for a reason that is not the
|
|
344
|
+
# host's fault. Given a request they are empty instead, which is an
|
|
345
|
+
# unauthenticated visitor: the right thing for a check to ask about.
|
|
346
|
+
def ask_for_scope(model)
|
|
347
|
+
controller = parent_controller.allocate
|
|
348
|
+
controller.set_request!(ActionDispatch::TestRequest.create) if controller.respond_to?(:set_request!)
|
|
349
|
+
[ :ok, controller.send(:policy_scope, model) ]
|
|
350
|
+
rescue StandardError => e
|
|
351
|
+
[ :raised, e ]
|
|
263
352
|
end
|
|
264
353
|
|
|
265
354
|
# Whether an endpoint is public depends on what the host's controller
|
|
266
355
|
# does, which cannot be determined by reading, so this observes and says
|
|
267
356
|
# so rather than declaring anything safe.
|
|
268
357
|
def unauthenticated_endpoints
|
|
269
|
-
parent =
|
|
358
|
+
parent = parent_controller
|
|
270
359
|
return unless parent
|
|
271
360
|
|
|
272
361
|
filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
|
|
@@ -281,7 +370,7 @@ module Janela
|
|
|
281
370
|
|
|
282
371
|
def janela_models
|
|
283
372
|
Rails.application.eager_load!
|
|
284
|
-
ActiveRecord::Base.descendants.select { |model|
|
|
373
|
+
ActiveRecord::Base.descendants.select { |model| Janela.our_definition(model) }
|
|
285
374
|
rescue StandardError
|
|
286
375
|
[]
|
|
287
376
|
end
|
data/lib/janela/model.rb
CHANGED
|
@@ -29,9 +29,14 @@ module Janela
|
|
|
29
29
|
# does not exist yet, so it registers as it is created (ADR 031). An
|
|
30
30
|
# anonymous class has no route key to be addressed by; naming it is the
|
|
31
31
|
# host's move and declaring on it is the host's other one.
|
|
32
|
+
#
|
|
33
|
+
# A Definition rather than anything truthy: this is extended onto every
|
|
34
|
+
# model in the application, so `janela` may be a method the host wrote for
|
|
35
|
+
# its own reasons, and the host's own wins. Janela believes only what
|
|
36
|
+
# Janela built (#54).
|
|
32
37
|
def inherited(subclass)
|
|
33
38
|
super
|
|
34
|
-
Janela.register_subclass(subclass) if subclass.name && janela
|
|
39
|
+
Janela.register_subclass(subclass) if subclass.name && janela.is_a?(Definition)
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
private
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -27,7 +27,41 @@ module Janela
|
|
|
27
27
|
|
|
28
28
|
# Janela's controllers inherit from the host's, so the host's authentication
|
|
29
29
|
# and authorisation apply to dashboards with no configuration.
|
|
30
|
-
|
|
30
|
+
#
|
|
31
|
+
# Resolved once, when Janela::ApplicationController is first loaded. A host
|
|
32
|
+
# naming one after that point used to be ignored in silence: dashboards kept
|
|
33
|
+
# inheriting whatever was named first, so the host's authentication and its
|
|
34
|
+
# policy_scope were not the ones it had written, and nothing said so. Naming
|
|
35
|
+
# a different one too late raises instead (ADR 035, #38).
|
|
36
|
+
@parent_controller = "ApplicationController"
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
attr_reader :parent_controller
|
|
40
|
+
|
|
41
|
+
def parent_controller=(name)
|
|
42
|
+
inherited = inherited_controller
|
|
43
|
+
if inherited && inherited != name.to_s
|
|
44
|
+
raise Error, "Janela::ApplicationController already inherits #{inherited}, so naming " \
|
|
45
|
+
"#{name} now would do nothing: the superclass is resolved once, the first " \
|
|
46
|
+
"time the class is loaded. Set it earlier, in config/initializers/janela.rb, " \
|
|
47
|
+
"which runs before anything can load a Janela controller. config.to_prepare " \
|
|
48
|
+
"and config.after_initialize are both too late, and so is anything that runs " \
|
|
49
|
+
"once the application is serving."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@parent_controller = name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# What Janela::ApplicationController resolved to, or nil if it has not been
|
|
56
|
+
# loaded yet. Asked without forcing the autoload the question is about:
|
|
57
|
+
# const_defined? is true for a registered autoload, and autoload? stops
|
|
58
|
+
# being truthy only once the file has actually been loaded.
|
|
59
|
+
def inherited_controller
|
|
60
|
+
return nil if autoload?(:ApplicationController) || !const_defined?(:ApplicationController, false)
|
|
61
|
+
|
|
62
|
+
const_get(:ApplicationController, false).superclass.name
|
|
63
|
+
end
|
|
64
|
+
end
|
|
31
65
|
|
|
32
66
|
# The stylesheet Janela's own pages load on top of janela.css. Nil means the
|
|
33
67
|
# structural one only, which is what a host that has its own look wants. The
|
|
@@ -95,7 +129,7 @@ module Janela
|
|
|
95
129
|
# here until a restart, and a form offering it would fail to draw at all.
|
|
96
130
|
def self.definitions
|
|
97
131
|
Rails.application.eager_load!
|
|
98
|
-
registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize
|
|
132
|
+
registry.sort.filter_map { |_route_key, class_name| our_definition(class_name.safe_constantize) }
|
|
99
133
|
end
|
|
100
134
|
|
|
101
135
|
def self.definition!(route_key)
|
|
@@ -104,7 +138,17 @@ module Janela
|
|
|
104
138
|
Rails.application.eager_load! unless registry.key?(route_key)
|
|
105
139
|
class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
|
|
106
140
|
|
|
107
|
-
class_name.constantize
|
|
141
|
+
our_definition(class_name.constantize) ||
|
|
142
|
+
raise(NotFound, "#{route_key.inspect} is not a janela model")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Janela::Model is extended onto every model in the application, so `janela`
|
|
146
|
+
# is a question any of them can answer, and a host that answers it for its
|
|
147
|
+
# own reasons wins: its method is on its own singleton. Janela believes only
|
|
148
|
+
# what Janela built, rather than anything truthy that comes back (#54).
|
|
149
|
+
def self.our_definition(model)
|
|
150
|
+
definition = model.janela if model.respond_to?(:janela)
|
|
151
|
+
definition if definition.is_a?(Definition)
|
|
108
152
|
end
|
|
109
153
|
|
|
110
154
|
# What Janela can draw, so a gallery asks rather than reaching into
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: janela
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -171,9 +171,14 @@ files:
|
|
|
171
171
|
- docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md
|
|
172
172
|
- docs/decisions/033-a-snapshot-is-told-who-owns-it.md
|
|
173
173
|
- docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md
|
|
174
|
+
- docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md
|
|
175
|
+
- docs/decisions/036-janela-publishes-what-a-theme-may-target.md
|
|
176
|
+
- docs/decisions/037-what-1-0-means.md
|
|
174
177
|
- docs/decisions/INDEX.md
|
|
175
178
|
- docs/multi-tenancy.md
|
|
176
179
|
- docs/naming.md
|
|
180
|
+
- docs/roadmap.md
|
|
181
|
+
- docs/theming.md
|
|
177
182
|
- lib/janela.rb
|
|
178
183
|
- lib/janela/definition.rb
|
|
179
184
|
- lib/janela/dimension.rb
|
|
@@ -194,22 +199,24 @@ metadata:
|
|
|
194
199
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
195
200
|
rubygems_mfa_required: 'true'
|
|
196
201
|
post_install_message: |
|
|
197
|
-
Janela 0.
|
|
198
|
-
|
|
202
|
+
Janela 0.8.0 raises where it used to do nothing. One thing to act on,
|
|
203
|
+
and one to read.
|
|
199
204
|
|
|
200
|
-
1. If
|
|
201
|
-
raises
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
1. If you set Janela.parent_controller anywhere but an initializer, it
|
|
206
|
+
now raises instead of being ignored in silence. config.to_prepare
|
|
207
|
+
and config.after_initialize are both too late, and the README's own
|
|
208
|
+
layout recipe is a to_prepare block. Until now your dashboards kept
|
|
209
|
+
inheriting whatever was named first, so your authentication and
|
|
210
|
+
policy_scope were not the ones you wrote (ADR 035).
|
|
204
211
|
|
|
205
|
-
2.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
or re-enqueue (ADR 034).
|
|
212
|
+
2. bin/rails janela:doctor reports findings it could not reach before,
|
|
213
|
+
including a Pundit host with no policy for Janela::Frame or
|
|
214
|
+
Janela::Snapshot, which used to pass the doctor and raise on every
|
|
215
|
+
request. Expect things that were always true and never printed.
|
|
210
216
|
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
Also: the class names on Janela's own pages are no longer public API.
|
|
218
|
+
They are scoped under janela-page and may change in any release. What
|
|
219
|
+
your own markup contains is unchanged and is listed in docs/theming.md.
|
|
213
220
|
|
|
214
221
|
Steps: UPGRADING.md in this gem, or
|
|
215
222
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|