tina4ruby 3.10.42 → 3.10.44

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.
data/lib/tina4/frond.rb CHANGED
@@ -843,32 +843,68 @@ module Tina4
843
843
  # Expression evaluator
844
844
  # -----------------------------------------------------------------------
845
845
 
846
+ # ── Expression evaluator (dispatcher) ──────────────────────────────
847
+ # Each expression type is handled by a focused helper method.
848
+ # Helpers return :not_matched when the expression doesn't match their
849
+ # type, so the dispatcher falls through to the next handler.
850
+
846
851
  def eval_expr(expr, context)
847
852
  expr = expr.strip
848
853
  return nil if expr.empty?
849
854
 
850
- # String literal
855
+ result = eval_literal(expr)
856
+ return result unless result == :not_literal
857
+
858
+ result = eval_collection_literal(expr, context)
859
+ return result unless result == :not_collection
860
+
861
+ return eval_expr(expr[1..-2], context) if matched_parens?(expr)
862
+
863
+ result = eval_ternary(expr, context)
864
+ return result unless result == :not_ternary
865
+
866
+ result = eval_inline_if(expr, context)
867
+ return result unless result == :not_inline_if
868
+
869
+ result = eval_null_coalesce(expr, context)
870
+ return result unless result == :not_coalesce
871
+
872
+ result = eval_concat(expr, context)
873
+ return result unless result == :not_concat
874
+
875
+ return eval_comparison(expr, context) if has_comparison?(expr)
876
+
877
+ result = eval_arithmetic(expr, context)
878
+ return result unless result == :not_arithmetic
879
+
880
+ result = eval_function_call(expr, context)
881
+ return result unless result == :not_function
882
+
883
+ resolve(expr, context)
884
+ end
885
+
886
+ # ── Literal values: strings, numbers, booleans, null ──
887
+
888
+ def eval_literal(expr)
851
889
  if (expr.start_with?('"') && expr.end_with?('"')) ||
852
890
  (expr.start_with?("'") && expr.end_with?("'"))
853
891
  return expr[1..-2]
854
892
  end
855
-
856
- # Numeric
857
893
  return expr.to_i if expr =~ INTEGER_RE
858
894
  return expr.to_f if expr =~ FLOAT_RE
859
-
860
- # Boolean/null
861
895
  return true if expr == "true"
862
896
  return false if expr == "false"
863
897
  return nil if expr == "null" || expr == "none" || expr == "nil"
898
+ :not_literal
899
+ end
900
+
901
+ # ── Collection literals: arrays, hashes, ranges ──
864
902
 
865
- # Array literal [a, b, c]
903
+ def eval_collection_literal(expr, context)
866
904
  if expr =~ ARRAY_LIT_RE
867
905
  inner = Regexp.last_match(1)
868
906
  return split_args_toplevel(inner).map { |item| eval_expr(item.strip, context) }
869
907
  end
870
-
871
- # Hash literal { key: value, ... }
872
908
  if expr =~ HASH_LIT_RE
873
909
  inner = Regexp.last_match(1)
874
910
  hash = {}
@@ -879,99 +915,94 @@ module Tina4
879
915
  end
880
916
  return hash
881
917
  end
882
-
883
- # Range literal: 1..5
884
918
  if expr =~ RANGE_LIT_RE
885
919
  return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
886
920
  end
921
+ :not_collection
922
+ end
887
923
 
888
- # Parenthesized sub-expression: (expr) — strip parens and evaluate inner
889
- if expr.start_with?("(") && expr.end_with?(")")
890
- depth = 0
891
- matched = true
892
- expr.each_char.with_index do |ch, pi|
893
- depth += 1 if ch == "("
894
- depth -= 1 if ch == ")"
895
- if depth == 0 && pi < expr.length - 1
896
- matched = false
897
- break
898
- end
899
- end
900
- return eval_expr(expr[1..-2], context) if matched
924
+ # ── Parenthesized sub-expression check ──
925
+
926
+ def matched_parens?(expr)
927
+ return false unless expr.start_with?("(") && expr.end_with?(")")
928
+ depth = 0
929
+ expr.each_char.with_index do |ch, pi|
930
+ depth += 1 if ch == "("
931
+ depth -= 1 if ch == ")"
932
+ return false if depth == 0 && pi < expr.length - 1
901
933
  end
