tina4ruby 3.11.15 → 3.11.17

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1291 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
@@ -1,122 +1,122 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- module Drivers
5
- class SqliteDriver
6
- attr_reader :connection
7
-
8
- def connect(connection_string, username: nil, password: nil)
9
- require "sqlite3"
10
- db_path = self.class.resolve_path(connection_string)
11
-
12
- @connection = SQLite3::Database.new(db_path)
13
- @connection.results_as_hash = true
14
- @connection.execute("PRAGMA journal_mode=WAL")
15
- @connection.execute("PRAGMA foreign_keys=ON")
16
- end
17
-
18
- # Resolve a SQLite URL / path against the project root (cwd).
19
- #
20
- # Convention (matches tina4-python, tina4-php, tina4-nodejs):
21
- # sqlite::memory: → :memory:
22
- # sqlite:///:memory: → :memory:
23
- # sqlite:///app.db → {cwd}/app.db (relative)
24
- # sqlite:///data/app.db → {cwd}/data/app.db (relative; auto-mkdir under cwd)
25
- # sqlite:////var/data/app.db → /var/data/app.db (absolute; no auto-mkdir)
26
- # sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
27
- #
28
- # Never mkdir outside cwd — that was the root cause of the
29
- # "Read-only file system: '/data'" crash on macOS.
30
- def self.resolve_path(connection_string)
31
- return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"
32
-
33
- # Strip the scheme + up to three slashes, preserving a potential fourth
34
- # slash (absolute) or drive letter.
35
- raw = connection_string.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
36
- return ":memory:" if raw == ":memory:"
37
-
38
- is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
39
- is_unix_abs = raw.start_with?("/")
40
-
41
- if is_windows_abs || is_unix_abs
42
- # Absolute — trust the user; don't auto-mkdir outside cwd.
43
- raw
44
- else
45
- # Relative — resolve under cwd; auto-mkdir parent dir.
46
- resolved = File.join(Dir.pwd, raw)
47
- parent = File.dirname(resolved)
48
- require "fileutils"
49
- FileUtils.mkdir_p(parent) unless File.directory?(parent)
50
- resolved
51
- end
52
- end
53
-
54
- def close
55
- @connection&.close
56
- end
57
-
58
- def execute_query(sql, params = [])
59
- results = @connection.execute(sql, params)
60
- results.map { |row| symbolize_keys(row) }
61
- end
62
-
63
- def execute(sql, params = [])
64
- @connection.execute(sql, params)
65
- end
66
-
67
- def last_insert_id
68
- @connection.last_insert_row_id
69
- end
70
-
71
- def placeholder
72
- "?"
73
- end
74
-
75
- def placeholders(count)
76
- (["?"] * count).join(", ")
77
- end
78
-
79
- def apply_limit(sql, limit, offset = 0)
80
- "#{sql} LIMIT #{limit} OFFSET #{offset}"
81
- end
82
-
83
- def begin_transaction
84
- @connection.execute("BEGIN TRANSACTION")
85
- end
86
-
87
- def commit
88
- @connection.execute("COMMIT")
89
- end
90
-
91
- def rollback
92
- @connection.execute("ROLLBACK")
93
- end
94
-
95
- def tables
96
- rows = execute_query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
97
- rows.map { |r| r[:name] }
98
- end
99
-
100
- def columns(table_name)
101
- rows = execute_query("PRAGMA table_info(#{table_name})")
102
- rows.map do |r|
103
- {
104
- name: r[:name],
105
- type: r[:type],
106
- nullable: r[:notnull] == 0,
107
- default: r[:dflt_value],
108
- primary_key: r[:pk] == 1
109
- }
110
- end
111
- end
112
-
113
- private
114
-
115
- def symbolize_keys(hash)
116
- hash.each_with_object({}) do |(k, v), h|
117
- h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
118
- end
119
- end
120
- end
121
- end
122
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class SqliteDriver
6
+ attr_reader :connection
7
+
8
+ def connect(connection_string, username: nil, password: nil)
9
+ require "sqlite3"
10
+ db_path = self.class.resolve_path(connection_string)
11
+
12
+ @connection = SQLite3::Database.new(db_path)
13
+ @connection.results_as_hash = true
14
+ @connection.execute("PRAGMA journal_mode=WAL")
15
+ @connection.execute("PRAGMA foreign_keys=ON")
16
+ end
17
+
18
+ # Resolve a SQLite URL / path against the project root (cwd).
19
+ #
20
+ # Convention (matches tina4-python, tina4-php, tina4-nodejs):
21
+ # sqlite::memory: → :memory:
22
+ # sqlite:///:memory: → :memory:
23
+ # sqlite:///app.db → {cwd}/app.db (relative)
24
+ # sqlite:///data/app.db → {cwd}/data/app.db (relative; auto-mkdir under cwd)
25
+ # sqlite:////var/data/app.db → /var/data/app.db (absolute; no auto-mkdir)
26
+ # sqlite:///C:/Users/app.db → C:/Users/app.db (Windows absolute)
27
+ #
28
+ # Never mkdir outside cwd — that was the root cause of the
29
+ # "Read-only file system: '/data'" crash on macOS.
30
+ def self.resolve_path(connection_string)
31
+ return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"
32
+
33
+ # Strip the scheme + up to three slashes, preserving a potential fourth
34
+ # slash (absolute) or drive letter.
35
+ raw = connection_string.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
36
+ return ":memory:" if raw == ":memory:"
37
+
38
+ is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
39
+ is_unix_abs = raw.start_with?("/")
40
+
41
+ if is_windows_abs || is_unix_abs
42
+ # Absolute — trust the user; don't auto-mkdir outside cwd.
43
+ raw
44
+ else
45
+ # Relative — resolve under cwd; auto-mkdir parent dir.
46
+ resolved = File.join(Dir.pwd, raw)
47
+ parent = File.dirname(resolved)
48
+ require "fileutils"
49
+ FileUtils.mkdir_p(parent) unless File.directory?(parent)
50
+ resolved
51
+ end
52
+ end
53
+
54
+ def close
55
+ @connection&.close
56
+ end
57
+
58
+ def execute_query(sql, params = [])
59
+ results = @connection.execute(sql, params)
60
+ results.map { |row| symbolize_keys(row) }
61
+ end
62
+
63
+ def execute(sql, params = [])
64
+ @connection.execute(sql, params)
65
+ end
66
+
67
+ def last_insert_id
68
+ @connection.last_insert_row_id
69
+ end
70
+
71
+ def placeholder
72
+ "?"
73
+ end
74
+
75
+ def placeholders(count)
76
+ (["?"] * count).join(", ")
77
+ end
78
+
79
+ def apply_limit(sql, limit, offset = 0)
80
+ "#{sql} LIMIT #{limit} OFFSET #{offset}"
81
+ end
82
+
83
+ def begin_transaction
84
+ @connection.execute("BEGIN TRANSACTION")
85
+ end
86
+
87
+ def commit
88
+ @connection.execute("COMMIT")
89
+ end
90
+
91
+ def rollback
92
+ @connection.execute("ROLLBACK")
93
+ end
94
+
95
+ def tables
96
+ rows = execute_query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
97
+ rows.map { |r| r[:name] }
98
+ end
99
+
100
+ def columns(table_name)
101
+ rows = execute_query("PRAGMA table_info(#{table_name})")
102
+ rows.map do |r|
103
+ {
104
+ name: r[:name],
105
+ type: r[:type],
106
+ nullable: r[:notnull] == 0,
107
+ default: r[:dflt_value],
108
+ primary_key: r[:pk] == 1
109
+ }
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def symbolize_keys(hash)
116
+ hash.each_with_object({}) do |(k, v), h|
117
+ h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
data/lib/tina4/env.rb CHANGED
@@ -1,95 +1,95 @@
1
- # frozen_string_literal: true
2
- require "digest"
3
-
4
- module Tina4
5
- module Env
6
- DEFAULT_ENV = {
7
- "PROJECT_NAME" => "Tina4 Ruby Project",
8
- "VERSION" => "1.0.0",
9
- "TINA4_LOCALE" => "en",
10
- "TINA4_DEBUG" => "true",
11
- "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
12
- "SECRET" => "tina4-secret-change-me"
13
- }.freeze
14
-
15
- # Check if a value is truthy for env boolean checks.
16
- #
17
- # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
18
- # Everything else is falsy (including empty string, nil, not set).
19
- def self.is_truthy(val)
20
- %w[true 1 yes on].include?(val.to_s.strip.downcase)
21
- end
22
-
23
- class << self
24
- def load_env(root_dir = Dir.pwd)
25
- env_file = resolve_env_file(root_dir)
26
- unless File.exist?(env_file)
27
- create_default_env(env_file)
28
- end
29
- parse_env_file(env_file)
30
- end
31
-
32
- # Get an env var value, with optional default
33
- def get_env(key, default = nil)
34
- ENV[key.to_s] || default
35
- end
36
-
37
- # Check if an env var exists
38
- def has_env?(key)
39
- ENV.key?(key.to_s)
40
- end
41
-
42
- # Return all current ENV vars as a hash
43
- def all_env
44
- ENV.to_h
45
- end
46
-
47
- # Raise if any of the given keys are missing from ENV
48
- def require_env!(*keys)
49
- missing = keys.map(&:to_s).reject { |k| ENV.key?(k) }
50
- unless missing.empty?
51
- raise KeyError, "Missing required env vars: #{missing.join(', ')}"
52
- end
53
- end
54
-
55
- # Reset: clear all env vars that were loaded (restore to process defaults)
56
- def reset_env
57
- @loaded_keys&.each { |k| ENV.delete(k) }
58
- @loaded_keys = []
59
- end
60
-
61
- private
62
-
63
- def resolve_env_file(root_dir)
64
- environment = ENV["ENVIRONMENT"]
65
- if environment && !environment.empty?
66
- candidate = File.join(root_dir, ".env.#{environment}")
67
- return candidate if File.exist?(candidate)
68
- end
69
- File.join(root_dir, ".env")
70
- end
71
-
72
- def create_default_env(path)
73
- api_key = Digest::MD5.hexdigest(Time.now.to_s)
74
- content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
75
- content += "\nAPI_KEY=\"#{api_key}\"\n"
76
- File.write(path, content)
77
- end
78
-
79
- def parse_env_file(path)
80
- return unless File.exist?(path)
81
- File.readlines(path).each do |line|
82
- line = line.strip
83
- next if line.empty? || line.start_with?("#")
84
- if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
85
- key = match[1]
86
- value = match[2].gsub(/["']\z/, "")
87
- ENV[key] ||= value
88
- @loaded_keys ||= []
89
- @loaded_keys << key
90
- end
91
- end
92
- end
93
- end
94
- end
95
- end
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+
4
+ module Tina4
5
+ module Env
6
+ DEFAULT_ENV = {
7
+ "PROJECT_NAME" => "Tina4 Ruby Project",
8
+ "VERSION" => "1.0.0",
9
+ "TINA4_LOCALE" => "en",
10
+ "TINA4_DEBUG" => "true",
11
+ "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
12
+ "SECRET" => "tina4-secret-change-me"
13
+ }.freeze
14
+
15
+ # Check if a value is truthy for env boolean checks.
16
+ #
17
+ # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
18
+ # Everything else is falsy (including empty string, nil, not set).
19
+ def self.is_truthy(val)
20
+ %w[true 1 yes on].include?(val.to_s.strip.downcase)
21
+ end
22
+
23
+ class << self
24
+ def load_env(root_dir = Dir.pwd)
25
+ env_file = resolve_env_file(root_dir)
26
+ unless File.exist?(env_file)
27
+ create_default_env(env_file)
28
+ end
29
+ parse_env_file(env_file)
30
+ end
31
+
32
+ # Get an env var value, with optional default
33
+ def get_env(key, default = nil)
34
+ ENV[key.to_s] || default
35
+ end
36
+
37
+ # Check if an env var exists
38
+ def has_env?(key)
39
+ ENV.key?(key.to_s)
40
+ end
41
+
42
+ # Return all current ENV vars as a hash
43
+ def all_env
44
+ ENV.to_h
45
+ end
46
+
47
+ # Raise if any of the given keys are missing from ENV
48
+ def require_env!(*keys)
49
+ missing = keys.map(&:to_s).reject { |k| ENV.key?(k) }
50
+ unless missing.empty?
51
+ raise KeyError, "Missing required env vars: #{missing.join(', ')}"
52
+ end
53
+ end
54
+
55
+ # Reset: clear all env vars that were loaded (restore to process defaults)
56
+ def reset_env
57
+ @loaded_keys&.each { |k| ENV.delete(k) }
58
+ @loaded_keys = []
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_env_file(root_dir)
64
+ environment = ENV["ENVIRONMENT"]
65
+ if environment && !environment.empty?
66
+ candidate = File.join(root_dir, ".env.#{environment}")
67
+ return candidate if File.exist?(candidate)
68
+ end
69
+ File.join(root_dir, ".env")
70
+ end
71
+
72
+ def create_default_env(path)
73
+ api_key = Digest::MD5.hexdigest(Time.now.to_s)
74
+ content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
75
+ content += "\nAPI_KEY=\"#{api_key}\"\n"
76
+ File.write(path, content)
77
+ end
78
+
79
+ def parse_env_file(path)
80
+ return unless File.exist?(path)
81
+ File.readlines(path).each do |line|
82
+ line = line.strip
83
+ next if line.empty? || line.start_with?("#")
84
+ if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
85
+ key = match[1]
86
+ value = match[2].gsub(/["']\z/, "")
87
+ ENV[key] ||= value
88
+ @loaded_keys ||= []
89
+ @loaded_keys << key
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end