tina4ruby 3.10.92 → 3.10.93

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: 872c1e0d5dbae634259ad2772d69630f7160784967c03cb0d5ec68da3b122673
4
- data.tar.gz: c08ab7a877b583e3141dc523125bc236e6e548f2acc5b757114074b3547b27c2
3
+ metadata.gz: 5820ce91766359bcb5365ef8ea729e09641d2477b0b68179ca7c617fc3ce4630
4
+ data.tar.gz: 05c9793be438311072c37ce8570aea4c6322d7b498a2b079007996450ca8006b
5
5
  SHA512:
6
- metadata.gz: 2657a571cda5d98dcd0e46a0db670b6bcb027d23376d8b198413ad3b9c2f8bc56bec600fff1f77d71a0cf33ca51e632c3f4923f3caf67a8b40fd80125432f3b3
7
- data.tar.gz: ecca10a04db72af6d1e7f42d60c1509c9d98ccb516d2635ecfa78bc48d2def7ac63dbb14092fa27e9c668fea1f2c57982c280210e56f755a6d9ccc122f326747
6
+ metadata.gz: ba80ea5428f0354e0c005a0e647f1a1c8982bf2bbc9a69059a86b20d49b9523448f1fec0e28a7e9c459b441987b811e91b50ae77cc2fdafd6100f64fdc34789a
7
+ data.tar.gz: 4eb11c4f9a289f88a49330a763b32a595c7c46715788af3c8ada2d8742e7bd5d35f8d219416e4e49a3e794c4cf2fc23d23032f7b99e1b3ed8536345d3ec4800e
data/lib/tina4/frond.rb CHANGED
@@ -655,6 +655,7 @@ module Tina4
655
655
  def find_outside_quotes(expr, needle)
656
656
  in_q = nil
657
657
  depth = 0
658
+ bracket_depth = 0
658
659
  i = 0
659
660
  nlen = needle.length
660
661
  while i <= expr.length - nlen
@@ -676,8 +677,12 @@ module Tina4
676
677
  depth += 1
677
678
  elsif ch == ")"
678
679
  depth -= 1
680
+ elsif ch == "["
681
+ bracket_depth += 1
682
+ elsif ch == "]"
683
+ bracket_depth -= 1
679
684
  end
680
- if depth == 0 && expr[i, nlen] == needle
685
+ if depth == 0 && bracket_depth == 0 && expr[i, nlen] == needle
681
686
  return i
682
687
  end
683
688
  i += 1
@@ -1172,8 +1177,30 @@ module Tina4
1172
1177
  part = part.strip.gsub(RESOLVE_STRIP_RE, "") # strip quotes from bracket access
1173
1178
  if value.is_a?(Hash) || value.is_a?(LoopContext)
1174
1179
  value = value[part] || value[part.to_sym]
1175
- elsif value.is_a?(Array) && part =~ DIGIT_RE
1176
- value = value[part.to_i]
1180
+ elsif value.is_a?(Array)
1181
+ # Slice syntax: value[1:5], value[:10], value[start:end]
1182
+ if part.include?(":") && !(part.start_with?('"') || part.start_with?("'"))
1183
+ slice_parts = part.split(":", 2)
1184
+ s_start = slice_parts[0].strip.empty? ? nil : eval_expr(slice_parts[0].strip, context).to_i
1185
+ s_end = slice_parts[1].strip.empty? ? nil : eval_expr(slice_parts[1].strip, context).to_i
1186
+ if s_start && s_end
1187
+ value = value[s_start...s_end]
1188
+ elsif s_start
1189
+ value = value[s_start..]
1190
+ elsif s_end
1191
+ value = value[0...s_end]
1192
+ else
1193
+ value = value.dup
1194
+ end
1195
+ next
1196
+ end
1197
+ idx = if part =~ DIGIT_RE
1198
+ part.to_i
1199
+ else
1200
+ eval_expr(part, context)
1201
+ end
1202
+ idx = idx.to_i if idx.is_a?(Numeric)
1203
+ value = idx.is_a?(Integer) ? value[idx] : nil
1177
1204
  elsif value.respond_to?(part.to_sym)