934
+ true
935
+ end
902
936
 
903
- # Ternary: condition ? "yes" : "no" — quote-aware
937
+ # ── Ternary: condition ? "yes" : "no" ──
938
+
939
+ def eval_ternary(expr, context)
904
940
  q_pos = find_outside_quotes(expr, "?")
905
- if q_pos > 0
906
- cond_part = expr[0...q_pos].strip
907
- rest = expr[(q_pos + 1)..]
908
- c_pos = find_outside_quotes(rest, ":")
909
- if c_pos >= 0
910
- true_part = rest[0...c_pos].strip
911
- false_part = rest[(c_pos + 1)..].strip
912
- cond = eval_expr(cond_part, context)
913
- return truthy?(cond) ? eval_expr(true_part, context) : eval_expr(false_part, context)
914
- end
915
- end
941
+ return :not_ternary unless q_pos && q_pos > 0
942
+ cond_part = expr[0...q_pos].strip
943
+ rest = expr[(q_pos + 1)..]
944
+ c_pos = find_outside_quotes(rest, ":")
945
+ return :not_ternary unless c_pos && c_pos >= 0
946
+ true_part = rest[0...c_pos].strip
947
+ false_part = rest[(c_pos + 1)..].strip
948
+ cond = eval_expr(cond_part, context)
949
+ truthy?(cond) ? eval_expr(true_part, context) : eval_expr(false_part, context)
950
+ end
951
+
952
+ # ── Inline if: value if condition else other_value ──
916
953
 
917
- # Jinja2-style inline if: value if condition else other_value — quote-aware
954
+ def eval_inline_if(expr, context)
918
955
  if_pos = find_outside_quotes(expr, " if ")
919
- if if_pos >= 0
920
- else_pos = find_outside_quotes(expr, " else ")
921
- if else_pos && else_pos > if_pos
922
- value_part = expr[0...if_pos].strip
923
- cond_part = expr[(if_pos + 4)...else_pos].strip
924
- else_part = expr[(else_pos + 6)..].strip
925
- cond = eval_expr(cond_part, context)
926
- return truthy?(cond) ? eval_expr(value_part, context) : eval_expr(else_part, context)
927
- end
928
- end
956
+ return :not_inline_if unless if_pos && if_pos >= 0
957
+ else_pos = find_outside_quotes(expr, " else ")
958
+ return :not_inline_if unless else_pos && else_pos > if_pos
959
+ value_part = expr[0...if_pos].strip
960
+ cond_part = expr[(if_pos + 4)...else_pos].strip
961
+ else_part = expr[(else_pos + 6)..].strip
962
+ cond = eval_expr(cond_part, context)
963
+ truthy?(cond) ? eval_expr(value_part, context) : eval_expr(else_part, context)
964
+ end
929
965
 
930
- # Null coalescing: value ?? "default"
931
- if expr.include?("??")
932
- left, _, right = expr.partition("??")
933
- val = eval_expr(left.strip, context)
934
- return val.nil? ? eval_expr(right.strip, context) : val
935
- end
966
+ # ── Null coalescing: value ?? "default" ──
936
967
 
937
- # String concatenation with ~
938
- if expr.include?("~")
939
- parts = expr.split("~")
940
- return parts.map { |p| (eval_expr(p.strip, context) || "").to_s }.join
941
- end
968
+ def eval_null_coalesce(expr, context)
969
+ return :not_coalesce unless expr.include?("??")
970
+ left, _, right = expr.partition("??")
971
+ val = eval_expr(left.strip, context)
972
+ val.nil? ? eval_expr(right.strip, context) : val
973
+ end
942
974
 
943
- # Check for comparison / logical operators -- delegate
944
- if has_comparison?(expr)
945
- return eval_comparison(expr, context)
946
- end
975
+ # ── String concatenation: a ~ b ──
976
+
977
+ def eval_concat(expr, context)
978
+ return :not_concat unless expr.include?("~")
979
+ parts = expr.split("~")
980
+ parts.map { |p| (eval_expr(p.strip, context) || "").to_s }.join
981
+ end
982
+
983
+ # ── Arithmetic: +, -, *, //, /, %, ** ──
947
984
 
