recycle_bin 1.1.0 → 1.2.0

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.
@@ -2,9 +2,21 @@
2
2
 
3
3
  module RecycleBin
4
4
  class TrashController < ApplicationController
5
+ layout 'recycle_bin/layouts/recycle_bin'
5
6
  before_action :load_deleted_items_with_pagination, only: [:index]
7
+ before_action :load_dashboard_data, only: [:dashboard]
6
8
  before_action :find_item, only: %i[show restore destroy]
7
9
 
10
+ # Dashboard view - Overview with stats and recent activity
11
+ def dashboard
12
+ respond_to do |format|
13
+ format.html { render :dashboard }
14
+ format.csv { export_to_csv(@all_deleted_items) }
15
+ format.json { export_to_json(@all_deleted_items) }
16
+ end
17
+ end
18
+
19
+ # All Items view - Detailed list with filtering
8
20
  def index
9
21
  # Apply filters to the relation before pagination
10
22
  @filtered_items = filter_items_relation(@all_deleted_items_relation)
@@ -31,6 +43,13 @@ module RecycleBin
31
43
 
32
44
  # Sort by deletion time (most recent first) - only for current page to maintain performance
33
45
  @deleted_items.sort_by!(&:deleted_at).reverse!
46
+
47
+ # Handle export requests
48
+ respond_to do |format|
49
+ format.html
50
+ format.csv { export_to_csv(@filtered_items) }
51
+ format.json { export_to_json(@filtered_items) }
52
+ end
34
53
  end
35
54
 
36
55
  def show
@@ -46,7 +65,7 @@ module RecycleBin
46
65
  redirect_to root_path, alert: "Failed to restore #{@item.class.name}."
47
66
  end
48
67
  rescue => e
49
- Rails.logger.error "Error restoring item: #{e.message}"
68
+ log_error('Error restoring item', e, item: @item)
50
69
  redirect_to root_path, alert: "Failed to restore #{@item.class.name}."
51
70
  end
52
71
 
@@ -58,42 +77,86 @@ module RecycleBin
58
77
  redirect_to root_path, alert: "Failed to delete #{model_name}."
59
78
  end
60
79
  rescue => e
61
- Rails.logger.error "Error deleting item: #{e.message}"
80
+ log_error('Error deleting item', e, item: @item)
62
81
  redirect_to root_path, alert: "Failed to delete #{model_name}."
63
82
  end
64
83
 
65
84
  def bulk_restore
66
85
  restored_count = 0
86
+ failed_count = 0
67
87
  selected_items = parse_bulk_selection
68
88
 
89
+ if selected_items.empty?
90
+ redirect_to trash_index_path, alert: 'No items selected for restoration.'
91
+ return
92
+ end
93
+
69
94
  selected_items.each do |model_class, id|
70
95
  next unless model_class && id
71
96
 
72
97
  model = safe_constantize_model(model_class)
73
- next unless model.respond_to?(:with_deleted)
98
+ unless model.respond_to?(:with_deleted)
99
+ Rails.logger.warn "Model #{model_class} does not support soft delete"
100
+ failed_count += 1
101
+ next
102
+ end
74
103
 
75
104
  item = model.with_deleted.find_by(id: id)
76
- restored_count += 1 if item&.restore
105
+ if item&.restore
106
+ restored_count += 1
107
+ Rails.logger.info "Successfully restored #{model_class}##{id}"
108
+ else
109
+ failed_count += 1
110
+ Rails.logger.warn "Failed to restore #{model_class}##{id}"
111
+ end
77
112
  end
78
113
 
79
- redirect_to trash_index_path, notice: "#{restored_count} items restored successfully!"
114
+ message = "#{restored_count} items restored successfully!"
115
+ message += " (#{failed_count} failed)" if failed_count.positive?
116
+
117
+ redirect_to trash_index_path, notice: message
118
+ rescue => e
119
+ log_error('Bulk restore failed', e)
120
+ redirect_to trash_index_path, alert: "Bulk restore failed: #{e.message}"
80
121
  end