1178
1205
  value = value.send(part.to_sym)
1179
1206
  else
@@ -10,6 +10,12 @@ module Tina4
10
10
  @translations ||= {}
11
11
  end
12
12
 
13
+ # Flat alias map: { locale => { leaf_key => value } }
14
+ # First-wins on conflict — later duplicates are ignored.
15
+ def flat_aliases
16
+ @flat_aliases ||= {}
17
+ end
18
+
13
19
  def current_locale
14
20
  @current_locale || ENV["TINA4_LOCALE"] || "en"
15
21
  end
@@ -19,8 +25,11 @@ module Tina4
19
25
  end
20
26
 
21
27
  def load(root_dir = Dir.pwd)
22
- LOCALE_DIRS.each do |dir|
23
- locale_dir = File.join(root_dir, dir)
28
+ locale_dir_override = ENV["TINA4_LOCALE_DIR"]
29
+ search_dirs = locale_dir_override && !locale_dir_override.empty? ? [locale_dir_override] : LOCALE_DIRS
30
+
31
+ search_dirs.each do |dir|
32
+ locale_dir = File.expand_path(dir, root_dir)
24
33
  next unless Dir.exist?(locale_dir)
25
34
 
26
35
  Dir.glob(File.join(locale_dir, "*.json")).each do |file|
@@ -28,6 +37,8 @@ module Tina4
28
37
  data = JSON.parse(File.read(file))
29
38
  translations[locale] ||= {}
30
39
  translations[locale].merge!(data)
40
+ # Build leaf-key aliases from the loaded data
41
+ build_leaf_aliases(locale, data)
31
42
  Tina4::Log.debug("Loaded locale: #{locale} from #{file}")
32
43
  end
33
44
 
@@ -37,8 +48,11 @@ module Tina4
37
48
  require "yaml"
38
49
  locale = File.basename(file, File.extname(file))
39
50
  data = YAML.safe_load(File.read(file))
40
- translations[locale] ||= {}
41
- translations[locale].merge!(data) if data.is_a?(Hash)
51
+ if data.is_a?(Hash)
52
+ translations[locale] ||= {}
53
+ translations[locale].merge!(data)
54
+ build_leaf_aliases(locale, data)
55
+ end
42
56
  rescue LoadError
43
57
  Tina4::Log.warning("YAML support requires the 'yaml' gem")
44
58
  end
@@ -94,6 +108,13 @@ module Tina4
94
108
  hash = hash[k]
95
109
  end
96
110
  hash[keys.last] = value
111
+
112
+ # Register leaf-key alias (first-wins)
113
+ leaf = keys.last
114
+ if value.is_a?(String)
115
+ flat_aliases[locale.to_s] ||= {}
116
+ flat_aliases[locale.to_s][leaf] ||= value
117
+ end
97
118
  end
98
119
 
99
120
  def available_locales
@@ -102,19 +123,45 @@ module Tina4
102
123
 
103
124
  private
104
125
 
126
+ # Recursively walk a nested hash and register leaf-key aliases.
127
+ # First-wins: if a leaf key already exists, it is NOT overwritten.
128
+ def build_leaf_aliases(locale, hash, prefix = nil)
129
+ flat_aliases[locale.to_s] ||= {}
130
+ hash.each do |key, value|
131
+ full_key = prefix ? "#{prefix}.#{key}" : key.to_s
132
+ if value.is_a?(Hash)
133
+ build_leaf_aliases(locale, value, full_key)
134
+ else
135
+ # Store the leaf key as an alias (first-wins)
136
+ flat_aliases[locale.to_s][key.to_s] ||= value
137
+ end
138
+ end
139
+ end
140
+
105
141
  def lookup(locale, key)
