tunable 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8f46c7acc701e5042514bd5e5b38aaf6e7d55f36
4
+ data.tar.gz: d64dacc173796faffd043002b8984df92f2f744a
5
+ SHA512:
6
+ metadata.gz: 4f3b123409df4270709860c7f0f02ae43e738450905ef44494664a01ef54c583a5897ca723e8fc120e849c465b1a26fdd5d2060c369551fa59e2a1e479ae497f
7
+ data.tar.gz: 8c89d8944a34e84aa30c79ac91504aa120c31d6788bbe01f2ecabc61ea98f01a2c0098fae763458df75529a8f09153868d90eb1fea2f6c9b9fabe8f76b8ba1b3
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ *~
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source :rubygems
2
+
3
+ # specified in tuktuk.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ Tunable
2
+ =======
3
+
4
+ Pluggable settings for your AR models.
5
+
6
+ The code
7
+ --------
8
+
9
+ Let's set up notification settings for our Users.
10
+
11
+ ``` rb
12
+ class User < ActiveRecord::Base
13
+ include Tunable::Model
14
+
15
+ has_settings :notify => :activity, :new_messages, :weekly_report
16
+
17
+ end
18
+ ```
19
+
20
+ Now we can do:
21
+
22
+ ``` rb
23
+ user = User.create(:name => 'John Lennon')
24
+ user.get_setting(:notify, :activity) # => nil
25
+
26
+ user.settings = { notify: { activity: true } }
27
+ user.save
28
+
29
+ user.get_setting(:notify, :activity) # => true
30
+ ```
31
+
32
+ Tunable also lets you set defaults for your settings. Here's how.
33
+
34
+
35
+ ``` rb
36
+ class User < ActiveRecord::Base
37
+ include Tunable::Model
38
+
39
+ has_settings :notify => {
40
+ activity: { default: false },
41
+ new_messages: { default: true },
42
+ weekly_report: { default: true }
43
+ }
44
+
45
+ end
46
+ ```
47
+
48
+ Now we can do:
49
+
50
+ ``` rb
51
+ user = User.create(:name => 'John Lennon')
52
+ user.get_setting(:notify, :new_messages) # => true (default value)
53
+
54
+ user.settings = { notify: { new_messages: false } }
55
+ user.save
56
+
57
+ user.get_setting(:notify, :new_messages) # => false
58
+ ```
59
+
60
+ Tunable also provides a `main_settings` helper that sets up main level settings.
61
+
62
+ ``` rb
63
+ class User < ActiveRecord::Base
64
+ include Tunable::Model
65
+
66
+ # in this case we're not setting a default value for the :no_cookies setting
67
+ main_settings :no_cookies, :language => { :default => 'en' }
68
+
69
+ end
70
+ ```
71
+
72
+ Now let's see what happens.
73
+
74
+
75
+ ``` rb
76
+ user = User.create(:name => 'Paul MacCartney')
77
+ user.no_cookies # => nil
78
+ user.language # => 'en'
79
+
80
+ user.language = 'es'
81
+ user.no_cookies = true
82
+ user.save
83
+
84
+ user.no_cookies # => true
85
+ user.language # => 'es'
86
+ ```
87
+
88
+ You can also set a lambda to return the default setting for a model.
89
+
90
+
91
+ ```
92
+ class User < ActiveRecord::Base
93
+
94
+ main_settings :layout_color => {
95
+ :default => lambda { |user| user.is_admin? ? 'black' : 'blue' }
96
+ }
97
+
98
+ end
99
+ ```
100
+
101
+ Then:
102
+
103
+ ``` rb
104
+ user = User.create(:name => 'Ringo Starr', :admin => false)
105
+ user.layout_color # => 'blue'
106
+ user.admin = true
107
+ user.layout_color # => 'black'
108
+ ```
109
+
110
+ That's pretty much it.
111
+
112
+ Boring stuff
113
+ ------------
114
+
115
+ Copyright (c) Fork Ltd. (http://forkhq.com), released under the MIT license.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'spec/rake/spectask'
5
+
6
+ Spec::Rake::SpecTask.new do |t|
7
+ t.spec_files = FileList["spec/**/*_spec.rb"]
8
+ end
@@ -0,0 +1,23 @@
1
+ class TunableMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :settings do |t|
4
+ t.column :context, :string
5
+ t.column :key, :string
6
+ t.column :value, :string
7
+
8
+ t.column :settable_id, :integer
9
+ t.column :settable_type, :string
10
+ t.column :created_at, :datetime
11
+ end
12
+
13
+ add_index :settings, [:settable_id, :settable_type]
14
+ add_index :settings, [:settable_id, :context, :key], :unique => true
15
+ end
16
+
17
+ def self.down
18
+ remove_index :settings, :name => "index_settings_on_settable_id_and_settable_type"
19
+ remove_index :settings, :name => "index_settings_on_settable_id_and_context_and_key"
20
+
21
+ drop_table :settings
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ class TunableMigrationGenerator < Rails::Generator::Base
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template 'migration.rb', 'db/migrate', :migration_file_name => "acts_as_settable_migration"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ require 'tunable/emoji_fix'
2
+
3
+ module Tunable
4
+
5
+ module ActiveRecordExtensions
6
+
7
+ def import(columns, values, options = {})
8
+ if columns.length != values[0].length
9
+ raise ArgumentError "Column and row lengths must match!"
10
+ end
11
+
12
+ columns_array = columns.map { |column| connection.quote_column_name(column) }
13
+
14
+ values_array = values.map do |arr|
15
+ row_values = []
16
+ arr.each_with_index do |val, i|
17
+ row_values << connection.quote(val)
18
+ end
19
+ row_values.join(',')
20
+ end
21
+
22
+ values_array = values_array.map{ |str| str.gsub(EmojiFix::REGEX, " ") }
23
+ insert_method = options[:method] || 'INSERT'
24
+ sql = "#{insert_method} INTO `#{self.table_name}` (#{columns_array.join(',')}) VALUES "
25
+
26
+ # sqlite3 does not support multiple insert/replace,
27
+ # so we need to generate a transaction with separate queries
28
+ if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
29
+ values_array.each do |vals|
30
+ row_sql = "#{sql}(#{vals})"
31
+ connection.execute(row_sql)
32
+ end
33
+ else
34
+ sql += "(#{values_array.join('),(')})"
35
+ connection.execute(sql)
36
+ end
37
+ end
38
+
39
+ end
40
+
41
+ end
@@ -0,0 +1,5 @@
1
+ module EmojiFix
2
+
3
+ REGEX = /[\u{203C}\u{2049}\u{20E3}\u{2122}\u{2139}\u{2194}-\u{2199}\u{21A9}-\u{21AA}\u{231A}-\u{231B}\u{23E9}-\u{23EC}\u{23F0}\u{23F3}\u{24C2}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2600}-\u{2601}\u{260E}\u{2611}\u{2614}-\u{2615}\u{261D}\u{263A}\u{2648}-\u{2653}\u{2660}\u{2663}\u{2665}-\u{2666}\u{2668}\u{267B}\u{267F}\u{2693}\u{26A0}-\u{26A1}\u{26AA}-\u{26AB}\u{26BD}-\u{26BE}\u{26C4}-\u{26C5}\u{26CE}\u{26D4}\u{26EA}\u{26F2}-\u{26F3}\u{26F5}\u{26FA}\u{26FD}\u{2702}\u{2705}\u{2708}-\u{270C}\u{270F}\u{2712}\u{2714}\u{2716}\u{2728}\u{2733}-\u{2734}\u{2744}\u{2747}\u{274C}\u{274E}\u{2753}-\u{2755}\u{2757}\u{2764}\u{2795}-\u{2797}\u{27A1}\u{27B0}\u{2934}-\u{2935}\u{2B05}-\u{2B07}\u{2B1B}-\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}\u{1F004}\u{1F0CF}\u{1F170}-\u{1F171}\u{1F17E}-\u{1F17F}\u{1F18E}\u{1F191}-\u{1F19A}\u{1F1E7}-\u{1F1EC}\u{1F1EE}-\u{1F1F0}\u{1F1F3}\u{1F1F5}\u{1F1F7}-\u{1F1FA}\u{1F201}-\u{1F202}\u{1F21A}\u{1F22F}\u{1F232}-\u{1F23A}\u{1F250}-\u{1F251}\u{1F300}-\u{1F320}\u{1F330}-\u{1F335}\u{1F337}-\u{1F37C}\u{1F380}-\u{1F393}\u{1F3A0}-\u{1F3C4}\u{1F3C6}-\u{1F3CA}\u{1F3E0}-\u{1F3F0}\u{1F400}-\u{1F43E}\u{1F440}\u{1F442}-\u{1F4F7}\u{1F4F9}-\u{1F4FC}\u{1F500}-\u{1F507}\u{1F509}-\u{1F53D}\u{1F550}-\u{1F567}\u{1F5FB}-\u{1F640}\u{1F645}-\u{1F64F}\u{1F680}-\u{1F68A}]/
4
+
5
+ end
@@ -0,0 +1,30 @@
1
+ module Tunable
2
+
3
+ module Hasher
4
+
5
+ def self.flatten(hash, primary_key, secondary_key = nil)
6
+ if secondary_key.nil?
7
+ hashify_using(hash, primary_key)
8
+ else
9
+ double_hashify_using(hash, primary_key, secondary_key)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def self.hashify_using(hash, key)
16
+ return {} if hash.empty?
17
+ Hash[*hash.collect { |v| [v.send(key).to_sym, v.normalized_value] }.flatten]
18
+ end
19
+
20
+ def self.double_hashify_using(hash, primary_key, secondary_key)
21
+ return {} if hash.empty?
22
+ c = {}
23
+ hash.collect { |e| c[e.send(primary_key).to_sym] = {} }
24
+ hash.collect { |e| c[e.send(primary_key).to_sym][e.send(secondary_key).to_sym] = e.normalized_value }
25
+ c
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -0,0 +1,260 @@
1
+ require 'active_support/concern'
2
+ require 'tunable/setting'
3
+ require 'tunable/hasher'
4
+
5
+ module Tunable
6
+
7
+ COLUMNS = [:context, :key, :value, :settable_id, :settable_type]
8
+
9
+ module Model
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ # class variables for main settings and defaults
14
+ class_variable_set('@@main_settings', [])
15
+ class_variable_set('@@default_settings', {})
16
+
17
+ # declare relationship
18
+ has_many :settings, :class_name => "Tunable::Setting", :as => :settable, :dependent => :delete_all
19
+
20
+ # and make sure settings are saved after any changes
21
+ after_save :save_new_settings
22
+ end
23
+
24
+ module ClassMethods
25
+
26
+ def default_settings(context)
27
+ class_variable_get('@@default_settings')[context.to_sym] || {}
28
+ end
29
+
30
+ def main_settings_list
31
+ class_variable_get('@@main_settings')
32
+ end
33
+
34
+ def set_default_setting(key, field, value)
35
+ hash = class_variable_get('@@default_settings')
36
+ hash[key.to_sym] = {} unless hash[key.to_sym]
37
+ hash[key.to_sym][field.to_sym] = value
38
+
39
+ class_variable_set('@@default_settings', hash)
40
+ end
41
+
42
+ def has_settings(contexts)
43
+
44
+ contexts.each do |name, options|
45
+
46
+ if options.is_a?(Array)
47
+ res = {}
48
+ options.each { |el| res[el.keys.first] = el.values.first }
49
+ else
50
+ res = options
51
+ end
52
+
53
+ res.each do |field, opts|
54
+ if opts.is_a?(Hash)
55
+ default = opts[:default]
56
+ else
57
+ default = opts
58
+ end
59
+
60
+ set_default_setting(name, field, default) unless default.nil?
61
+ end
62
+ end
63
+
64
+ end
65
+
66
+ def main_settings(*options)
67
+
68
+ if options[0].is_a?(Hash)
69
+ fields = options[0]
70
+ else # no defaults
71
+ fields = {}
72
+ options.each { |key| fields[key] = {} }
73
+ end
74
+
75
+ fields.each do |field, opts|
76
+
77
+ if opts.is_a?(Hash)
78
+ strict = opts[:strict]
79
+ default = opts[:default]
80
+ else
81
+ default = opts
82
+ strict = false
83
+ end
84
+
85
+ main_settings_list.push(field)
86
+ set_default_setting(:main, field, default) unless default.nil?
87
+
88
+ define_method field do
89
+ if instance_variable_defined?("@setting_main_#{field}")
90
+ # the instance var is already normalized to 1/0 when called by the setter
91
+ Tunable.getter_value(instance_variable_get("@setting_main_#{field}"))
92
+ else
93
+ current = main_settings[field.to_sym]
94
+ default_value = default.is_a?(Proc) ? default.call(self) : default
95
+ current.nil? ? default_value : current
96
+ end
97
+ end
98
+
99
+ define_method "#{field}?" do
100
+ main_setting_on?(field.to_sym)
101
+ end
102
+
103
+ define_method "#{field}_changed?" do
104
+ changes[field.to_sym].present?
105
+ end
106
+
107
+ define_method "#{field}=" do |raw_value|
108
+
109
+ if strict && !default.nil? && !Tunable.matching_type(raw_value, default)
110
+ raise "Invalid value: #{raw_value}. Expected #{default.class}, got #{raw_value.class}"
111
+ end
112
+
113
+ value = Tunable.normalize_value(raw_value)
114
+ current = Tunable.normalize_value(send("#{field}"))
115
+ # debug "Setting #{field} to #{value} (#{value.class}), current: #{current} (#{current.class})"
116
+
117
+ if value === current
118
+ send('changed_attributes').delete(field) # in case we had set if before
119
+ return
120
+ end
121
+
122
+ instance_variable_set("@setting_main_#{field}", value)
123
+
124
+ if value.nil?
125
+ main_settings.delete(field.to_sym)
126
+ queue_setting_for_deletion(:main, field)
127
+ else
128
+ main_settings[field.to_sym] = value
129
+ queue_setting_for_update(:main, field, value)
130
+ end
131
+ end
132
+
133
+ end
134
+
135
+ end # main_settings
136
+
137
+ end # ClassMethods
138
+
139
+ # instance methods below
140
+
141
+ def settings=(hash)
142
+ puts hash.inspect
143
+ Tunable::Setting.store_many(hash, self)
144
+ end
145
+
146
+ def settings_hash
147
+ if modified_settings.any?
148
+ puts "Settings have been changed. Hash will be incomplete."
149
+ end
150
+
151
+ @object_hashed_settings ||= Hasher.flatten(settings.reload, :context, :key)
152
+ end
153
+
154
+ def get_setting(context, key)
155
+ val = settings_context(context)[key]
156
+
157
+ # if value is nil or no default is set, stop here
158
+ return val if !val.nil? or self.class.default_settings(context)[key.to_sym].nil?
159
+
160
+ self.class.default_settings(context)[key.to_sym]
161
+ end
162
+
163
+ def get_main_setting(key)
164
+ get_setting(:main, key)
165
+ end
166
+
167
+ def settings_context(context)
168
+ settings_hash[context.to_sym] || {}
169
+ end
170
+
171
+ def main_settings
172
+ settings_context(:main)
173
+ end
174
+
175
+ def clear_instance_settings
176
+ @object_hashed_settings = nil
177
+ @settings = nil # so settings get reloaded from DB
178
+ end
179
+
180
+ def setting_off?(context, key)
181
+ get_setting(context, key) == false
182
+ end
183
+
184
+ def setting_on?(context, key)
185
+ get_setting(context, key) == true
186
+ end
187
+
188
+ def queue_setting_for_update(context, key, val)
189
+ if self.class.main_settings_list.include?(key.to_sym)
190
+ send('changed_attributes')[key.to_sym] = val
191
+ end
192
+ (modified_settings[context.to_sym] ||= {})[key.to_sym] = val
193
+ end
194
+
195
+ def queue_setting_for_deletion(context, key)
196
+ if self.class.main_settings_list.include?(key)
197
+ send('changed_attributes')[key.to_sym] = nil
198
+ end
199
+ (deleted_settings[context.to_sym] ||= []) << key.to_sym
200
+ end
201
+
202
+ private
203
+
204
+ def modified_settings
205
+ @modified_settings ||= {}
206
+ end
207
+
208
+ def deleted_settings
209
+ @deleted_settings ||= {}
210
+ end
211
+
212
+ def delete_setting(context, key)
213
+ Tunable::Setting.delete_all(
214
+ :context => context.to_s,
215
+ :key => key.to_s,
216
+ :settable_type => self.class.model_name.to_s,
217
+ :settable_id => self.id
218
+ )
219
+ end
220
+
221
+ def save_new_settings
222
+
223
+ if modified_settings.any?
224
+ # debug "Saving new settings: #{modified_settings.inspect}"
225
+ new_settings = []
226
+
227
+ modified_settings.each do |context, fields|
228
+ fields.each do |key, value|
229
+ # even though we do normalize on the setters, not all settings are
230
+ # main settings, so we need to make sure we normalize here again
231
+ normalized_value = Tunable.normalize_value(value)
232
+
233
+ new_settings << [context.to_s, key.to_s, normalized_value, self.id, self.class.model_name.to_s]
234
+ # remove_instance_variable("@setting_main_#{key}") if context == :main
235
+ end
236
+ end
237
+
238
+ Tunable::Setting.import(Tunable::COLUMNS, new_settings, { :method => 'REPLACE' }) # from lib/core_ext
239
+ end
240
+
241
+ if deleted_settings.any?
242
+ # puts deleted_settings.inspect
243
+ deleted_settings.each do |context, fields|
244
+ fields.each do |key|
245
+ delete_setting(context.to_s, key.to_s)
246
+ # remove_instance_variable("@setting_main_#{key}") if context == :main
247
+ end
248
+ end
249
+ end
250
+
251
+ self.clear_instance_settings
252
+ @modified_settings = {}
253
+ @deleted_settings = {}
254
+
255
+ true # make sure the other callbacks are triggered
256
+ end
257
+
258
+ end
259
+
260
+ end
@@ -0,0 +1,34 @@
1
+ module Tunable
2
+
3
+ module Normalizer
4
+
5
+ TRUTHIES = ['true', 't', 'on', 'yes', 'y', '1'].freeze
6
+ FALSIES = ['false', 'f', 'off', 'no', 'n', '0'].freeze
7
+
8
+ def normalize_value(val)
9
+ return 1 if TRUTHIES.include?(val.to_s)
10
+ return 0 if FALSIES.include?(val.to_s)
11
+ return if val.blank? # false.blank? returns true so this needs to go after the 0 line
12
+ val
13
+ end
14
+
15
+ def getter_value(normalized)
16
+ return normalized === 1 ? true : normalized === 0 ? false : normalized
17
+ end
18
+
19
+ # Called from Setting#normalized_value and DeviceActions#toggle_action
20
+ def normalize_and_get(val)
21
+ getter_value(normalize_value(val))
22
+ end
23
+
24
+ def matching_type(a, b)
25
+ if [true, false].include?(a)
26
+ return [true, false].include?(b)
27
+ else
28
+ a.class == b.class
29
+ end
30
+ end
31
+
32
+ end
33
+
34
+ end
@@ -0,0 +1,59 @@
1
+ require 'tunable/core_ext'
2
+
3
+ module Tunable
4
+
5
+ class Setting < ActiveRecord::Base
6
+ extend ActiveRecordExtensions
7
+
8
+ belongs_to :settable, :polymorphic => true
9
+
10
+ # scope :for_context, lambda { |context|
11
+ # return if context.blank?
12
+ # where(:context => context)
13
+ # }
14
+
15
+ # scope :main, lambda { where(:context => 'main') }
16
+
17
+ # scope :get, lambda { |key|
18
+ # return if key.blank?
19
+ # where(:key => key)
20
+ # }
21
+
22
+ # this method regenerates all settings when updating a device.
23
+ # we first remove all the settings (we dont get params from disabled modules)
24
+ def self.store_many(hash, object)
25
+ wipe_all(object) and return if hash.blank?
26
+
27
+ hash.each do |context, fields|
28
+ fields.each do |key, val|
29
+ if val.blank? && object.settings_context(context.to_sym)[key.to_sym].present?
30
+
31
+ # setting was present and now deleted
32
+ object.queue_setting_for_deletion(context, key)
33
+
34
+ elsif val != object.settings_context(context.to_sym)[key.to_sym]
35
+
36
+ # settings different from previous, so update
37
+ object.queue_setting_for_update(context, key, val)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def self.wipe_all(object)
44
+ count = object.settings.where("`context` != 'main'").delete_all
45
+ # debug "#{count} deleted settings."
46
+ true
47
+ end
48
+
49
+ def normalized_value
50
+ Tunable.normalize_and_get(self[:value])
51
+ end
52
+
53
+ def value=(val)
54
+ self[:value] = Tunable.normalize_value(val)
55
+ end
56
+
57
+ end
58
+
59
+ end
@@ -0,0 +1,5 @@
1
+ module Tunable
2
+
3
+ VERSION = '0.0.1'
4
+
5
+ end
data/lib/tunable.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'tunable/model'
2
+ require 'tunable/normalizer'
3
+
4
+ module Tunable
5
+ extend Normalizer
6
+ end
data/spec/schema.rb ADDED
@@ -0,0 +1,17 @@
1
+ ActiveRecord::Schema.define :version => 0 do
2
+ create_table "settings", :force => true do |t|
3
+ t.integer "settable_id", :limit => 11
4
+ t.string "settable_type"
5
+ t.string "context"
6
+ t.string "key"
7
+ t.string "value"
8
+ t.datetime "created_at"
9
+ end
10
+
11
+ add_index "settings", ["settable_id", "context", "key"], name: "index_settings_on_settable_id_and_context_and_key", unique: true, using: :btree
12
+ add_index "settings", ["settable_id", "settable_type"], name: "index_settings_on_settable_id_and_settable_type", using: :btree
13
+
14
+ create_table :tunable_models, :force => true do |t|
15
+ t.column :name, :string
16
+ end
17
+ end
@@ -0,0 +1,40 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+ require 'active_record'
4
+
5
+ ActiveRecord::Base.establish_connection(
6
+ "adapter" => "sqlite3", "database" => ':memory:'
7
+ )
8
+
9
+ ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), "debug.log"))
10
+
11
+ this_path = File.dirname(__FILE__)
12
+ load File.join(this_path, '/schema.rb')
13
+ require File.join(this_path, '..', 'lib', 'tunable.rb')
14
+
15
+ class TunableModel < ActiveRecord::Base
16
+ include Tunable::Model
17
+ end
18
+
19
+ def load_main_settings!
20
+
21
+ TunableModel.main_settings \
22
+ :boolean_setting,
23
+ :number_setting,
24
+ :empty_setting,
25
+ :on_off_setting,
26
+ :y_n_setting,
27
+ :other_setting
28
+
29
+ =begin
30
+ TunableModel.main_settings ({
31
+ :boolean_setting => { :default => true },
32
+ :number_setting => { :default => false },
33
+ :empty_setting => { },
34
+ :on_off_setting => { },
35
+ :y_n_setting => { :default => 'y', :strict => false },
36
+ :other_setting => { }
37
+ })
38
+ =end
39
+
40
+ end