81
122
 
82
123
  def bulk_destroy
83
124
  destroyed_count = 0
125
+ failed_count = 0
84
126
  selected_items = parse_bulk_selection
85
127
 
128
+ if selected_items.empty?
129
+ redirect_to trash_index_path, alert: 'No items selected for deletion.'
130
+ return
131
+ end
132
+
86
133
  selected_items.each do |model_class, id|
87
134
  next unless model_class && id
88
135
 
89
136
  model = safe_constantize_model(model_class)
90
- next unless model.respond_to?(:with_deleted)
137
+ unless model.respond_to?(:with_deleted)
138
+ Rails.logger.warn "Model #{model_class} does not support soft delete"
139
+ failed_count += 1
140
+ next
141
+ end
91
142
 
92
143
  item = model.with_deleted.find_by(id: id)
93
- destroyed_count += 1 if item&.destroy!
144
+ if item&.destroy!
145
+ destroyed_count += 1
146
+ Rails.logger.info "Successfully destroyed #{model_class}##{id}"
147
+ else
148
+ failed_count += 1
149
+ Rails.logger.warn "Failed to destroy #{model_class}##{id}"
150
+ end
94
151
  end
95
152
 
96
- redirect_to trash_index_path, notice: "#{destroyed_count} items permanently deleted."
153
+ message = "#{destroyed_count} items permanently deleted."
154
+ message += " (#{failed_count} failed)" if failed_count.positive?
155
+
156
+ redirect_to trash_index_path, notice: message
157
+ rescue => e
158
+ log_error('Bulk destroy failed', e)
159
+ redirect_to trash_index_path, alert: "Bulk destroy failed: #{e.message}"
97
160
  end
98
161
 
99
162
  private
@@ -103,6 +166,62 @@ module RecycleBin
103
166
  @all_deleted_items_relation = build_deleted_items_relation
104
167
  end
105
168
 
169
+ def load_dashboard_data
170
+ # Get all deleted items for dashboard
171
+ @all_deleted_items = RecycleBin.all_deleted_items(limit: 1000).to_a
172
+ @total_count = @all_deleted_items.count
173
+ @model_types = @all_deleted_items.map { |item| item.class.name }.uniq.sort
174
+
175
+ # Recent activity (last 10 items)
176
+ @recent_items = @all_deleted_items.sort_by(&:deleted_at).reverse.first(10)
177
+
178
+ # Model breakdown for chart
179
+ model_counts = @all_deleted_items.group_by { |item| item.class.name }
180
+ model_data = model_counts.map do |model_name, items|
181
+ count = items.count
182
+ percentage = @total_count.positive? ? (count.to_f / @total_count * 100) : 0
183
+ {
184
+ name: model_name,
185
+ count: count,
186
+ percentage: percentage
187
+ }
188
+ end
189
+ @model_breakdown = model_data.sort_by { |data| -data[:count] }
190
+
191
+ # Weekly stats (last 7 days)
192
+ @weekly_stats = if @all_deleted_items.any?
193
+ calculate_weekly_stats(@all_deleted_items)
194
+ else
195
+ []
196
+ end
197
+ end
198
+
199
+ def calculate_weekly_stats(items)
200
+ # Get last 7 days
201
+ days = 7.times.map { |i| (Date.current - i.days) }.reverse
202
+
203
+ # Group items by date
204
+ items_by_date = items.group_by { |item| item.deleted_at.to_date }
205
+
206
+ # Calculate stats for each day
207
+ max_count = 0
208
+ day_stats = days.map do |date|
209
+ count = items_by_date[date]&.count || 0
210
+ max_count = [max_count, count].max
211
+ {
212
+ day: date.strftime('%a'),
213
+ count: count,
214
+ date: date
215
+ }
216
+ end
217
+
218
+ # Calculate percentages
219
+ day_stats.map do |stat|
220
+ stat[:percentage] = max_count.positive? ? (stat[:count].to_f / max_count * 100) : 0
221
+ stat
222
+ end
223
+ end
224
+
106
225
  def build_deleted_items_relation
107
226
  relations = []
108
227
 
@@ -114,13 +233,12 @@ module RecycleBin
114
233
  relations << relation
115
234
  end
116
235
  rescue => e
117
- Rails.logger.debug "Skipping model #{model_name}: #{e.message}"
236
+ log_debug("Skipping model #{model_name}: #{e.message}")
118
237
  next
119
238
  end
120
239
 
121
240
  # If we have relations, combine them; otherwise return empty relation
122
241
  if relations.any?
123
- # For now, we'll work with arrays since UNION queries are complex across different models
124
242
  # Convert relations to arrays and combine
125
243
  combined_items = []
126
244
  relations.each do |relation|
@@ -137,12 +255,81 @@ module RecycleBin
137
255
  def filter_items_relation(items_collection)
138
256
  filtered_items = items_collection.items
139
257
 
258
+ # Apply search filter first
259
+ filtered_items = filter_by_search(filtered_items) if params[:search].present?
260
+
261
+ # Apply other filters
140
262
  filtered_items = filter_by_type(filtered_items) if params[:type].present?
141
263
  filtered_items = filter_by_time(filtered_items) if params[:time].present?
264
+ filtered_items = filter_by_user(filtered_items) if params[:deleted_by].present?
265
+ filtered_items = filter_by_size(filtered_items) if params[:size].present?
266
+ filtered_items = filter_by_date_range(filtered_items) if params[:date_from].present? || params[:date_to].present?
142
267
 
143
268
  DeletedItemsCollection.new(filtered_items)
144
269
  end
145
270
 
271
+ def filter_by_search(items)
272
+ search_term = params[:search].downcase.strip
273
+ return items if search_term.blank?
274
+
275
+ items.select do |item|
276
+ # Priority 1: Exact matches in primary title fields (highest relevance)
277
+ primary_fields = %w[title name subject]
278
+ exact_title_match = primary_fields.any? do |field|
279
+ if item.respond_to?(field)
280
+ field_value = item.send(field)
281
+ field_value && field_value.to_s.downcase.strip == search_term
282
+ else
283
+ false
284
+ end
285
+ end
286
+
287
+ # Priority 2: Starts with matches in primary fields
288
+ starts_with_match = !exact_title_match && primary_fields.any? do |field|
289
+ if item.respond_to?(field)
290
+ field_value = item.send(field)
291
+ field_value && field_value.to_s.downcase.strip.start_with?(search_term)
292
+ else
293
+ false
294
+ end
295
+ end
296
+
297
+ # Priority 3: Contains matches in primary fields (only if search term is 3+ characters)
298
+ primary_contains_match = !exact_title_match && !starts_with_match && search_term.length >= 3 && primary_fields.any? do |field|
299
+ if item.respond_to?(field)
300
+ field_value = item.send(field)
301
+ field_value && field_value.to_s.downcase.include?(search_term)
302
+ else
303
+ false
304
+ end
305
+ end
306
+
307
+ # Priority 4: ID exact match
308
+ id_match = !exact_title_match && !starts_with_match && !primary_contains_match && item.id.to_s == search_term
309
+
310
+ # Priority 5: Model class name match (only if search term is 3+ characters)
311
+ class_match = !exact_title_match && !starts_with_match && !primary_contains_match && !id_match &&
312
+ search_term.length >= 3 && item.class.name.downcase.include?(search_term)
313
+
314
+ # Only search secondary fields for longer search terms (4+ characters) and as last resort
315
+ secondary_match = false
316
+ if !exact_title_match && !starts_with_match && !primary_contains_match && !id_match && !class_match && search_term.length >= 4
317
+ secondary_fields = %w[email] # Removed content, body, description to reduce false matches
318
+ secondary_match = secondary_fields.any? do |field|
319
+ if item.respond_to?(field)
320
+ field_value = item.send(field)
321
+ field_value && !field_value.to_s.strip.empty? && field_value.to_s.downcase.include?(search_term)
322
+ else
323
+ false
324
+ end
325
+ end
326
+ end
327
+
328
+ # Return true if any priority level matches
329
+ exact_title_match || starts_with_match || primary_contains_match || id_match || class_match || secondary_match
330
+ end
331
+ end
332
+
146
333
  def filter_by_type(items)
147
334
  target_class_name = params[:type]
148
335
  items.select { |item| item.class.name == target_class_name }
@@ -153,12 +340,77 @@ module RecycleBin
153
340
  when 'today' then 1.day.ago
154
341
  when 'week' then 1.week.ago
155
342
  when 'month' then 1.month.ago
343
+ when 'year' then 1.year.ago
156
344
  else return items
157
345
  end
158
346
 
159
347
  items.select { |item| item.deleted_at >= cutoff_time }
160
348
  end
161
349
 
350
+ def filter_by_user(items)
351
+ user_id = params[:deleted_by]
352
+ return items if user_id.blank?
353
+
354
+ items.select do |item|
355
+ # Check if item has user association or deleted_by field
356
+ if item.respond_to?(:user_id)
357
+ item.user_id.to_s == user_id.to_s
358
+ elsif item.respond_to?(:deleted_by)
359
+ item.deleted_by.to_s == user_id.to_s
360
+ elsif item.respond_to?(:created_by)
361
+ item.created_by.to_s == user_id.to_s
362
+ else
363
+ false
364
+ end
365
+ end
366
+ end
367
+
368
+ def filter_by_size(items)
369
+ size_filter = params[:size]
370
+ return items if size_filter.blank?
371
+
372
+ items.select do |item|
373
+ item_size = calculate_item_memory_size(item)
374
+ case size_filter
375
+ when 'small'
376
+ item_size < 1.kilobyte
377
+ when 'medium'
378
+ item_size >= 1.kilobyte && item_size < 100.kilobytes
379
+ when 'large'
380
+ item_size >= 100.kilobytes
381
+ else
382
+ true
383
+ end
384
+ end
385
+ end
386
+
387
+ def filter_by_date_range(items)
388
+ date_from = parse_date_param(params[:date_from])
389
+ date_to = parse_date_param(params[:date_to])
390
+
391
+ return items if date_from.nil? && date_to.nil?
392
+
393
+ items.select do |item|
394
+ item_date = item.deleted_at
395
+ next false unless item_date
396
+
397
+ from_condition = date_from.nil? || item_date >= date_from
398
+ to_condition = date_to.nil? || item_date <= date_to
399
+
400
+ from_condition && to_condition
401
+ end
402
+ end
403
+
404
+ def parse_date_param(date_string)
405
+ return nil if date_string.blank?
406
+
407
+ begin
408
+ Date.parse(date_string)
409
+ rescue
410
+ nil
411
+ end
412
+ end
413
+
162
414
  def get_all_model_types
163
415
  all_items = build_deleted_items_relation.items
164
416
  all_items.map { |item| item.class.name }.uniq.sort
@@ -175,43 +427,64 @@ module RecycleBin
175
427
  @item = model_class.with_deleted.find(params[:id])
176
428
  rescue ActiveRecord::RecordNotFound
177
429
  redirect_to trash_index_path, alert: 'Item not found.'
430
+ rescue => e
431
+ log_error('Error finding item', e, model_type: params[:model_type], id: params[:id])
432
+ redirect_to trash_index_path, alert: 'An error occurred while finding the item.'
178
433
  end
179
434
 
180
435
  def load_associations(item)
436
+ return {} unless item.respond_to?(:class)
437
+
181
438
  associations = {}
182
439
 
183
- item.class.reflect_on_all_associations.each do |association|
184
- next if association.macro == :belongs_to
440
+ begin
441
+ item.class.reflect_on_all_associations.each do |association|
442
+ next if association.macro == :belongs_to
185
443
 
186
- related_items = safely_load_association(item, association)
187
- associations[association.name] = related_items if related_items.present?
444
+ related_items = safely_load_association(item, association)
445
+ associations[association.name] = related_items if related_items.present?
446
+ end
447
+ rescue => e
448
+ log_error('Error loading associations', e, item: item)
449
+ associations = {}
188
450
  end
189
451
 
190
452
  associations
191
453
  end
192
454
 
193
455
  def safely_load_association(item, association)
456
+ return nil unless item.respond_to?(association.name)
457
+
194
458
  related_items = item.send(association.name)
195
459
  limit_association_items(related_items)
196
460
  rescue => e
197
- Rails.logger.debug "Skipping association #{association.name}: #{e.message}"
461
+ log_debug("Skipping association #{association.name}: #{e.message}")
198
462
  nil
199
463
  end
200
464
 
201
465
  def limit_association_items(related_items)
466
+ return [] if related_items.blank?
202
467
  return Array(related_items) unless related_items.respond_to?(:limit)
203
468
 
204
- if related_items.respond_to?(:first)
205
- related_items.is_a?(Array) ? related_items.first(10) : related_items
206
- else
207
- related_items.limit(10)
469
+ begin
470
+ if related_items.respond_to?(:first)
471
+ related_items.is_a?(Array) ? related_items.first(10) : related_items
472
+ else
473
+ related_items.limit(10)
474
+ end
475
+ rescue => e
476
+ log_debug("Error limiting association items: #{e.message}")
477
+ Array(related_items).first(10)
208
478
  end
209
479
  end
210
480
 
211
481
  def calculate_item_memory_size(item)
482
+ return 0 unless item.respond_to?(:attributes)
483
+
212
484
  # Simple calculation of item memory footprint
213
485
  item.attributes.to_s.bytesize
214
- rescue
486
+ rescue => e
487
+ log_debug("Error calculating memory size: #{e.message}")
215
488
  0
216
489
  end
217
490
 
@@ -220,24 +493,38 @@ module RecycleBin
220
493
  return [] unless selected_items.is_a?(Array)
221
494
 
222
495
  parse_item_strings(selected_items)
496
+ rescue => e
497
+ log_error('Error parsing bulk selection', e)
498
+ []
223
499
  end
224
500
 
225
501
  def extract_selected_items
226
502
  selected_items = params[:selected_items]
227
503
 
504
+ return [] if selected_items.blank?
505
+
228
506
  return JSON.parse(selected_items) if selected_items.is_a?(String)
229
507
 
230
508
  selected_items
231
- rescue JSON::ParserError
509
+ rescue JSON::ParserError => e
510
+ log_error('Invalid JSON in selected items', e)
511
+ []
512
+ rescue => e
513
+ log_error('Error extracting selected items', e)
232
514
  []
233
515
  end
234
516
 
235
517
  def parse_item_strings(selected_items)
518
+ return [] unless selected_items.is_a?(Array)
519
+
236
520
  parsed_items = selected_items.filter_map do |item_string|
237
521
  parse_single_item(item_string)
238
522
  end
239
523
 
240
524
  parsed_items.reject { |model_class, id| model_class.blank? || id.zero? }
525
+ rescue => e
526
+ log_error('Error parsing item strings', e)
527
+ []
241
528
  end
242
529
 
243
530
  def parse_single_item(item_string)
@@ -247,6 +534,64 @@ module RecycleBin
247
534
  return nil if model_class.blank? || id.blank?
248
535
 
249
536
  [model_class, id.to_i]
537
+ rescue => e
538
+ log_debug("Error parsing single item '#{item_string}': #{e.message}")
539
+ nil
540
+ end
541
+
542
+ # Export functionality
543
+ def export_to_csv(items)
544
+ csv_data = generate_csv_data(items)
545
+ send_data csv_data,
546
+ filename: "recycle_bin_export_#{Time.current.strftime('%Y%m%d_%H%M%S')}.csv",
547
+ type: 'text/csv'
548
+ end
549
+
550
+ def export_to_json(items)
551
+ json_data = generate_json_data(items)
552
+ send_data json_data,
553
+ filename: "recycle_bin_export_#{Time.current.strftime('%Y%m%d_%H%M%S')}.json",
554
+ type: 'application/json'
555
+ end
556
+
557
+ def generate_csv_data(items)
558
+ require 'csv'
559
+
560
+ CSV.generate(headers: true) do |csv|
561
+ # Headers
562
+ csv << ['ID', 'Model Type', 'Title', 'Deleted At', 'Size (bytes)', 'Attributes']
563
+
564
+ # Data rows
565
+ items.each do |item|
566
+ csv << [
567
+ item.id,
568
+ item.class.name,
569
+ item.recyclable_title,
570
+ item.deleted_at&.strftime('%Y-%m-%d %H:%M:%S'),
571
+ calculate_item_memory_size(item),
572
+ item.attributes.except('deleted_at').to_json
573
+ ]
574
+ end
575
+ end
576
+ end
577
+
578
+ def generate_json_data(items)
579
+ data = {
580
+ export_date: Time.current.iso8601,
581
+ total_items: items.count,
582
+ items: items.map do |item|
583
+ {
584
+ id: item.id,
585
+ model_type: item.class.name,
586
+ title: item.recyclable_title,
587
+ deleted_at: item.deleted_at&.iso8601,
588
+ size_bytes: calculate_item_memory_size(item),
589
+ attributes: item.attributes.except('deleted_at')
590
+ }
591
+ end
592
+ }
593
+
594
+ JSON.pretty_generate(data)
250
595
  end
251
596
 
252
597
  def trash_index_path
@@ -254,6 +599,60 @@ module RecycleBin
254
599
  rescue StandardError
255
600
  root_path
256
601
  end
602
+
603
+ def safe_constantize_model(model_name)
604
+ return nil if model_name.blank?
605
+
606
+ model_name.constantize
607
+ rescue NameError => e
608
+ log_warn("Failed to constantize model #{model_name}: #{e.message}")
609
+ nil
610
+ rescue => e
611
+ log_error("Unexpected error constantizing model #{model_name}", e)
612
+ nil
613
+ end
614
+
615
+ # Enhanced logging methods
616
+ def log_error(message, error, context = {})
617
+ error_context = {
618
+ message: message,
619
+ error: error.message,
620
+ backtrace: error.backtrace&.first(5),
621
+ controller: controller_name,
622
+ action: action_name,
623
+ params: params.except(:controller, :action, :password, :password_confirmation),
624
+ **context
625
+ }
626
+
627
+ Rails.logger.error("RecycleBin Error: #{JSON.pretty_generate(error_context)}")
628
+
629
+ # Log to external service if configured
630
+ return unless RecycleBin.config.respond_to?(:error_logging_service)
631
+
632
+ RecycleBin.config.error_logging_service.call(error_context)
633
+ end
634
+
635
+ def log_warn(message, context = {})
636
+ warn_context = {
637
+ message: message,
638
+ controller: controller_name,
639
+ action: action_name,
640
+ **context
641
+ }
642
+
643
+ Rails.logger.warn("RecycleBin Warning: #{JSON.pretty_generate(warn_context)}")
644
+ end
645
+
646
+ def log_debug(message, context = {})
647
+ debug_context = {
648
+ message: message,
649
+ controller: controller_name,
650
+ action: action_name,
651
+ **context
652
+ }
653
+
654
+ Rails.logger.debug("RecycleBin Debug: #{JSON.pretty_generate(debug_context)}")
655
+ end
257
656
  end
258
657
 
259
658
  # Helper class to work with combined deleted items from different models
@@ -292,6 +691,10 @@ module RecycleBin
292
691
  DeletedItemsCollection.new(@items.select(&block))
293
692
  end
294
693
 
694
+ def reject(&block)
695
+ DeletedItemsCollection.new(@items.reject(&block))
696
+ end
697
+
295
698
  def empty?
296
699
  @items.empty?
297
700
  end