106
142
  keys = key.to_s.split(".")
107
143
  result = translations[locale]
108
144
  return nil unless result
109
145
 
146
+ # Try dot-path traversal first
147
+ dot_result = result
110
148
  keys.each do |k|
111
- if result.is_a?(Hash)
112
- result = result[k] || result[k.to_sym]
149
+ if dot_result.is_a?(Hash)
150
+ dot_result = dot_result[k] || dot_result[k.to_sym]
113
151
  else
114
- return nil
152
+ dot_result = nil
153
+ break
115
154
  end
116
155
  end
117
- result.is_a?(String) ? result : nil
156
+ return dot_result if dot_result.is_a?(String)
157
+
158
+ # Fall back to leaf-key alias (only for simple keys without dots)
159
+ if flat_aliases[locale]
160
+ alias_val = flat_aliases[locale][key.to_s]
161
+ return alias_val if alias_val.is_a?(String)
162
+ end
163
+
164
+ nil
118
165
  end
119
166
  end
120
167
  end
data/lib/tina4/orm.rb CHANGED
@@ -4,7 +4,7 @@ require "json"
4
4
  module Tina4
5
5
  # Convert a snake_case name to camelCase.
6
6
  def self.snake_to_camel(name)
7
- parts = name.to_s.split("_")
7
+ parts = name.to_s.downcase.split("_")
8
8
  parts[0] + parts[1..].map(&:capitalize).join
9
9
  end
10
10
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
3
  require "securerandom"
4
+ require "uri"
4
5
 
5
6
  module Tina4
6
7
  # Middleware wrapper that tags requests arriving on the AI dev port.
@@ -168,8 +169,35 @@ module Tina4
168
169
 
169
170
  # Secure-by-default: enforce bearer-token auth on write routes
170
171
  if route.auth_required
172
+ token = nil
173
+ token_source = nil # :header, :body, :session
174
+
175
+ # Priority 1: Authorization Bearer header
171
176
  auth_header = env["HTTP_AUTHORIZATION"] || ""
172
- token = auth_header =~ /\ABearer\s+(.+)\z/i ? Regexp.last_match(1) : nil
177
+ if auth_header =~ /\ABearer\s+(.+)\z/i
178
+ token = Regexp.last_match(1)
179
+ token_source = :header
180
+ end
181
+
182
+ # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
183
+ if token.nil?
184
+ body_str = _read_rack_body(env)
185
+ form_token = _extract_form_token(body_str, env)
186
+ if form_token && !form_token.empty?
187
+ token = form_token
188
+ token_source = :body
189
+ end
190
+ end
191
+
192
+ # Priority 3: Session token (for secured GET routes after login)
193
+ if token.nil?
194
+ session = Tina4::Session.new(env)
195
+ session_token = session.get("token")
196
+ if session_token && !session_token.empty?
197
+ token = session_token
198
+ token_source = :session
199
+ end
200
+ end
173
201
 
174
202
  # API_KEY bypass — matches tina4_python behavior
175
203
  api_key = ENV["TINA4_API_KEY"] || ENV["API_KEY"]
@@ -180,6 +208,11 @@ module Tina4
180
208
  return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
181
209
  end
182
210
  env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
211
+
212
+ # When body formToken validates, store a refreshed token for the FreshToken response header
213
+ if token_source == :body
214
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token)
215
+ end
183
216
  else
184
217
  return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
185
218
  end
@@ -231,6 +264,11 @@ module Tina4
231
264
  # Run global after middleware (block-based + class-based after_* methods)
232
265
  Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, final_response)
233
266
 
267
+ # Inject FreshToken header when body formToken was used for auth
268
+ if env["tina4.fresh_token"]
269
+ final_response.add_header("FreshToken", env["tina4.fresh_token"])
270
+ end
271
+
234
272
  final_response.to_rack
