tina4ruby 3.10.3 → 3.10.5

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: cfe8b7f49e17e2a703725d23ce90791acca6e078dc7747a0eebaf97091121330
4
- data.tar.gz: a6a2e0c2e48b69e332d6bc0358929a141f91919b19062639259d7c51cb390a39
3
+ metadata.gz: 1e9679c01c6e303af77a6c9a73c595991c14010af16e36d1b4471eaf0caa6ed3
4
+ data.tar.gz: 11cb40c1733ec3b360f4c9ad047558f7548b68fe8dc452dcaa7764a2f0a2d33f
5
5
  SHA512:
6
- metadata.gz: 24a17f01e57ffda9278e80e0451e54f82111921c1066f21ab30436391e05c475c613c6948824f4fe8d94ddc17f39796240ca47f6ecda8bfe166f805b54335b22
7
- data.tar.gz: 2b0c24c5dbef5d4f4d2c3e5337f7dce2bc1f8a89a90cbb149484fe375914cf8465e604ccc6f30e264a78922e4132ad27f7f4d7189e7f589450cf3baa5361c0f1
6
+ metadata.gz: 9ee5400333e5662e0f812887d75d1c95f3c00af5ea2ae64ece651acc0430f27131653510bd1239d262fda5ce39652957f6a796e141f5530fe1424c67527840e4
7
+ data.tar.gz: e7fa0c88df994957e0836fa0d9437320e7f03cac2f804cd54f55e66f6702294bf9a942fb259aa1e9d7febfed1ffda8a682ed759266bdb53eb78a58682efac0e9
data/lib/tina4/crud.rb CHANGED
@@ -1,8 +1,95 @@
1
1
  # frozen_string_literal: true
2
+ require "json"
2
3
 
3
4
  module Tina4
5
+ # Crud — Auto-generate a complete HTML CRUD interface from a SQL query or ORM model.
6
+ #
7
+ # Usage:
8
+ # Tina4.get "/admin/users" do |request, response|
9
+ # response.html(Tina4::Crud.to_crud(request, {
10
+ # sql: "SELECT id, name, email FROM users",
11
+ # title: "User Management",
12
+ # primary_key: "id"
13
+ # }))
14
+ # end
15
+ #
16
+ # Or with an ORM model class:
17
+ # Tina4.get "/admin/users" do |request, response|
18
+ # response.html(Tina4::Crud.to_crud(request, {
19
+ # model: User,
20
+ # title: "User Management"
21
+ # }))
22
+ # end
4
23
  module Crud
5
24
  class << self
25
+ # Generate a complete CRUD HTML interface: searchable/paginated table
26
+ # with create, edit, and delete modals. Also registers the supporting
27
+ # REST API routes on first call per table.
28
+ #
29
+ # @param request [Tina4::Request] the current request
30
+ # @param options [Hash] configuration options
31
+ # @option options [String] :sql SQL query for listing records
32
+ # @option options [Class] :model ORM model class (alternative to :sql)
33
+ # @option options [String] :title page title (default: table name)
34
+ # @option options [String] :primary_key primary key column (default: "id")
35
+ # @option options [String] :prefix API route prefix (default: "/api")
36
+ # @option options [Integer] :limit records per page (default: 10)
37
+ # @return [String] complete HTML page
38
+ def to_crud(request, options = {})
39
+ sql = options[:sql]
40
+ model = options[:model]
41
+ title = options[:title] || "CRUD"
42
+ pk = options[:primary_key] || "id"
43
+ prefix = options[:prefix] || "/api"
44
+ limit = (options[:limit] || 10).to_i
45
+
46
+ # Determine table name and columns from SQL or model
47
+ if model
48
+ table_name = model.table_name.to_s
49
+ pk = (model.primary_key_field || :id).to_s
50
+ columns = model.field_definitions.keys.map(&:to_s)
51
+ elsif sql
52
+ table_name = extract_table_name(sql)
53
+ columns = extract_columns(sql)
54
+ else
55
+ raise ArgumentError, "Crud.to_crud requires either :sql or :model option"
56
+ end
57
+
58
+ # Parse request params for pagination, search, and sorting
59
+ query_params = request.respond_to?(:query) ? request.query : {}
60
+ page = [(query_params["page"] || 1).to_i, 1].max
61
+ search = query_params["search"].to_s.strip
62
+ sort_col = query_params["sort"] || pk
63
+ sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
+ offset = (page - 1) * limit
65
+
66
+ # Build the data query
67
+ if model
68
+ records, total = fetch_model_data(model, search: search, sort: sort_col,
69
+ sort_dir: sort_dir, limit: limit, offset: offset)
70
+ else
71
+ records, total = fetch_sql_data(sql, search: search, sort: sort_col,
72
+ sort_dir: sort_dir, limit: limit, offset: offset)
73
+ end
74
+
75
+ total_pages = total > 0 ? (total.to_f / limit).ceil : 1
76
+ api_path = "#{prefix}/#{table_name}"
77
+
78
+ # Register supporting CRUD API routes (idempotent)
79
+ register_crud_routes(model, table_name, pk, prefix) unless crud_routes_registered?(table_name, prefix)
80
+
81
+ # Build the HTML
82
+ build_crud_html(
83
+ title: title, table_name: table_name, pk: pk,
84
+ columns: columns, records: records,
85
+ page: page, total_pages: total_pages, total: total,
86
+ limit: limit, search: search, sort_col: sort_col,
87
+ sort_dir: sort_dir, api_path: api_path,
88
+ request_path: request.path
89
+ )
90
+ end
91
+
92
+ # Generate an HTML table from an array of record hashes.
6
93
  def generate_table(records, table_name: "data", primary_key: "id", editable: true)
7
94
  return "<p>No records found.</p>" if records.nil? || records.empty?
8
95
 
@@ -43,12 +130,13 @@ module Tina4
43
130
  html += "</tbody></table></div>"
44
131
 
45
132
  if editable
46
- html += crud_javascript(table_name)
133
+ html += inline_crud_javascript(table_name)
47
134
  end
48
135
 
49
136
  html
50
137
  end
51
138
 
139
+ # Generate an HTML form from a field definition array.
52
140
  def generate_form(fields, action: "/", method: "POST", table_name: "data")
53
141
  html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
54
142
  html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
@@ -95,7 +183,484 @@ module Tina4
95
183
 
96
184
  private
97
185
 
98
- def crud_javascript(table_name)
186
+ # Track which tables have had CRUD routes registered
187
+ def registered_tables
188
+ @registered_tables ||= {}
189
+ end
190
+
191
+ def crud_routes_registered?(table_name, prefix)
192
+ registered_tables["#{prefix}/#{table_name}"]
193
+ end
194
+
195
+ # Register REST API routes for the CRUD interface when using :sql mode.
196
+ # When using :model mode, the caller should use AutoCrud.register instead
197
+ # for full ORM-backed routes. These routes provide basic SQL-backed CRUD.
198
+ def register_crud_routes(model, table_name, pk, prefix)
199
+ api_path = "#{prefix}/#{table_name}"
200
+ registered_tables[api_path] = true
201
+
202
+ # If we have a model, use AutoCrud for full ORM-backed routes
203
+ if model
204
+ Tina4::AutoCrud.register(model)
205
+ Tina4::AutoCrud.generate_routes(prefix: prefix)
206
+ return
207
+ end
208
+
209
+ db = Tina4.database
210
+ return unless db
211
+
212
+ # GET list (already handled by the page itself)
213
+ # POST create
214
+ Tina4::Router.add_route("POST", api_path, proc { |req, res|
215
+ begin
216
+ data = req.body_parsed
217
+ result = db.insert(table_name, data)
218
+ res.json({ data: data, message: "Created" }, status: 201)
219
+ rescue => e
220
+ res.json({ error: e.message }, status: 500)
221
+ end
222
+ })
223
+
224
+ # PUT update
225
+ Tina4::Router.add_route("PUT", "#{api_path}/{id}", proc { |req, res|
226
+ begin
227
+ id = req.params["id"]
228
+ data = req.body_parsed
229
+ db.update(table_name, data, { pk => id })
230
+ res.json({ data: data, message: "Updated" })
231
+ rescue => e
232
+ res.json({ error: e.message }, status: 500)
233
+ end
234
+ })
235
+
236
+ # DELETE
237
+ Tina4::Router.add_route("DELETE", "#{api_path}/{id}", proc { |req, res|
238
+ begin
239
+ id = req.params["id"]
240
+ db.delete(table_name, { pk => id })
241
+ res.json({ message: "Deleted" })
242
+ rescue => e
243
+ res.json({ error: e.message }, status: 500)
244
+ end
245
+ })
246
+ end
247
+
248
+ # Fetch data using an ORM model class
249
+ def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
+ order_by = "#{sort} #{sort_dir.upcase}"
251
+
252
+ if search.empty?
253
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
254
+ total = model.count
255
+ else
256
+ # Build search across all string/text fields
257
+ searchable = model.field_definitions.select { |_, opts|
258
+ [:string, :text].include?(opts[:type])
259
+ }.keys
260
+ if searchable.empty?
261
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
262
+ total = model.count
263
+ else
264
+ where_parts = searchable.map { |col| "#{col} LIKE ?" }
265
+ where_clause = where_parts.join(" OR ")
266
+ params = searchable.map { "%#{search}%" }
267
+ records = model.where(where_clause, params)
268
+ total = records.length
269
+ records = records.slice(offset, limit) || []
270
+ end
271
+ end
272
+
273
+ record_hashes = records.map { |r| r.to_h }
274
+ [record_hashes, total]
275
+ end
276
+
277
+ # Fetch data using a raw SQL query
278
+ def fetch_sql_data(sql, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
279
+ db = Tina4.database
280
+ return [[], 0] unless db
281
+
282
+ # Wrap the original SQL for sorting
283
+ query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
+ query += " ORDER BY #{sort} #{sort_dir.upcase}"
285
+
286
+ if !search.empty?
287
+ # Wrap in a subquery to add search filtering
288
+ wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
289
+ columns = extract_columns(sql)
290
+ search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
+ wrapped += search_parts.join(" OR ")
292
+ wrapped += " ORDER BY #{sort} #{sort_dir.upcase}"
293
+ params = columns.map { "%#{search}%" }
294
+
295
+ # Get total count
296
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
+ count_result = db.fetch_one(count_sql, params)
298
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
+
300
+ results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
+ else
302
+ # Get total count
303
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
304
+ count_result = db.fetch_one(count_sql)
305
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
+
307
+ results = db.fetch(query, [], limit: limit, offset: offset)
308
+ end
309
+
310
+ records = results.respond_to?(:records) ? results.records : results.to_a
311
+ [records, total]
312
+ end
313
+
314
+ # Extract table name from a SQL SELECT statement
315
+ def extract_table_name(sql)
316
+ match = sql.match(/FROM\s+(\w+)/i)
317
+ match ? match[1] : "data"
318
+ end
319
+
320
+ # Extract column names from a SQL SELECT statement
321
+ def extract_columns(sql)
322
+ match = sql.match(/SELECT\s+(.+?)\s+FROM/im)
323
+ return ["*"] unless match
324
+
325
+ cols_str = match[1].strip
326
+ return ["*"] if cols_str == "*"
327
+
328
+ cols_str.split(",").map { |c|
329
+ c = c.strip
330
+ # Handle "table.column AS alias" or "column AS alias"
331
+ if c =~ /\bAS\s+(\w+)/i
332
+ $1
333
+ elsif c.include?(".")
334
+ c.split(".").last.strip
335
+ else
336
+ c.strip
337
+ end
338
+ }
339
+ end
340
+
341
+ # Escape HTML special characters
342
+ def h(text)
343
+ text.to_s
344
+ .gsub("&", "&amp;")
345
+ .gsub("<", "&lt;")
346
+ .gsub(">", "&gt;")
347
+ .gsub('"', "&quot;")
348
+ .gsub("'", "&#39;")
349
+ end
350
+
351
+ # Pretty label from a column name: "user_name" => "User Name"
352
+ def pretty_label(col)
353
+ col.to_s.split("_").map(&:capitalize).join(" ")
354
+ end
355
+
356
+ # Determine input type from column name or ORM field type
357
+ def input_type_for(col, model = nil)
358
+ if model && model.respond_to?(:field_definitions)
359
+ opts = model.field_definitions[col.to_sym]
360
+ if opts
361
+ case opts[:type]
362
+ when :integer then return "number"
363
+ when :float, :decimal then return "number"
364
+ when :boolean then return "checkbox"
365
+ when :date then return "date"
366
+ when :datetime, :timestamp then return "datetime-local"
367
+ when :text then return "textarea"
368
+ end
369
+ end
370
+ end
371
+ # Guess from column name
372
+ return "email" if col.to_s.include?("email")
373
+ return "date" if col.to_s.end_with?("_at", "_date")
374
+ return "number" if col.to_s.end_with?("_id") && col.to_s != "id"
375
+ "text"
376
+ end
377
+
378
+ # Build the complete CRUD HTML page
379
+ def build_crud_html(title:, table_name:, pk:, columns:, records:,
380
+ page:, total_pages:, total:, limit:, search:,
381
+ sort_col:, sort_dir:, api_path:, request_path:)
382
+ # Filter out auto-increment PK from editable columns
383
+ editable_columns = columns.reject { |c| c.to_s == pk.to_s }
384
+
385
+ html = <<~HTML
386
+ <!DOCTYPE html>
387
+ <html lang="en">
388
+ <head>
389
+ <meta charset="UTF-8">
390
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
391
+ <title>#{h(title)}</title>
392
+ <link rel="stylesheet" href="/css/tina4.min.css">
393
+ <style>
394
+ .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
395
+ .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
396
+ .crud-search { max-width: 300px; }
397
+ .crud-actions { display: flex; gap: 0.5rem; align-items: center; }
398
+ .crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
399
+ .crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
400
+ .sort-link { text-decoration: none; color: inherit; cursor: pointer; }
401
+ .sort-link:hover { text-decoration: underline; }
402
+ .sort-indicator { font-size: 0.75rem; }
403
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
404
+ background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
405
+ .modal-overlay.active { display: flex; }
406
+ .modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
407
+ width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
408
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
409
+ .modal-box h3 { margin-top: 0; }
410
+ .modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
411
+ .alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
412
+ .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
413
+ .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+ <div class="crud-container">
418
+ <div id="crud-alert" class="alert"></div>
419
+ <div class="crud-header">
420
+ <h2>#{h(title)}</h2>
421
+ <div class="crud-actions">
422
+ <form method="GET" action="#{h(request_path)}" style="display:flex;gap:0.5rem;">
423
+ <input type="text" name="search" value="#{h(search)}" placeholder="Search..."
424
+ class="form-control crud-search">
425
+ <button type="submit" class="btn btn-secondary">Search</button>
426
+ </form>
427
+ <button class="btn btn-primary" onclick="crudShowCreate()">+ New</button>
428
+ </div>
429
+ </div>
430
+ <div class="crud-info">
431
+ Showing #{records.length} of #{total} records (page #{page} of #{total_pages})
432
+ </div>
433
+ <div class="table-responsive">
434
+ <table class="table table-striped table-hover">
435
+ <thead class="table-dark"><tr>
436
+ HTML
437
+
438
+ # Table headers with sort links
439
+ columns.each do |col|
440
+ next_dir = (sort_col == col.to_s && sort_dir == "asc") ? "desc" : "asc"
441
+ indicator = ""
442
+ if sort_col == col.to_s
443
+ indicator = sort_dir == "asc" ? " <span class=\"sort-indicator\">&#9650;</span>" : " <span class=\"sort-indicator\">&#9660;</span>"
444
+ end
445
+ sort_params = "sort=#{h(col)}&sort_dir=#{next_dir}&page=#{page}&search=#{URI.encode_www_form_component(search)}&limit=#{limit}"
446
+ html += "<th><a class=\"sort-link\" href=\"#{h(request_path)}?#{sort_params}\">#{pretty_label(col)}#{indicator}</a></th>"
447
+ end
448
+ html += "<th>Actions</th></tr></thead><tbody>"
449
+
450
+ # Table body
451
+ if records.empty?
452
+ html += "<tr><td colspan=\"#{columns.length + 1}\" style=\"text-align:center;padding:2rem;\">No records found.</td></tr>"
453
+ else
454
+ records.each do |row|
455
+ pk_value = row[pk.to_sym] || row[pk.to_s] || row[pk]
456
+ html += "<tr>"
457
+ columns.each do |col|
458
+ value = row[col.to_sym] || row[col.to_s] || row[col]
459
+ html += "<td>#{h(value)}</td>"
460
+ end
461
+ html += "<td>"
462
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
463
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
464
+ html += "</td></tr>"
465
+ end
466
+ end
467
+ html += "</tbody></table></div>"
468
+
469
+ # Pagination
470
+ if total_pages > 1
471
+ html += "<div class=\"crud-pagination\">"
472
+ if page > 1
473
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page - 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Prev</a>"
474
+ end
475
+ # Show page numbers (max 7)
476
+ start_page = [page - 3, 1].max
477
+ end_page = [start_page + 6, total_pages].min
478
+ start_page = [end_page - 6, 1].max
479
+ (start_page..end_page).each do |p|
480
+ active = p == page ? " btn-primary" : " btn-secondary"
481
+ html += "<a class=\"btn btn-sm#{active}\" href=\"#{h(request_path)}?page=#{p}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">#{p}</a>"
482
+ end
483
+ if page < total_pages
484
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page + 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Next</a>"
485
+ end
486
+ html += "</div>"
487
+ end
488
+
489
+ # Create modal
490
+ html += build_modal("create", "Create New Record", editable_columns, pk, api_path, request_path)
491
+
492
+ # Edit modal
493
+ html += build_modal("edit", "Edit Record", editable_columns, pk, api_path, request_path, edit: true)
494
+
495
+ # Delete confirmation modal
496
+ html += <<~HTML
497
+ <div class="modal-overlay" id="modal-delete">
498
+ <div class="modal-box">
499
+ <h3>Confirm Delete</h3>
500
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
501
+ <input type="hidden" id="delete-pk-value">
502
+ <div class="modal-footer">
503
+ <button class="btn btn-secondary" onclick="crudCloseModal('delete')">Cancel</button>
504
+ <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ HTML
509
+
510
+ # JavaScript
511
+ html += build_crud_javascript(api_path, pk, editable_columns, request_path)
512
+
513
+ html += "</div></body></html>"
514
+ html
515
+ end
516
+
517
+ # Build a create or edit modal
518
+ def build_modal(id, title, columns, pk, api_path, request_path, edit: false)
519
+ html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
520
+ html += "<div class=\"modal-box\">"
521
+ html += "<h3>#{h(title)}</h3>"
522
+ html += "<form id=\"form-#{id}\" onsubmit=\"return false;\">"
523
+ html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
524
+
525
+ columns.each do |col|
526
+ label = pretty_label(col)
527
+ field_id = "#{id}-#{col}"
528
+ html += "<div class=\"mb-3\">"
529
+ html += "<label for=\"#{field_id}\" class=\"form-label\">#{label}</label>"
530
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{field_id}\" name=\"#{col}\" placeholder=\"Enter #{label.downcase}\">"
531
+ html += "</div>"
532
+ end
533
+
534
+ html += "<div class=\"modal-footer\">"
535
+ html += "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
536
+ html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
537
+ html += "</div></form></div></div>"
538
+ html
539
+ end
540
+
541
+ # Build the JavaScript for the CRUD interface
542
+ def build_crud_javascript(api_path, pk, columns, request_path)
543
+ columns_json = JSON.generate(columns.map(&:to_s))
544
+ <<~HTML
545
+ <script>
546
+ var CRUD_API = '#{api_path}';
547
+ var CRUD_PK = '#{pk}';
548
+ var CRUD_COLUMNS = #{columns_json};
549
+
550
+ function crudShowAlert(message, type) {
551
+ var el = document.getElementById('crud-alert');
552
+ el.className = 'alert alert-' + type;
553
+ el.textContent = message;
554
+ el.style.display = 'block';
555
+ setTimeout(function() { el.style.display = 'none'; }, 3000);
556
+ }
557
+
558
+ function crudShowCreate() {
559
+ var form = document.getElementById('form-create');
560
+ form.reset();
561
+ document.getElementById('modal-create').classList.add('active');
562
+ }
563
+
564
+ function crudShowEdit(id) {
565
+ fetch(CRUD_API + '/' + id)
566
+ .then(function(r) { return r.json(); })
567
+ .then(function(result) {
568
+ var data = result.data || result;
569
+ document.getElementById('edit-pk-value').value = id;
570
+ CRUD_COLUMNS.forEach(function(col) {
571
+ var input = document.getElementById('edit-' + col);
572
+ if (input) input.value = data[col] != null ? data[col] : '';
573
+ });
574
+ document.getElementById('modal-edit').classList.add('active');
575
+ })
576
+ .catch(function(e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
577
+ }
578
+
579
+ function crudShowDelete(id) {
580
+ document.getElementById('delete-pk-value').value = id;
581
+ document.getElementById('modal-delete').classList.add('active');
582
+ }
583
+
584
+ function crudCloseModal(name) {
585
+ document.getElementById('modal-' + name).classList.remove('active');
586
+ }
587
+
588
+ function crudSaveCreate() {
589
+ var data = {};
590
+ CRUD_COLUMNS.forEach(function(col) {
591
+ var input = document.getElementById('create-' + col);
592
+ if (input) data[col] = input.value;
593
+ });
594
+ fetch(CRUD_API, {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify(data)
598
+ })
599
+ .then(function(r) { return r.json(); })
600
+ .then(function(result) {
601
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
602
+ crudCloseModal('create');
603
+ crudShowAlert('Record created successfully', 'success');
604
+ setTimeout(function() { window.location.reload(); }, 500);
605
+ })
606
+ .catch(function(e) { crudShowAlert('Failed to create: ' + e, 'danger'); });
607
+ }
608
+
609
+ function crudSaveEdit() {
610
+ var id = document.getElementById('edit-pk-value').value;
611
+ var data = {};
612
+ CRUD_COLUMNS.forEach(function(col) {
613
+ var input = document.getElementById('edit-' + col);
614
+ if (input) data[col] = input.value;
615
+ });
616
+ fetch(CRUD_API + '/' + id, {
617
+ method: 'PUT',
618
+ headers: { 'Content-Type': 'application/json' },
619
+ body: JSON.stringify(data)
620
+ })
621
+ .then(function(r) { return r.json(); })
622
+ .then(function(result) {
623
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
624
+ crudCloseModal('edit');
625
+ crudShowAlert('Record updated successfully', 'success');
626
+ setTimeout(function() { window.location.reload(); }, 500);
627
+ })
628
+ .catch(function(e) { crudShowAlert('Failed to update: ' + e, 'danger'); });
629
+ }
630
+
631
+ function crudConfirmDelete() {
632
+ var id = document.getElementById('delete-pk-value').value;
633
+ fetch(CRUD_API + '/' + id, { method: 'DELETE' })
634
+ .then(function(r) { return r.json(); })
635
+ .then(function(result) {
636
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
637
+ crudCloseModal('delete');
638
+ crudShowAlert('Record deleted successfully', 'success');
639
+ setTimeout(function() { window.location.reload(); }, 500);
640
+ })
641
+ .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
642
+ }
643
+
644
+ // Close modal on overlay click
645
+ document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
646
+ overlay.addEventListener('click', function(e) {
647
+ if (e.target === overlay) overlay.classList.remove('active');
648
+ });
649
+ });
650
+
651
+ // Close modal on Escape key
652
+ document.addEventListener('keydown', function(e) {
653
+ if (e.key === 'Escape') {
654
+ document.querySelectorAll('.modal-overlay.active').forEach(function(m) {
655
+ m.classList.remove('active');
656
+ });
657
+ }
658
+ });
659
+ </script>
660
+ HTML
661
+ end
662
+
663
+ def inline_crud_javascript(table_name)
99
664
  <<~JS
100
665
  <script>
101
666
  function crudSave(table, id) {
@@ -121,4 +686,7 @@ module Tina4
121
686
  end
122
687
  end
123
688
  end
689
+
690
+ # Uppercase alias for convenience: Tina4::CRUD.to_crud(...)
691
+ CRUD = Crud
124
692
  end
@@ -368,9 +368,85 @@ module Tina4
368
368
  end
369
369
  end
370
370
 
371
+ # Find the first occurrence of +needle+ outside quotes and parentheses.
372
+ # Returns the index, or -1 if not found.
373
+ def find_outside_quotes(expr, needle)
374
+ in_q = nil
375
+ depth = 0
376
+ i = 0
377
+ while i <= expr.length - needle.length
378
+ ch = expr[i]
379
+ if (ch == '"' || ch == "'") && depth == 0
380
+ if in_q.nil?
381
+ in_q = ch
382
+ elsif ch == in_q
383
+ in_q = nil
384
+ end
385
+ i += 1
386
+ next
387
+ end
388
+ if in_q
389
+ i += 1
390
+ next
391
+ end
392
+ if ch == "("
393
+ depth += 1
394
+ elsif ch == ")"
395
+ depth -= 1
396
+ end
397
+ if depth == 0 && expr[i, needle.length] == needle
398
+ return i
399
+ end
400
+ i += 1
401
+ end
402
+ -1
403
+ end
404
+
405
+ # Split +expr+ on +sep+ only when +sep+ is outside quotes and parentheses.
406
+ def split_outside_quotes(expr, sep)
407
+ parts = []
408
+ current_start = 0
409
+ in_q = nil
410
+ depth = 0
411
+ i = 0
412
+ while i <= expr.length - sep.length
413
+ ch = expr[i]
414
+ if (ch == '"' || ch == "'") && depth == 0
415
+ if in_q.nil?
416
+ in_q = ch
417
+ elsif ch == in_q
418
+ in_q = nil
419
+ end
420
+ i += 1
421
+ next
422
+ end
423
+ if in_q
424
+ i += 1
425
+ next
426
+ end
427
+ if ch == "("
428
+ depth += 1
429
+ elsif ch == ")"
430
+ depth -= 1
431
+ end
432
+ if depth == 0 && expr[i, sep.length] == sep
433
+ parts << expr[current_start...i]
434
+ i += sep.length
435
+ current_start = i
436
+ next
437
+ end
438
+ i += 1
439
+ end
440
+ parts << expr[current_start..]
441
+ parts
442
+ end
443
+
371
444
  def evaluate_expression(expr)
372
445
  expr = expr.strip
446
+
447
+ # String literal early-return
373
448
  return Regexp.last_match(1) if expr =~ /\A"([^"]*)"\z/ || expr =~ /\A'([^']*)'\z/
449
+
374
450
  return expr.to_i if expr =~ /\A-?\d+\z/
375
451
  return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
376
452
  return true if expr == "true"
@@ -382,10 +458,30 @@ module Tina4
382
458
  if expr =~ /\A(\d+)\.\.(\d+)\z/
383
459
  return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i)
384
460
  end
385
- if expr.include?("~")
386
- parts = expr.split("~").map { |p| evaluate_expression(p.strip) }
387
- return parts.map(&:to_s).join
461
+
462
+ # Null coalescing: value ?? "default"
463
+ if find_outside_quotes(expr, "??") >= 0
464
+ pos = find_outside_quotes(expr, "??")
465
+ left = expr[0...pos]
466
+ right = expr[(pos + 2)..]
467
+ val = evaluate_expression(left.strip)
468
+ return val unless val.nil?
469
+ return evaluate_expression(right.strip)
470
+ end
471
+
472
+ # String concatenation with ~ (only outside quotes/parens)
473
+ if find_outside_quotes(expr, "~") >= 0
474
+ parts = split_outside_quotes(expr, "~")
475
+ return parts.map { |p| (evaluate_expression(p.strip) || "").to_s }.join
476
+ end
477
+
478
+ # Comparison operators (only outside quotes/parens)
479
+ [" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "].each do |op|
480
+ if find_outside_quotes(expr, op) >= 0
481
+ return evaluate_condition(expr)
482
+ end
388
483
  end
484
+
389
485
  if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
390
486
  left = evaluate_expression(Regexp.last_match(1))
391
487
  op = Regexp.last_match(2)
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.3"
4
+ VERSION = "3.10.5"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -82,6 +82,7 @@ module Tina4
82
82
  # ── Lazy-loaded: optional modules ─────────────────────────────────────
83
83
  autoload :Swagger, File.expand_path("tina4/swagger", __dir__)
84
84
  autoload :Crud, File.expand_path("tina4/crud", __dir__)
85
+ autoload :CRUD, File.expand_path("tina4/crud", __dir__)
85
86
  autoload :API, File.expand_path("tina4/api", __dir__)
86
87
  autoload :APIResponse, File.expand_path("tina4/api", __dir__)
87
88
  autoload :GraphQLType, File.expand_path("tina4/graphql", __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.3
4
+ version: 3.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team