948
- # Arithmetic: +, -, *, //, /, %, ** (lowest to highest precedence)
985
+ def eval_arithmetic(expr, context)
949
986
  ARITHMETIC_OPS.each do |op|
950
987
  pos = find_outside_quotes(expr, op)
951
988
  next unless pos && pos >= 0
952
- left_s = expr[0...pos].strip
953
- right_s = expr[(pos + op.length)..].strip
954
- l_val = eval_expr(left_s, context)
955
- r_val = eval_expr(right_s, context)
989
+ l_val = eval_expr(expr[0...pos].strip, context)
990
+ r_val = eval_expr(expr[(pos + op.length)..].strip, context)
956
991
  return apply_math(l_val, op.strip, r_val)
957
992
  end
993
+ :not_arithmetic
994
+ end
958
995
 
959
- # Function call: name(arg1, arg2)
960
- if expr =~ FUNC_CALL_RE
961
- fn_name = Regexp.last_match(1)
962
- raw_args = Regexp.last_match(2).strip
963
- fn = context[fn_name]
964
- if fn.respond_to?(:call)
965
- if raw_args.empty?
966
- return fn.call
967
- else
968
- args = split_args_toplevel(raw_args).map { |a| eval_expr(a.strip, context) }
969
- return fn.call(*args)
970
- end
971
- end
972
- end
996
+ # ── Function call: name(arg1, arg2) ──
973
997
 
974
- resolve(expr, context)
998
+ def eval_function_call(expr, context)
999
+ return :not_function unless expr =~ FUNC_CALL_RE
1000
+ fn_name = Regexp.last_match(1)
1001
+ raw_args = Regexp.last_match(2).strip
1002
+ fn = context[fn_name]
1003
+ return :not_function unless fn.respond_to?(:call)
1004
+ args = raw_args.empty? ? [] : split_args_toplevel(raw_args).map { |a| eval_expr(a.strip, context) }
1005
+ fn.call(*args)
975
1006
  end
976
1007
 
977
1008
  def has_comparison?(expr)
data/lib/tina4/metrics.rb CHANGED
@@ -21,12 +21,31 @@ module Tina4
21
21
  @full_cache_time = 0
22
22
  CACHE_TTL = 60
23
23
 
24
+ # ── Root Resolution ──────────────────────────────────────────
25
+
26
+ # Pick the right directory to scan.
27
+ #
28
+ # If the root dir has Ruby files, scan the user's project code.
29
+ # Otherwise, scan the framework itself — so the bubble chart is never empty.
30
+ def self._resolve_root(root = 'src')
31
+ root_path = Pathname.new(root)
32
+ if root_path.directory? && !Dir.glob(root_path.join('**', '*.rb')).empty?
33
+ return root
34
+ end
35
+ # Fallback: scan the framework package itself
36
+ File.dirname(__FILE__)
37
+ end
38
+
24
39
  # ── Quick Metrics ───────────────────────────────────────────
25
40
 
26
41
  def self.quick_metrics(root = 'src')
42
+ # Check if the requested directory exists before falling back
27
43
  root_path = Pathname.new(root)
28
44
  return { "error" => "Directory not found: #{root}" } unless root_path.directory?
29
45
 
46
+ root = _resolve_root(root)
47
+ root_path = Pathname.new(root)
48
+
30
49
  rb_files = Dir.glob(root_path.join('**', '*.rb'))
31
50
  twig_files = Dir.glob(root_path.join('**', '*.twig')) + Dir.glob(root_path.join('**', '*.erb'))
32
51
 
@@ -184,9 +203,13 @@ module Tina4
184
203
  # ── Full Analysis (Ripper-based) ────────────────────────────
185
204
 
186
205
  def self.full_analysis(root = 'src')
206
+ # Check if the requested directory exists before falling back
187
207
  root_path = Pathname.new(root)
188
208
  return { "error" => "Directory not found: #{root}" } unless root_path.directory?
189
209
 
210
+ root = _resolve_root(root)
211
+ root_path = Pathname.new(root)
212
+
190
213
  current_hash = _files_hash(root)
191
214
  now = Time.now.to_f
192
215
 