235
273
  end
236
274
 
@@ -740,5 +778,40 @@ module Tina4
740
778
  end
741
779
 
742
780
 
781
+ # Read and rewind the Rack input body. Returns the raw body string.
782
+ def _read_rack_body(env)
783
+ input = env["rack.input"]
784
+ return "" unless input
785
+ input.rewind if input.respond_to?(:rewind)
786
+ body = input.read || ""
787
+ input.rewind if input.respond_to?(:rewind)
788
+ body
789
+ end
790
+
791
+ # Extract a formToken from the request body.
792
+ # Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).
793
+ def _extract_form_token(body_str, env)
794
+ return nil if body_str.nil? || body_str.empty?
795
+
796
+ content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""
797
+
798
+ if content_type.include?("application/json")
799
+ begin
800
+ parsed = JSON.parse(body_str)
801
+ return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
802
+ rescue JSON::ParserError
803
+ # Not valid JSON — fall through
804
+ end
805
+ end
806
+
807
+ # URL-encoded form data (or fallback for any content type)
808
+ if body_str.include?("formToken=")
809
+ match = body_str.match(/(?:^|&)formToken=([^&]+)/)
810
+ return URI.decode_www_form_component(match[1]) if match
811
+ end
812
+
813
+ nil
814
+ end
815
+
743
816
  end
744
817
  end
data/lib/tina4/seeder.rb CHANGED
@@ -524,6 +524,13 @@ module Tina4
524
524
  results
525
525
  end
526
526
 
527
+ # Run all seed files in the given folder.
528
+ #
529
+ # @param seed_folder [String] path to seed files (default: "seeds")
530
+ def self.seed(seed_folder: "seeds", clear: false)
531
+ seed_dir(seed_folder: seed_folder, clear: clear)
532
+ end
533
+
527
534
  # Run all seed files in the given folder.
528
535
  #
529
536
  # @param seed_folder [String] path to seed files (default: "seeds")
@@ -230,16 +230,112 @@ module Tina4
230
230
  parent_path = Regexp.last_match(1)
231
231
  full_parent = resolve_template(parent_path)
232
232
  if full_parent && File.exist?(full_parent)
