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.
- checksums.yaml +4 -4
- data/lib/tina4/ai.rb +492 -134
- data/lib/tina4/cli.rb +784 -74
- data/lib/tina4/database.rb +12 -5
- data/lib/tina4/dev_admin.rb +266 -66
- data/lib/tina4/events.rb +19 -0
- data/lib/tina4/field_types.rb +6 -1
- data/lib/tina4/frond.rb +112 -81
- data/lib/tina4/metrics.rb +43 -2
- data/lib/tina4/orm.rb +17 -7
- data/lib/tina4/public/js/tina4-dev-admin.min.js +167 -28
- data/lib/tina4/query_builder.rb +8 -2
- data/lib/tina4/rack_app.rb +17 -1
- data/lib/tina4/template.rb +57 -15
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
937
|
+
# ── Ternary: condition ? "yes" : "no" ──
|
|
938
|
+
|
|
939
|
+
def eval_ternary(expr, context)
|
|
904
940
|
q_pos = find_outside_quotes(expr, "?")
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
954
|
+
def eval_inline_if(expr, context)
|
|
918
955
|
if_pos = find_outside_quotes(expr, " if ")
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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')
|
|
127
|
-
|
|
128
|
-
).
|
|
129
|
-
|
|
130
|
-
|
|
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) {
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
api('/__dev/api/query', 'POST', {query, type}).then(d
|
|
150
|
-
if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); return; }
|
|
151
|
-
|
|
152
|
-
if (d.rows
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
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
|
-
|
|
297
|
+
resultEl.innerHTML = '<p style="color:var(--success)">' + d.affected + ' row(s) affected</p>';
|
|
298
|
+
loadTables();
|
|
161
299
|
} else {
|
|
162
|
-
|
|
300
|
+
resultEl.innerHTML = '<pre>' + JSON.stringify(d, null, 2) + '</pre>';
|
|
163
301
|
}
|
|
164
|
-
}).catch(e
|
|
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
|
});
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -365,10 +365,16 @@ module Tina4
|
|
|
365
365
|
|
|
366
366
|
# Ensure a database connection is available.
|
|
367
367
|
def ensure_db!
|
|
368
|
-
|
|
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
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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">&#
|
|
452
|
+
<a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank"><span class="star-wiggle">☆</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>
|