@@ -271,7 +294,9 @@ module Tina4
271
294
  "halstead_volume" => volume.round(1),
272
295
  "coupling_afferent" => ca,
273
296
  "coupling_efferent" => ce,
274
- "instability" => instability.round(3)
297
+ "instability" => instability.round(3),
298
+ "has_tests" => _has_matching_test(rel_path),
299
+ "dep_count" => imports.length
275
300
  }
276
301
  end
277
302
 
@@ -293,6 +318,11 @@ module Tina4
293
318
  total_mi = file_metrics.sum { |f| f["maintainability"] }
294
319
  avg_mi = file_metrics.empty? ? 0 : total_mi.to_f / file_metrics.length
295
320
 
321
+ # Detect if we're scanning framework or project
322
+ framework_dir = File.expand_path(File.dirname(__FILE__))
323
+ resolved_root = File.expand_path(root_path.to_s)
324
+ scanning_framework = resolved_root == framework_dir || resolved_root.start_with?(framework_dir + '/')
325
+
296
326
  result = {
297
327
  "files_analyzed" => file_metrics.length,
298
328
  "total_functions" => all_functions.length,
@@ -301,7 +331,9 @@ module Tina4
301
331
  "most_complex_functions" => all_functions.first(15),
302
332
  "file_metrics" => file_metrics,
303
333
  "violations" => violations,
304
- "dependency_graph" => import_graph
334
+ "dependency_graph" => import_graph,
335
+ "scan_mode" => scanning_framework ? "framework" : "project",
336
+ "scan_root" => resolved_root
305
337
  }
306
338
 
307
339
  @full_cache_hash = current_hash
@@ -372,6 +404,15 @@ module Tina4
372
404
 
373
405
  private_class_method
374
406
 
407
+ def self._has_matching_test(rel_path)
408
+ name = File.basename(rel_path, '.rb')
409
+ ['spec', 'test'].any? do |dir|
410
+ File.exist?("#{dir}/#{name}_spec.rb") ||
411
+ File.exist?("#{dir}/#{name}_test.rb") ||
412
+ File.exist?("#{dir}/test_#{name}.rb")
413
+ end
414
+ end
415
+
375
416
  def self._files_hash(root)
376
417
  md5 = Digest::MD5.new
377
418
  root_path = Pathname.new(root)
data/lib/tina4/orm.rb CHANGED
@@ -474,15 +474,25 @@ module Tina4
474
474
  errors
475
475
  end
476
476
 
477
- def load(id = nil)
478
- pk = self.class.primary_key_field || :id
479
- id ||= __send__(pk)
480
- return false unless id
477
+ def load(filter_sql = nil, params = [])
481
478
  @relationship_cache = {} # Clear relationship cache on reload
482
479
 
483
- result = self.class.db.fetch_one(
484
- "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
485
- )
480
+ pk = self.class.primary_key_field || :id
481
+
482
+ # Determine the SQL to execute
483
+ if filter_sql.nil? || filter_sql.is_a?(Integer)
484
+ # Legacy: load by primary key (load() or load(id))
485
+ id = filter_sql || __send__(pk)
486
+ return false unless id
487
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?"
488
+ query_params = [id]
489
+ else
490
+ # Filter-based: load("email = ?", ["alice@example.com"])
491
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{filter_sql}"
492
+ query_params = params
493
+ end
494
+
495
+ result = self.class.db.fetch_one(sql, query_params)
486
496
  return false unless result
487
497
 
488
498
  mapping_reverse = self.class.field_mapping.invert
@@ -119,49 +119,187 @@ ${m.data ? '<code class="text-sm text-muted">' + JSON.stringify(m.data) + '</cod
119
119
  </div>`).join('');
120
120
  }
121
121
  function clearMessages() { api('/__dev/api/messages/clear', 'POST', {}).then(() => loadMessages()); }
122
+ var _currentTable='';
122
123
  function loadTables() {
123
- api('/__dev/api/tables').then(d => {
124
- const tables = d.tables || [];
124
+ api('/__dev/api/tables').then(function(d) {
125
+ var tables = d.tables || [];
125
126
  document.getElementById('db-count').textContent = tables.length;
126
- document.getElementById('table-list').innerHTML = tables.map(t =>
127
- `<div style="padding:0.2rem 0.4rem;cursor:pointer;border-radius:0.25rem" onclick="browseTable('${t}')" onmouseover="this.style.background='rgba(198,40,40,0.1)'" onmouseout="this.style.background=''">${t}</div>`
128
- ).join('');
129
- const sel = document.getElementById('seed-table');
130
- sel.innerHTML = '<option value="">Pick table...</option>' + tables.map(t => `<option value="${t}">${t}</option>`).join('');
127
+ var list = document.getElementById('table-list');
128
+ var seed = document.getElementById('seed-table');
129
+ if (!tables.length) { list.innerHTML = '<div class="text-muted">No tables</div>'; return; }
130
+ list.innerHTML = '';
131
+ tables.forEach(function(t) {
132
+ var div = document.createElement('div');
133
+ div.style.cssText = 'cursor:pointer;padding:4px 6px;color:var(--primary);border-radius:4px;margin-bottom:1px';
134
+ div.textContent = t;
135
+ div.addEventListener('mouseenter', function() { if (t !== _currentTable) div.style.background = 'var(--bg-alt,rgba(255,255,255,0.05))'; });
136
+ div.addEventListener('mouseleave', function() { if (t !== _currentTable) div.style.background = ''; });
137
+ div.addEventListener('click', function() {
138
+ list.querySelectorAll('div').forEach(function(d) { d.style.background = ''; d.style.fontWeight = ''; });
139
+ div.style.background = 'var(--primary-bg,rgba(53,114,165,0.2))'; div.style.fontWeight = '600';
140
+ browseTable(t);
141
+ });
142
+ list.appendChild(div);
143
+ });
144
+ seed.innerHTML = '<option value="">Pick table...</option>' + tables.map(function(t) { return '<option value="' + t + '">' + t + '</option>'; }).join('');
131
145
  });
132
146
  }
133
- function browseTable(name) { document.getElementById('query-input').value = 'SELECT * FROM ' + name + ' LIMIT 20'; runQuery(); }
147
+ function browseTable(name) {
148
+ _currentTable = name;
149
+ var lim = document.getElementById('query-limit').value;
150
+ document.getElementById('query-input').value = 'SELECT * FROM ' + name + (lim !== '0' ? ' LIMIT ' + lim : '');
151
+ runQuery();
152
+ }
134
153
  function seedTable() {
135
- const table = document.getElementById('seed-table').value;
136
- const count = parseInt(document.getElementById('seed-count').value) || 10;
154
+ var table = document.getElementById('seed-table').value;
155
+ var count = parseInt(document.getElementById('seed-count').value) || 10;
137
156
  if (!table) return;
138
- api('/__dev/api/seed', 'POST', {table, count}).then(d => {
157
+ api('/__dev/api/seed', 'POST', {table: table, count: count}).then(function(d) {
139
158
  if (d.error) { alert(d.error); return; }
140
159
  browseTable(table);
141
160
  });
142
161
  }
162
+ function _clipCopy(text, btn) {
163
+ var orig = btn.textContent;
164
+ var ta = document.createElement('textarea');
165
+ ta.value = text;
166
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
167
+ document.body.appendChild(ta);
168
+ ta.focus();
169
+ ta.select();
170
+ try { document.execCommand('copy'); btn.textContent = 'Copied!'; btn.style.color = 'var(--success)'; }
171
+ catch(e) { btn.textContent = 'Failed'; btn.style.color = 'var(--danger)'; }
172
+ document.body.removeChild(ta);
173
+ setTimeout(function() { btn.textContent = orig; btn.style.color = ''; }, 1500);
174
+ }
175
+ function copyResults(fmt, btn) {
176
+ var el = document.getElementById('query-results');
177
+ var tbl = el ? el.querySelector('table') : null;
178
+ if (!tbl) { btn.textContent = 'No data'; btn.style.color = 'var(--danger)'; setTimeout(function() { btn.textContent = fmt === 'json' ? 'Copy JSON' : 'Copy CSV'; btn.style.color = ''; }, 1500); return; }
179
+ var headerCells = tbl.querySelectorAll('thead th');
180
+ var headers = []; headerCells.forEach(function(h) { headers.push(h.textContent); });
181
+ var bodyRows = tbl.querySelectorAll('tbody tr');
182
+ var data = [];
183
+ bodyRows.forEach(function(row) {
184
+ var cells = row.querySelectorAll('td');
185
+ var obj = {};
186
+ cells.forEach(function(c, i) { obj[headers[i] || ('col' + i)] = c.textContent === 'null' ? null : c.textContent; });
187
+ data.push(obj);
188
+ });
189
+ var text = '';
190
+ if (fmt === 'json') {
191
+ text = JSON.stringify(data, null, 2);
192
+ } else {
193
+ var lines = [headers.join(',')];
194
+ data.forEach(function(r) {
195
+ lines.push(headers.map(function(h) { var v = (r[h] !== null ? r[h] : ''); return v.indexOf(',') >= 0 || v.indexOf('"') >= 0 ? '"' + v.replace(/"/g, '""') + '"' : v; }).join(','));
196
+ });
197
+ text = lines.join(String.fromCharCode(10));
198
+ }
199
+ _clipCopy(text, btn);
200
+ }
201
+ function pasteData() {
202
+ var NL = String.fromCharCode(10);
203
+ var TAB = String.fromCharCode(9);
204
+ var overlay = document.createElement('div');
205
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:99999;display:flex;align-items:center;justify-content:center';
206
+ var box = document.createElement('div');
207
+ box.style.cssText = 'background:var(--bg,#1e293b);border:1px solid var(--border,#334155);border-radius:0.5rem;padding:1rem;width:500px;max-width:90vw';
208
+ box.innerHTML = '<div style="font-weight:600;margin-bottom:0.5rem;color:var(--text,#e2e8f0)">Paste Data</div><div style="font-size:0.75rem;color:var(--muted,#94a3b8);margin-bottom:0.5rem">Paste JSON array or CSV/tab-separated data below</div>';
209
+ var ta = document.createElement('textarea');
210
+ ta.style.cssText = 'width:100%;height:150px;font-family:monospace;font-size:0.75rem;background:var(--bg-alt,#0f172a);color:var(--text,#e2e8f0);border:1px solid var(--border,#334155);border-radius:0.25rem;padding:0.5rem;resize:vertical';
211
+ ta.placeholder = 'Paste here (Ctrl+V)...';
212
+ var btns = document.createElement('div');
213
+ btns.style.cssText = 'display:flex;gap:0.5rem;margin-top:0.5rem;justify-content:flex-end';
214
+ var cancelBtn = document.createElement('button');
215
+ cancelBtn.className = 'btn btn-sm'; cancelBtn.textContent = 'Cancel';
216
+ cancelBtn.onclick = function() { document.body.removeChild(overlay); };
217
+ var goBtn = document.createElement('button');
218
+ goBtn.className = 'btn btn-sm btn-primary'; goBtn.textContent = 'Generate SQL';
219
+ goBtn.onclick = function() {
220
+ var text = ta.value.trim();
221
+ if (!text) { alert('Paste some data first'); return; }
222
+ var upper = text.substring(0, 50).toUpperCase();
223
+ if (upper.indexOf('INSERT ') >= 0 || upper.indexOf('CREATE ') >= 0 || upper.indexOf('SELECT ') >= 0) {
224
+ document.getElementById('query-input').value = text;
225
+ document.body.removeChild(overlay);
226
+ return;
227
+ }
228
+ var rows = [];
229
+ var parsed = null;
230
+ try { parsed = JSON.parse(text); } catch(e) {}
231
+ if (parsed && Array.isArray(parsed) && parsed.length > 0) {
232
+ rows = parsed;
233
+ } else {
234
+ var lines = text.split(NL);
235
+ if (lines.length < 2) { alert('Need a header row + at least one data row'); return; }
236
+ var sep = lines[0].indexOf(TAB) >= 0 ? TAB : ',';
237
+ var headers = lines[0].split(sep);
238
+ for (var i = 1; i < lines.length; i++) {
239
+ var vals = lines[i].split(sep);
240
+ if (!vals.length || vals.join('').trim() === '') continue;
241
+ var row = {};
242
+ headers.forEach(function(h, idx) { row[h.trim()] = vals[idx] !== undefined ? vals[idx].trim() : ''; });
243
+ rows.push(row);
244
+ }
245
+ }
246
+ if (!rows.length) { alert('No data rows found'); return; }
247
+ var allCols = Object.keys(rows[0]);
248
+ var table = typeof _currentTable !== 'undefined' && _currentTable ? _currentTable : '';
249
+ var isNew = false;
250
+ if (!table) {
251
+ table = prompt('No table selected. Enter table name (creates if new):');
252
+ if (!table) { return; }
253
+ isNew = true;
254
+ }
255
+ var sql = '';
256
+ if (isNew) {
257
+ var hasId = allCols.some(function(c) { return c.toLowerCase() === 'id'; });
258
+ var colDefs = allCols.map(function(h) {
259
+ if (h.toLowerCase() === 'id') return h + ' INTEGER PRIMARY KEY AUTOINCREMENT';
260
+ return h + ' TEXT';
261
+ }).join(', ');
262
+ if (!hasId) colDefs = 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + colDefs;
263
+ sql = 'CREATE TABLE IF NOT EXISTS ' + table + ' (' + colDefs + ');' + NL;
264
+ }
265
+ sql += rows.map(function(r) {
266
+ var keys = Object.keys(r);
267
+ if (isNew) { keys = keys.filter(function(k) { return k.toLowerCase() !== 'id'; }); }
268
+ var cols = keys.join(', ');
269
+ var vals = keys.map(function(k) { var v = r[k]; return v === null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'"; }).join(', ');
270
+ return 'INSERT INTO ' + table + ' (' + cols + ') VALUES (' + vals + ')';
271
+ }).join(';' + NL);
272
+ document.getElementById('query-input').value = sql;
273
+ document.body.removeChild(overlay);
274
+ };
275
+ btns.appendChild(cancelBtn); btns.appendChild(goBtn);
276
+ box.appendChild(ta); box.appendChild(btns);
277
+ overlay.appendChild(box); document.body.appendChild(overlay);
278
+ ta.focus();
279
+ }
143
280
  function runQuery() {
144
- const query = document.getElementById('query-input').value.trim();
145
- const type = document.getElementById('query-type').value;
146
- const errorEl = document.getElementById('query-error');
281
+ var query = document.getElementById('query-input').value.trim();
282
+ var type = document.getElementById('query-type').value;
283
+ var errorEl = document.getElementById('query-error');
284
+ var resultEl = document.getElementById('query-results');
285
+ if (!query) { errorEl.textContent = 'Enter a query'; errorEl.classList.remove('hidden'); return; }
147
286
  errorEl.classList.add('hidden');
148
- if (!query) return;
149
- api('/__dev/api/query', 'POST', {query, type}).then(d => {
150
- if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); return; }
151
- const results = document.getElementById('query-results');
152
- if (d.rows && d.rows.length) {
153
- const cols = d.columns || Object.keys(d.rows[0]);
154
- results.innerHTML = `<div class="text-sm text-muted p-sm">${d.count||d.rows.length} rows</div>
155
- <table><thead><tr>${cols.map(c => '<th>'+c+'</th>').join('')}</tr></thead>
156
- <tbody>${d.rows.map(r => '<tr>' + cols.map(c => '<td class="text-mono text-sm">' + (r[c]===null?'<span class="text-muted">NULL</span>':esc(String(r[c]))) + '</td>').join('') + '</tr>').join('')}</tbody></table>`;
157
- } else if (d.data) {
158
- results.innerHTML = '<pre class="p-md text-mono text-sm">' + JSON.stringify(d.data, null, 2) + '</pre>';
287
+ resultEl.innerHTML = '<p class="text-muted">Running...</p>';
288
+ api('/__dev/api/query', 'POST', {query: query, type: type}).then(function(d) {
289
+ if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); resultEl.innerHTML = ''; return; }
290
+ if (d.rows) {
291
+ if (!d.rows.length) { resultEl.innerHTML = '<p class="text-muted">No results</p>'; return; }
292
+ var cols = d.columns || Object.keys(d.rows[0]);
293
+ var html = '<div class="text-muted" style="margin-bottom:0.25rem">' + d.rows.length + ' row(s)</div><table class="table"><thead><tr>' + cols.map(function(c) { return '<th>' + c + '</th>'; }).join('') + '</tr></thead><tbody>';
294
+ d.rows.forEach(function(row) { html += '<tr>' + cols.map(function(c) { return '<td>' + (row[c] !== null ? row[c] : '<em>null</em>') + '</td>'; }).join('') + '</tr>'; });
295
+ html += '</tbody></table>'; resultEl.innerHTML = html;
159
296
  } else if (d.success) {
160
- results.innerHTML = '<div class="empty">Query executed. ' + (d.affected||0) + ' rows affected.</div>';
297
+ resultEl.innerHTML = '<p style="color:var(--success)">' + d.affected + ' row(s) affected</p>';
298
+ loadTables();
161
299
  } else {
162
- results.innerHTML = '<div class="empty">No results</div>';
300
+ resultEl.innerHTML = '<pre>' + JSON.stringify(d, null, 2) + '</pre>';
163
301
  }
164
- }).catch(e => { errorEl.textContent = e.message; errorEl.classList.remove('hidden'); });
302
+ }).catch(function(e) { errorEl.textContent = e.message; errorEl.classList.remove('hidden'); });
165
303
  }
166
304
  function loadRequests() {
167
305
  api('/__dev/api/requests').then(d => {
@@ -364,4 +502,5 @@ if (d.health) document.getElementById('err-count').textContent = d.health.unreso
364
502
  if (d.requests) document.getElementById('req-count').textContent = d.requests.total || 0;
365
503
  if (d.message_counts) document.getElementById('messages-count').textContent = d.message_counts.total || 0;
366
504
  if (d.request_stats) document.getElementById('req-count').textContent = d.request_stats.total || 0;
505
+ if (d.db_tables !== undefined) document.getElementById('db-count').textContent = d.db_tables;
367
506
  });
@@ -365,10 +365,16 @@ module Tina4
365
365
 
366
366
  # Ensure a database connection is available.
367
367
  def ensure_db!
368
- return unless @db.nil?
368
+ if @db.nil?
369
+ @db = Tina4.database if defined?(Tina4.database) && Tina4.database
370
+ end
369
371
 
370
- @db = Tina4.database if defined?(Tina4.database) && Tina4.database
371
372
  raise "QueryBuilder: No database connection provided." if @db.nil?
373
+
374
+ # Check if the database connection is still open
375
+ if @db.respond_to?(:connected) && !@db.connected
376
+ raise "QueryBuilder: No database connection provided."
377
+ end
372
378
  end
373
379
  end
374
380
  end
@@ -434,6 +434,8 @@ module Tina4
434
434
  .view-modal-content{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:2rem;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
435
435
  .view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
436
436
  .view-modal-close:hover{color:#e2e8f0}
437
+ @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
438
+ .star-wiggle{display:inline-block;transform-origin:center}
437
439
  </style>
438
440
  </head>
439
441
  <body>
@@ -447,7 +449,7 @@ module Tina4
447
449
  <a href="/__dev" class="btn">Dev Admin</a>
448
450
  <a href="#gallery" class="btn">Gallery</a>
449
451
  <a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
450
- <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank">&#11088; Star</a>
452
+ <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank"><span class="star-wiggle">&#9734;</span> Star</a>
451
453
  </div>
452
454
  <div class="status">
453
455
  <span><span class="dot"></span>Server running</span>
@@ -543,6 +545,20 @@ module Tina4
543
545
  document.getElementById('viewModal').addEventListener('click', function(e) {
544
546
  if (e.target === this) this.classList.remove('active');
545
547
  });
548
+ (function(){
549
+ var star=document.querySelector('.star-wiggle');
550
+ if(!star)return;
551
+ function doWiggle(){
552
+ star.style.animation='wiggle 1.2s ease-in-out';
553
+ star.addEventListener('animationend',function onEnd(){
554
+ star.removeEventListener('animationend',onEnd);
555
+ star.style.animation='none';
556
+ var delay=3000+Math.random()*15000;
557
+ setTimeout(doWiggle,delay);
558
+ });
559
+ }
560
+ setTimeout(doWiggle,3000);
561
+ })();
546
562
  </script>
547
563
  </body>
548
564
  </html>