233
- @parent_template = File.read(full_parent)
234
- content.scan(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do |name, body|
235
- @blocks[name] = body
236
- end
237
- content = @parent_template
233
+ parent_source = File.read(full_parent)
234
+ child_blocks = extract_blocks(content)
235
+ @blocks.merge!(child_blocks)
236
+ content = render_with_blocks(parent_source, @blocks)
238
237
  end
239
238
  end
240
239
  content
241
240
  end
242
241
 
242
+ # Extract {% block name %}...{% endblock %} pairs using depth counting
243
+ # so that nested blocks are handled correctly (non-greedy regex fails
244
+ # when blocks are nested inside other blocks).
245
+ def extract_blocks(source)
246
+ blocks = {}
247
+ block_open = /\{%[-\s]*block\s+(\w+)\s*-?%\}/
248
+ block_close = /\{%[-\s]*endblock\s*-?%\}/
249
+
250
+ pos = 0
251
+ while pos < source.length
252
+ m_open = block_open.match(source, pos)
253
+ break unless m_open
254
+
255
+ name = m_open[1]
256
+ content_start = m_open.end(0)
257
+ depth = 1
258
+ scan = content_start
259
+
260
+ while depth > 0 && scan < source.length
261
+ next_open = block_open.match(source, scan)
262
+ next_close = block_close.match(source, scan)
263
+
264
+ break unless next_close # malformed — no matching endblock
265
+
266
+ if next_open && next_open.begin(0) < next_close.begin(0)
267
+ depth += 1
268
+ scan = next_open.end(0)
269
+ else
270
+ depth -= 1
271
+ if depth == 0
272
+ blocks[name] = source[content_start...next_close.begin(0)]
273
+ pos = next_close.end(0)
274
+ break
275
+ end
276
+ scan = next_close.end(0)
277
+ end
278
+ end
279
+
280
+ # If we didn't break out via depth==0, skip forward to avoid infinite loop
281
+ pos = content_start if depth > 0
282
+ end
283
+
284
+ blocks
285
+ end
286
+
287
+ # Render a parent template replacing blocks with child overrides.
288
+ # Supports multi-level inheritance: if the parent itself extends a
289
+ # grandparent, blocks are merged (child overrides parent) and the
290
+ # chain is followed recursively.
291
+ def render_with_blocks(parent_source, child_blocks)
292
+ extends_re = /\A\s*\{%\s*extends\s+["'](.+?)["']\s*%\}/
293
+
294
+ # Multi-level: if the parent itself extends another template, recurse
295
+ if parent_source =~ extends_re
296
+ grandparent_name = Regexp.last_match(1)
297
+ full_grandparent = resolve_template(grandparent_name)
298
+ if full_grandparent && File.exist?(full_grandparent)
299
+ grandparent_source = File.read(full_grandparent)
300
+
301
+ # Extract block defaults defined in the parent template
302
+ parent_blocks = extract_blocks(parent_source)
303
+
304
+ # Child blocks override parent blocks at the same name
305
+ merged_blocks = parent_blocks.merge(child_blocks)
306
+
307
+ # Resolve nested blocks: if a block value contains {% block inner %}
308
+ # tags, replace them with merged_blocks values too
309
+ block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m
310
+ changed = true
311
+ while changed
312
+ changed = false
313
+ merged_blocks.each do |bname, bsource|
314
+ resolved = bsource.gsub(block_re) do
315
+ inner_name = Regexp.last_match(1)
316
+ inner_default = Regexp.last_match(2)
317
+ merged_blocks[inner_name] || inner_default
318
+ end
319
+ if resolved != bsource
320
+ merged_blocks[bname] = resolved
321
+ changed = true
322
+ end
323
+ end
324
+ end
325
+
326
+ # Recurse up the chain (handles 3+, 4+, ... levels)
327
+ return render_with_blocks(grandparent_source, merged_blocks)
328
+ end
329
+ end
330
+
331
+ # Leaf parent (no extends) — resolve blocks and render
332
+ parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m) do
333
+ name = Regexp.last_match(1)
334
+ default_body = Regexp.last_match(2)
335
+ child_blocks[name] || default_body
336
+ end
337
+ end
338
+
243
339
  def process_blocks(content)
244
340
  content.gsub(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do
245
341
  name = Regexp.last_match(1)
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.92"
4
+ VERSION = "3.10.93"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -170,6 +170,9 @@ module Tina4
170
170
  # Load translations
171
171
  Tina4::Localization.load(root_dir)
172
172
 
173
+ # Auto-wire t() into template globals if locales were loaded
174
+ autowire_i18n_template_global
175
+
173
176
  # Connect database if configured
174
177
  setup_database
175
178
 
@@ -412,6 +415,17 @@ module Tina4
412
415
  end
413
416
  end
414
417
 
418
+ def autowire_i18n_template_global
419
+ # Only register if translations were actually loaded
420
+ return if Tina4::Localization.translations.empty?
421
+
422
+ # Don't overwrite a user-registered t() global
423
+ return if Tina4::Template.globals.key?("t")
424
+
425
+ Tina4::Template.add_global("t", ->(key, **opts) { Tina4::Localization.t(key, **opts) })
426
+ Tina4::Log.debug("Auto-wired i18n t() as template global")
427
+ end
428
+
415
429
  def setup_database
416
430
  db_url = ENV["DATABASE_URL"] || ENV["DB_URL"]
417
431
  if db_url && !db_url.empty?
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.92
4
+ version: 3.10.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-10 00:00:00.000000000 Z
11
+ date: 2026-04-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack