campagne 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. data/.gitignore +4 -0
  2. data/.rvmrc +1 -0
  3. data/Gemfile +3 -0
  4. data/README.md +21 -0
  5. data/Rakefile +1 -0
  6. data/app/assets/.DS_Store +0 -0
  7. data/app/assets/javascripts/campagne.js +15 -0
  8. data/app/assets/stylesheets/campagne.css +118 -0
  9. data/app/controllers/bulletin/.DS_Store +0 -0
  10. data/app/controllers/bulletin/campagne_campaigns_controller.rb +62 -0
  11. data/app/controllers/bulletin/campagne_deliveries_controller.rb +39 -0
  12. data/app/controllers/bulletin/campagne_lists_controller.rb +45 -0
  13. data/app/helpers/campagne/error_messages_helper.rb +26 -0
  14. data/app/helpers/campagne/layout_helper.rb +26 -0
  15. data/app/models/campagne/campagne_campaign.rb +25 -0
  16. data/app/models/campagne/campagne_contact.rb +10 -0
  17. data/app/models/campagne/campagne_delivery.rb +37 -0
  18. data/app/models/campagne/campagne_list.rb +11 -0
  19. data/app/models/campagne/sender.rb +19 -0
  20. data/app/models/campagne/sender_job.rb +29 -0
  21. data/app/validators/email_validator.rb +56 -0
  22. data/app/views/campagne/.DS_Store +0 -0
  23. data/app/views/campagne/campagne_campaigns/_form.html.erb +23 -0
  24. data/app/views/campagne/campagne_campaigns/deliveries.html.erb +30 -0
  25. data/app/views/campagne/campagne_campaigns/edit.html.erb +8 -0
  26. data/app/views/campagne/campagne_campaigns/index.html.erb +20 -0
  27. data/app/views/campagne/campagne_campaigns/new.html.erb +5 -0
  28. data/app/views/campagne/campagne_campaigns/preview.html.erb +1 -0
  29. data/app/views/campagne/campagne_campaigns/show.html.erb +35 -0
  30. data/app/views/campagne/campagne_lists/_form.html.erb +8 -0
  31. data/app/views/campagne/campagne_lists/import.html.erb +12 -0
  32. data/app/views/campagne/campagne_lists/index.html.erb +16 -0
  33. data/app/views/campagne/campagne_lists/new.html.erb +5 -0
  34. data/app/views/campagne/campagne_lists/show.html.erb +17 -0
  35. data/app/views/layouts/campagne/campagne.html.erb +30 -0
  36. data/campagne.gemspec +23 -0
  37. data/config/locales/en.yml +5 -0
  38. data/config/routes.rb +25 -0
  39. data/lib/bulletin.rb +18 -0
  40. data/lib/campagne/engine.rb +23 -0
  41. data/lib/campagne/version.rb +3 -0
  42. data/lib/generators/campagne/campagne_generator.rb +30 -0
  43. data/lib/generators/campagne/templates/1x1.gif +0 -0
  44. data/lib/generators/campagne/templates/initializer.rb +11 -0
  45. data/lib/generators/campagne/templates/migration.rb +48 -0
  46. data/lib/tasks/resque.rake +100 -0
  47. data/test/test_helper.rb +0 -0
  48. metadata +153 -0
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm ruby-1.9.3-p125
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,21 @@
1
+ Campagne
2
+ ========
3
+
4
+ A simple Rails 3 engine gem to manage and send newsletters.
5
+
6
+
7
+ Use this on campaign body:
8
+
9
+ ||WB|| - WebBeacon
10
+ ||UNSUB|| - Unsubscribe
11
+ ||LINK|| - Link
12
+
13
+ # Add to Gemfile
14
+ gem 'campagne'
15
+ $ bundle
16
+
17
+ $ bundle exec rails g campagne
18
+ # Change config/initializers/campagne.rb
19
+
20
+ $ bundle exec rake db:migrate
21
+ http://localhost:3000/campagne
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
Binary file
@@ -0,0 +1,15 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // the compiled file.
9
+ //
10
+ // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD
11
+ // GO AFTER THE REQUIRES BELOW.
12
+ //
13
+ //= require jquery
14
+ //= require jquery_ujs
15
+ //= require_tree .
@@ -0,0 +1,118 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the top of the
9
+ * compiled file, but it's generally better to create a new file per style scope.
10
+ *
11
+ *= require_self
12
+ *= require_tree .
13
+ */
14
+
15
+ body {
16
+ background-color: #CCC;
17
+ font-family: "Helvetica Nue", Helvetica, Arial;
18
+ font-size: 14px;
19
+ }
20
+
21
+ a img {
22
+ border: none;
23
+ }
24
+
25
+ a {
26
+ color: #0000FF;
27
+ }
28
+
29
+ .clear {
30
+ clear: both;
31
+ height: 0;
32
+ overflow: hidden;
33
+ }
34
+
35
+ #nav {
36
+ width: 75%;
37
+ margin: 0 auto;
38
+ padding: 0 40px;
39
+ margin-top: 20px;
40
+ }
41
+
42
+ #container {
43
+ width: 75%;
44
+ margin: 0 auto;
45
+ background-color: #FFF;
46
+ padding: 20px 40px;
47
+ border: solid 1px black;
48
+ margin-top: 20px;
49
+ }
50
+
51
+ #flash_notice, #flash_error {
52
+ padding: 5px 8px;
53
+ margin: 10px 0;
54
+ }
55
+
56
+ #flash_notice {
57
+ background-color: #CFC;
58
+ border: solid 1px #6C6;
59
+ }
60
+
61
+ #flash_error {
62
+ background-color: #FCC;
63
+ border: solid 1px #C66;
64
+ }
65
+
66
+ .fieldWithErrors {
67
+ display: inline;
68
+ }
69
+
70
+ #errorExplanation {
71
+ width: 400px;
72
+ border: 2px solid #CF0000;
73
+ padding: 0px;
74
+ padding-bottom: 12px;
75
+ margin-bottom: 20px;
76
+ background-color: #f0f0f0;
77
+ }
78
+
79
+ #errorExplanation h2 {
80
+ text-align: left;
81
+ font-weight: bold;
82
+ padding: 5px 5px 5px 15px;
83
+ font-size: 12px;
84
+ margin: 0;
85
+ background-color: #c00;
86
+ color: #fff;
87
+ }
88
+
89
+ #errorExplanation p {
90
+ color: #333;
91
+ margin-bottom: 0;
92
+ padding: 8px;
93
+ }
94
+
95
+ #errorExplanation ul {
96
+ margin: 2px 24px;
97
+ }
98
+
99
+ #errorExplanation ul li {
100
+ font-size: 12px;
101
+ list-style: disc;
102
+ }
103
+
104
+ table {
105
+ width: 50%;
106
+ border-top: 1px solid #CCC;
107
+ border-bottom: 1px solid #CCC;
108
+ }
109
+
110
+ table td {
111
+ border-top: 1px solid #CCC;
112
+ padding: 4px;
113
+ }
114
+
115
+ table th {
116
+ text-align: left;
117
+ padding: 4px;
118
+ }
Binary file
@@ -0,0 +1,62 @@
1
+ module Campagne
2
+ class CampagneCampaignsController < ApplicationController
3
+ respond_to :html, :xml
4
+ layout 'campagne/campagne'
5
+
6
+ def index
7
+ @campaigns = CampagneCampaign.all
8
+ end
9
+
10
+ def show
11
+ @campaign = CampagneCampaign.find(params[:id])
12
+ respond_with(@campaign)
13
+ end
14
+
15
+ def new
16
+ @campaign = CampagneCampaign.new
17
+ respond_with(@campaign)
18
+ end
19
+
20
+ def create
21
+ @campaign = CampagneCampaign.new(params[:campagne_campagne_campaign])
22
+ flash[:notice] = 'Campaign was successfully created.' if @campaign.save
23
+ respond_with(@campaign, :location => campagne_campagne_campaigns_path)
24
+ end
25
+
26
+ def edit
27
+ @campaign = CampagneCampaign.find(params[:id])
28
+ respond_with(@campaign)
29
+ end
30
+
31
+ def update
32
+ @campaign = CampagneCampaign.find(params[:id])
33
+ flash[:notice] = 'Campaign was successfully updated.' if @campaign.update_attributes(params[:campagne_campagne_campaign])
34
+ respond_with(@campaign)
35
+ end
36
+
37
+ def preview
38
+ @campaign = CampagneCampaign.find(params[:id])
39
+ render :layout => nil
40
+ end
41
+
42
+ def deliveries
43
+ @campaign = CampagneCampaign.find(params[:id])
44
+ @deliveries = @campaign.campagne_deliveries
45
+ end
46
+
47
+ def schedule
48
+ @campaign = CampagneCampaign.find(params[:id])
49
+ datetime = Time.zone.local(
50
+ params[:schedule][:"at(1i)"].to_i,
51
+ params[:schedule][:"at(2i)"].to_i,
52
+ params[:schedule][:"at(3i)"].to_i,
53
+ params[:schedule][:"at(4i)"].to_i,
54
+ params[:schedule][:"at(5i)"].to_i
55
+ )
56
+ Resque.enqueue_at(datetime, Campagne::SenderJob, @campaign.id)
57
+ redirect_to campagne_campagne_campaign_path(@campaign), :notice => 'Campaign was successfully scheduled.'
58
+ end
59
+
60
+
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ module Campagne
2
+ class CampagneDeliveriesController < ApplicationController
3
+
4
+ def see
5
+ if delivery = CampagneDelivery.find_by_token(params[:token])
6
+ delivery.see!(request)
7
+ end
8
+ image = File.read(File.join(Rails.root, "public/1x1.gif"))
9
+ send_data image, :type => "image/gif", :disposition => "inline"
10
+ end
11
+
12
+ def click
13
+ if delivery = CampagneDelivery.find_by_token(params[:token])
14
+ delivery.click!(request)
15
+ end
16
+ # TODO:
17
+ redirect_to "http://#{params[:link]}"
18
+ end
19
+
20
+ def unsubscribe
21
+ if delivery = CampagneDelivery.find_by_token(params[:token])
22
+ delivery.unsubscribe!(request)
23
+ render :text => 'Ok'
24
+ else
25
+ render :text => 'Error'
26
+ end
27
+ end
28
+
29
+ def bounce
30
+ if delivery = CampagneDelivery.find_by_token(params[:token])
31
+ delivery.bounce!
32
+ render :text => 'Ok'
33
+ else
34
+ render :text => 'Error'
35
+ end
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ module Campagne
2
+ class CampagneListsController < ApplicationController
3
+ respond_to :html, :xml
4
+ layout 'campagne/campagne'
5
+
6
+ def index
7
+ @lists = CampagneList.all
8
+ end
9
+
10
+ def show
11
+ @list = CampagneList.find(params[:id])
12
+ respond_with(@list)
13
+ end
14
+
15
+ def new
16
+ @list = CampagneList.new
17
+ respond_with(@list)
18
+ end
19
+
20
+ def create
21
+ @list = CampagneList.new(params[:campagne_campagne_list])
22
+ flash[:notice] = 'List was successfully created.' if @list.save
23
+ respond_with(@list, :location => campagne_campagne_lists_path)
24
+ end
25
+
26
+ def import
27
+ @list = CampagneList.find(params[:id])
28
+ if request.post?
29
+ emails = params[:emails].split("\r\n")
30
+ emails.each do |email|
31
+ if contact = CampagneContact.find_by_email(email)
32
+ if !@list.contacts.exists?(contact.id)
33
+ @list.contacts << contact
34
+ @list.save
35
+ end
36
+ else
37
+ @list.contacts.create(:email => email)
38
+ end
39
+ end
40
+ redirect_to campagne_campagne_lists_path, :notice => "Contacts were successfully imported."
41
+ end
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,26 @@
1
+ module Campagne
2
+ module ErrorMessagesHelper
3
+ # Render error messages for the given objects. The :message and :header_message options are allowed.
4
+ def error_messages_for(*objects)
5
+ options = objects.extract_options!
6
+ options[:header_message] ||= I18n.t(:"activerecord.errors.header", :default => "Invalid Fields")
7
+ options[:message] ||= I18n.t(:"activerecord.errors.message", :default => "Correct the following errors and try again.")
8
+ messages = objects.compact.map { |o| o.errors.full_messages }.flatten
9
+ unless messages.empty?
10
+ content_tag(:div, :class => "error_messages") do
11
+ list_items = messages.map { |msg| content_tag(:li, msg) }
12
+ content_tag(:h2, options[:header_message]) + content_tag(:p, options[:message]) + content_tag(:ul, list_items.join.html_safe)
13
+ end
14
+ end
15
+ end
16
+
17
+ module FormBuilderAdditions
18
+ def error_messages(options = {})
19
+ @template.error_messages_for(@object, options)
20
+ end
21
+ end
22
+
23
+ end
24
+ end
25
+
26
+ ActionView::Helpers::FormBuilder.send(:include, Campagne::ErrorMessagesHelper::FormBuilderAdditions)
@@ -0,0 +1,26 @@
1
+ # These helper methods can be called in your template to set variables to be used in the layout
2
+ # This module should be included in all views globally,
3
+ # to do so you may need to add this line to your ApplicationController
4
+ # helper :layout
5
+ module Campagne
6
+ module LayoutHelper
7
+
8
+ def title(page_title, show_title = true)
9
+ content_for(:title) { h(page_title.to_s) }
10
+ @show_title = show_title
11
+ end
12
+
13
+ def show_title?
14
+ @show_title
15
+ end
16
+
17
+ def stylesheet(*args)
18
+ content_for(:head) { stylesheet_link_tag(*args) }
19
+ end
20
+
21
+ def javascript(*args)
22
+ content_for(:head) { javascript_include_tag(*args) }
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ module Campagne
2
+ class CampagneCampaign < ActiveRecord::Base
3
+ has_and_belongs_to_many :campagne_lists
4
+ has_many :campagne_deliveries
5
+
6
+ attr_accessible :name, :subject, :body, :campagne_list_ids
7
+
8
+ validates :name, :presence => true, :uniqueness => true
9
+ validates :subject, :presence => true
10
+ validates :body, :presence => true
11
+
12
+ def formated_body(token)
13
+ formated_body = body.gsub('||WB||', "<img src=\"#{Rails.application.config.campagne_base_url}/campagne/see/#{token}\" width=\"1\" height=\"1\" />")
14
+ formated_body = formated_body.gsub('||UNSUB||', "#{Rails.application.config.campagne_base_url}/campagne/unsubscribe/#{token}")
15
+ formated_body = formated_body.gsub('||LINK||', "#{Rails.application.config.campagne_base_url}/campagne/click/#{token}?link=")
16
+ # formated_body = formated_body.gsub('||VIEW||', "#{Rails.application.config.campagne_base_url}/campagne/preview/#{token}")
17
+ formated_body
18
+ end
19
+
20
+ def preview
21
+ formated_body('0')
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,10 @@
1
+ module Campagne
2
+ class CampagneContact < ActiveRecord::Base
3
+ has_and_belongs_to_many :campagne_lists
4
+
5
+ attr_accessible :email, :name
6
+
7
+ validates :email, :presence => true, :uniqueness => true, :email => true, :incorrect_email => true, :denied_email => true
8
+
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ module Campagne
2
+ class CampagneDelivery < ActiveRecord::Base
3
+ belongs_to :campagne_campaign
4
+
5
+ attr_accessible :contact_id, :campagne_list_id , :email, :token
6
+
7
+ def see!(request)
8
+ self.ip_address = request.remote_ip if ip_address.blank?
9
+ self.user_agent = request.user_agent if user_agent.blank?
10
+ self.seen_at = Time.now if seen_at.nil?
11
+ save
12
+ end
13
+
14
+ def click!(request)
15
+ self.ip_address = request.remote_ip if ip_address.blank?
16
+ self.user_agent = request.user_agent if user_agent.blank?
17
+ self.seen_at = Time.now if seen_at.nil?
18
+ self.clicked_at = Time.now if clicked_at.nil?
19
+ save
20
+ end
21
+
22
+ def unsubscribe!(request)
23
+ CampagneList.find(list_id).contacts.delete(CampagneContact.find(contact_id)) # Remove contact from list
24
+ self.ip_address = request.remote_ip if ip_address.blank?
25
+ self.user_agent = request.user_agent if user_agent.blank?
26
+ self.seen_at = Time.now if seen_at.nil?
27
+ self.unsubscribed_at = Time.now if unsubscribed_at.nil?
28
+ save
29
+ end
30
+
31
+ def bounce!
32
+ CampagneList.find(list_id).contacts.delete(CampagneContact.find(contact_id))
33
+ update_attribute(:bounced_at, Time.now) if bounced_at.nil?
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,11 @@
1
+ module Campagne
2
+ class CampagneList < ActiveRecord::Base
3
+ has_and_belongs_to_many :campagne_contacts
4
+ has_and_belongs_to_many :campagne_campaigns
5
+
6
+ attr_accessible :name, :contact_ids
7
+
8
+ validates :name, :presence => true, :uniqueness => true
9
+
10
+ end
11
+ end
@@ -0,0 +1,19 @@
1
+ # encoding: UTF-8
2
+ module Campagne
3
+ class Sender
4
+
5
+ def self.deliver_email(email, subject, email_body, token)
6
+ sleep 0.5
7
+ ActionMailer::Base::mail({
8
+ :from => "#{Rails.application.config.campagne_from_name} <#{Rails.application.config.campagne_from_email}>",
9
+ :to => email.downcase,
10
+ :subject => subject,
11
+ :body => email_body,
12
+ :content_type => 'text/html; charset=UTF-8'#,
13
+ :'Return-Path' => "bounce+#{token}@#{Rails.application.config.campagne_domain}",
14
+ :'List-Unsubscribe' => "<mailto:unsubscribe-#{token}@#{Rails.application.config.campagne_domain}>, <#{Rails.application.config.campagne_base_url}/campagne/unsubscribe/#{token}>"
15
+ }).deliver
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # encoding: UTF-8
2
+ module Campagne
3
+ class SenderJob
4
+ @queue = :default
5
+
6
+ def self.generate_token
7
+ token = rand(36**18).to_s(36)
8
+ token = token.gsub('+', '')
9
+ while CampagneDelivery.where(:token => token).first do
10
+ token = rand(36**18).to_s(36)
11
+ end
12
+ token
13
+ end
14
+
15
+ def self.perform(campaign_id)
16
+ ActiveSupport::BufferedLogger.new(Rails.root.join('log/resque.log')).info([Time.now.iso8601, $$, "I", "---PERFORM---", campaign_id].join("\t"))
17
+ campaign = Campaign.find(campaign_id)
18
+ contacts = campaign.lists.map(&:contacts).flatten
19
+ contacts = contacts.sort_by {rand} # shuffle
20
+ contacts.each do |contact|
21
+ next if campaign.deliveries.where(:contact_id => contact.id).first
22
+ token = generate_token
23
+ Sender.deliver_email(contact.email, campaign.subject, campaign.formated_body(token), token)
24
+ campaign.deliveries.create(:contact_id => contact.id, :email => contact.email, :token => token)
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,56 @@
1
+ # encoding: UTF-8
2
+ require 'mail'
3
+
4
+ class EmailValidator < ActiveModel::EachValidator
5
+ def validate_each(record, attribute, value)
6
+ return if value.blank?
7
+ begin
8
+ # http://my.rails-royce.org/2010/07/21/email-validation-in-ruby-on-rails-without-regexp/
9
+ m = Mail::Address.new(value)
10
+ # We must check that value contains a domain and that value is an email address
11
+ r = m.domain && m.address == value
12
+ t = m.__send__(:tree)
13
+ # We need to dig into treetop
14
+ # A valid domain must have dot_atom_text elements size > 1
15
+ # user@localhost is excluded
16
+ # treetop must respond to domain
17
+ # We exclude valid email values like <user@localhost.com>
18
+ # Hence we use m.__send__(tree).domain
19
+ r &&= (t.domain.dot_atom_text.elements.size > 1)
20
+ rescue Exception => e
21
+ r = false
22
+ end
23
+ record.errors[attribute] << (options[:message] || 'inválido') unless r
24
+ end
25
+ end
26
+
27
+ class IncorrectEmailValidator < ActiveModel::EachValidator
28
+ def validate_each(record, attribute, value)
29
+ domains = %w(
30
+ hotmail.com.br gmail.com.br
31
+ hotamil.com hotimail.com hotmail.com.br hotmail.com.com hotmail.con hotmal.com hoymail.com hotmil.com
32
+ gamil.com gmail.com.br gmal.com gmeil.com.br gmial.com
33
+ teste.com teste.com.br
34
+ yaoo.com
35
+ .com.be
36
+ )
37
+ domains.each do |d|
38
+ if value && value.include?("@#{d}")
39
+ record.errors[attribute] << 'está incorreto'
40
+ break
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ class DeniedEmailValidator < ActiveModel::EachValidator
47
+ def validate_each(record, attribute, value)
48
+ domains = %w(mailinator.com dodgit.com uggsrock.com spambox.us spamhole.com spam.la trashymail.com guerrillamailblock.com spamspot.com spamfree tempomail.fr jetable.net maileater.com meltmail.com)
49
+ domains.each do |d|
50
+ if value && value.include?("@#{d}")
51
+ record.errors[attribute] << 'inválido'
52
+ break
53
+ end
54
+ end
55
+ end
56
+ end
Binary file
@@ -0,0 +1,23 @@
1
+ <%= form_for @campaign do |f| %>
2
+ <%= f.error_messages %>
3
+ <p>
4
+ <%= f.label :name %><br />
5
+ <%= f.text_field :name %>
6
+ </p>
7
+ <p>
8
+ <%= f.label :subject %><br />
9
+ <%= f.text_field :subject %>
10
+ </p>
11
+ <p>
12
+ <%= f.label :body %><br />
13
+ <%= f.text_area :body %>
14
+ </p>
15
+ <p>
16
+ <%= hidden_field_tag "campagne_campagne_campaign[campagne_list_ids][]", nil%>
17
+ <% Campagne::CampagneList.all.each do |list| %>
18
+ <%= check_box_tag "campagne_campagne_campaign[campagne_list_ids][]", list.id, @campaign.campagne_list_ids.include?(list.id), id: dom_id(list) %>
19
+ <%= label_tag dom_id(list), list.name %><br/>
20
+ <% end %>
21
+ </p>
22
+ <p><%= f.submit 'Create Campaign' %></p>
23
+ <% end %>
@@ -0,0 +1,30 @@
1
+ <% title "Deliveries" %>
2
+
3
+ <h2><%= @campaign.name %></h2>
4
+
5
+ <table style="width:100%">
6
+ <tr>
7
+ <th>Email</th>
8
+ <th>Token</th>
9
+ <th>Sent at</th>
10
+ <th>Seen at</th>
11
+ <th>Clicked at</th>
12
+ <th>Unsubscribed at</th>
13
+ <th>Bounced at</th>
14
+ <th>IP Address</th>
15
+ <th>User Agent</th>
16
+ </tr>
17
+ <% for delivery in @deliveries %>
18
+ <tr>
19
+ <td><%= delivery.email %></td>
20
+ <td><%= delivery.token %></td>
21
+ <td><%= delivery.created_at %></td>
22
+ <td><%= delivery.seen_at %></td>
23
+ <td><%= delivery.clicked_at %></td>
24
+ <td><%= delivery.unsubscribed_at %></td>
25
+ <td><%= delivery.bounced_at %></td>
26
+ <td><%= delivery.ip_address %></td>
27
+ <td><%= delivery.user_agent %></td>
28
+ </tr>
29
+ <% end %>
30
+ </table>
@@ -0,0 +1,8 @@
1
+ <% title "Edit Campaign" %>
2
+
3
+ <%= render 'form' %>
4
+
5
+ <p>
6
+ <%= link_to "Show", @campaign %> |
7
+ <%= link_to "View All", campagne_campagne_campaigns_path %>
8
+ </p>
@@ -0,0 +1,20 @@
1
+ <% title "Campaigns" %>
2
+
3
+ <p><%= link_to "New Campaign", new_campagne_campagne_campaign_path %></p>
4
+
5
+ <table>
6
+ <tr>
7
+ <th>Name</th>
8
+ <th>Subject</th>
9
+ </tr>
10
+ <% for campaign in @campaigns %>
11
+ <tr>
12
+ <td><%= campaign.name %></td>
13
+ <td><%= campaign.subject %></td>
14
+ <td><%= link_to "Preview", preview_campagne_campagne_campaign_path(campaign) %></td>
15
+ <td><%= link_to "Deliveries", deliveries_campagne_campagne_campaign_path(campaign) %></td>
16
+ <td><%= link_to "Show", campaign %></td>
17
+ <td><%= link_to "Edit", edit_campagne_campagne_campaign_path(campaign) %></td>
18
+ </tr>
19
+ <% end %>
20
+ </table>
@@ -0,0 +1,5 @@
1
+ <% title "New Campaign" %>
2
+
3
+ <%= render 'form' %>
4
+
5
+ <p><%= link_to "Back", campagne_campagne_campaigns_path %></p>
@@ -0,0 +1 @@
1
+ <%= raw(@campaign.preview) %>
@@ -0,0 +1,35 @@
1
+ <% title "Campaign" %>
2
+
3
+ <%= form_tag schedule_campagne_campagne_campaign_path(@campaign) do %>
4
+ <%= datetime_select("schedule", 'at', :default => Time.now) %>
5
+ <%= submit_tag 'Schedule' %>
6
+ <% end %>
7
+
8
+ <p>
9
+ <%= link_to "Preview", preview_campagne_campagne_campaign_path(@campaign) %> |
10
+ <%= link_to "Deliveries", deliveries_campagne_campagne_campaign_path(@campaign) %> |
11
+ <%= link_to "Edit", edit_campagne_campagne_campaign_path(@campaign) %> |
12
+ <%= link_to "View All", campagne_campagne_campaigns_path %>
13
+ </p>
14
+
15
+ <p>
16
+ <strong>Name:</strong>
17
+ <%= @campaign.name %>
18
+ </p>
19
+ <p>
20
+ <strong>Subject:</strong>
21
+ <%= @campaign.subject %>
22
+ </p>
23
+ <p>
24
+ <strong>Lists:</strong><br/>
25
+ <% @campaign.campagne_lists.each do |list| %>
26
+ <%= list.name %><br/>
27
+ <% end %>
28
+ </p>
29
+ <p>
30
+ <strong>Body:</strong><br />
31
+ <pre>
32
+ <%= raw(@campaign.body) %>
33
+ </pre>
34
+ </p>
35
+
@@ -0,0 +1,8 @@
1
+ <%= form_for @list do |f| %>
2
+ <%= f.error_messages %>
3
+ <p>
4
+ <%= f.label :name %><br />
5
+ <%= f.text_field :name %>
6
+ </p>
7
+ <p><%= f.submit 'Create List' %></p>
8
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <% title "Import contacts" %>
2
+
3
+ <h2><%= @list.name %></h2>
4
+
5
+ <%= form_tag import_campagne_campagne_list_path(@list) do %>
6
+ <p>
7
+ <%= text_area_tag 'emails', nil, :style => 'width:600px;height:400px' %>
8
+ </p>
9
+ <p><%= submit_tag 'Import' %></p>
10
+ <% end %>
11
+
12
+ <p><%= link_to "Back", campagne_campagne_lists_path %></p>
@@ -0,0 +1,16 @@
1
+ <% title "Lists" %>
2
+
3
+ <p><%= link_to "New List", new_campagne_campagne_list_path %></p>
4
+
5
+ <table>
6
+ <tr>
7
+ <th>Name</th>
8
+ </tr>
9
+ <% for list in @lists %>
10
+ <tr>
11
+ <td><%= list.name %></td>
12
+ <td><%= link_to "Import", import_campagne_campagne_list_path(list) %></td>
13
+ <td><%= link_to "Show", list %></td>
14
+ </tr>
15
+ <% end %>
16
+ </table>
@@ -0,0 +1,5 @@
1
+ <% title "New List" %>
2
+
3
+ <%= render 'form' %>
4
+
5
+ <p><%= link_to "Back", campagne_campagne_lists_path %></p>
@@ -0,0 +1,17 @@
1
+ <% title "List" %>
2
+
3
+ <p>
4
+ <strong>Name:</strong>
5
+ <%= @list.name %>
6
+ </p>
7
+
8
+ <p>
9
+ <strong>Emails:</strong><br />
10
+ <% @list.campagne_contacts.each do |contact| %>
11
+ <%= contact.email %><br />
12
+ <% end %>
13
+ </p>
14
+
15
+ <p>
16
+ <%= link_to "View All", campagne_campagne_lists_path %>
17
+ </p>
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for?(:title) ? yield(:title) : "Untitled" %></title>
5
+ <%= stylesheet_link_tag :campagne %>
6
+ <%= javascript_include_tag :campagne %>
7
+ <%= csrf_meta_tag %>
8
+ <%= yield(:head) %>
9
+ </head>
10
+ <body>
11
+
12
+ <div id="nav">
13
+ <%= link_to 'Lists', campagne_campagne_lists_path %> |
14
+ <%= link_to 'Campaings', campagne_campagne_campaigns_path %>
15
+ </div>
16
+
17
+ <div id="container">
18
+ <% if notice %>
19
+ <%= content_tag :div, notice, :id => "flash_notice" %>
20
+ <% end %>
21
+
22
+ <% if alert %>
23
+ <%= content_tag :div, alert, :id => "flash_alert" %>
24
+ <% end %>
25
+
26
+ <%= content_tag :h1, yield(:title) if show_title? %>
27
+ <%= yield %>
28
+ </div>
29
+ </body>
30
+ </html>
data/campagne.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path("../lib/campagne/version", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "campagne"
6
+ s.version = Campagne::VERSION
7
+ s.authors = ["Arley Lobato"]
8
+ s.email = ["arleylobato@gmail.com"]
9
+ s.homepage = "http://github.com/alobato/campagne"
10
+ s.summary = "campagne-#{s.version}"
11
+ s.description = "A simple Rails 3 engine gem to manage and send newsletters."
12
+
13
+ s.add_dependency "rails", "~> 3.2.3"
14
+ s.add_dependency "mysql2", "~> 0.3.11"
15
+ s.add_dependency "jquery-rails", "~> 2.0.2"
16
+ s.add_dependency "resque", "~> 1.20.0"
17
+ s.add_dependency "resque-scheduler", "~> 1.9.9"
18
+
19
+ s.files = `git ls-files`.split("\n")
20
+ s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact
21
+ s.require_path = 'lib'
22
+
23
+ end
@@ -0,0 +1,5 @@
1
+ # Sample localization file for English. Add more files in this directory for other locales.
2
+ # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3
+
4
+ en:
5
+ hello: "Hello world"
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ Rails.application.routes.draw do
2
+
3
+ namespace :campagne do
4
+ root to: 'campagne_lists#index'
5
+ mount Resque::Server.new, at: 'resque'
6
+ resources :campagne_lists do
7
+ member do
8
+ get 'import'
9
+ post 'import'
10
+ end
11
+ end
12
+ resources :campagne_campaigns do
13
+ member do
14
+ get 'preview'
15
+ get 'deliveries'
16
+ post 'schedule'
17
+ end
18
+ end
19
+ get 'see/:token' => 'campagne_deliveries#see'
20
+ get 'click/:token' => 'campagne_deliveries#click'
21
+ get 'unsubscribe/:token' => 'campagne_deliveries#unsubscribe'
22
+ get 'bounce/:token' => 'campagne_deliveries#bounce'
23
+ end
24
+
25
+ end
data/lib/bulletin.rb ADDED
@@ -0,0 +1,18 @@
1
+ # Requires
2
+ require "active_support/dependencies"
3
+
4
+ module Campagne
5
+
6
+ # Our host application root path
7
+ # We set this when the engine is initialized
8
+ mattr_accessor :app_root
9
+
10
+ # Yield self on setup for nice config blocks
11
+ def self.setup
12
+ yield self
13
+ end
14
+
15
+ end
16
+
17
+ # Require our engine
18
+ require "campagne/engine"
@@ -0,0 +1,23 @@
1
+ # http://edgeapi.rubyonrails.org/classes/Rails/Engine.html
2
+ module Campagne
3
+ class Engine < Rails::Engine
4
+
5
+ initializer "campagne.load_app_instance_data" do |app|
6
+ Campagne.setup do |config|
7
+ config.app_root = app.root
8
+ end
9
+ end
10
+
11
+ initializer "campagne.load_static_assets" do |app|
12
+ app.middleware.use ::ActionDispatch::Static, "#{root}/public"
13
+ end
14
+
15
+ initializer "campagne.load_resque_config" do |app|
16
+ require 'resque/server'
17
+ Resque::Server.use(Rack::Auth::Basic)
18
+ require 'resque_scheduler'
19
+ require 'resque_scheduler/server'
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Campagne
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,30 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ class CampagneGenerator < Rails::Generators::Base
5
+ include Rails::Generators::Migration
6
+ def self.source_root
7
+ @source_root ||= File.join(File.dirname(__FILE__), 'templates')
8
+ end
9
+
10
+ def self.next_migration_number(dirname)
11
+ if ActiveRecord::Base.timestamped_migrations
12
+ Time.new.utc.strftime("%Y%m%d%H%M%S")
13
+ else
14
+ "%.3d" % (current_migration_number(dirname) + 1)
15
+ end
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template 'migration.rb', 'db/migrate/create_campagne_tables.rb'
20
+ end
21
+
22
+ def copy_initializer_file
23
+ copy_file 'initializer.rb', 'config/initializers/campagne.rb'
24
+ end
25
+
26
+ def copy_1_pixel_image
27
+ copy_file "1x1.gif", "public/1x1.gif"
28
+ end
29
+
30
+ end
@@ -0,0 +1,11 @@
1
+ require 'resque/server'
2
+ Resque::Server.use(Rack::Auth::Basic) do |username, password|
3
+ username == 'admin' && password == "secret"
4
+ end
5
+
6
+ Rails.application.class.configure do
7
+ config.campagne_from_name = 'Foo'
8
+ config.campagne_from_email = 'foo@bar.com'
9
+ config.campagne_domain = 'foobar.com'
10
+ config.campagne_base_url = 'http://www.foobar.com'
11
+ end
@@ -0,0 +1,48 @@
1
+ class CreateCampagneTables < ActiveRecord::Migration
2
+
3
+ def change
4
+ create_table :campagne_contacts do |t|
5
+ t.string :email
6
+ t.string :name
7
+ t.timestamps
8
+ end
9
+ create_table :campagne_lists do |t|
10
+ t.string :name
11
+ t.timestamps
12
+ end
13
+ create_table :campagne_contacts_campagne_lists, :id => false do |t|
14
+ t.integer :campagne_contact_id
15
+ t.integer :campagne_list_id
16
+ t.timestamps
17
+ end
18
+ create_table :campagne_campaigns do |t|
19
+ t.string :name
20
+ t.string :subject
21
+ t.text :body, :limit => 2147483647
22
+ t.timestamps
23
+ end
24
+ create_table :campagne_campaigns_campagne_lists, :id => false do |t|
25
+ t.integer :campagne_campaign_id
26
+ t.integer :campagne_list_id
27
+ t.timestamps
28
+ end
29
+ create_table :campagne_deliveries do |t|
30
+ t.integer :campagne_campaign_id
31
+ t.integer :campagne_contact_id
32
+ t.integer :campagne_list_id
33
+ t.string :email
34
+ t.string :token
35
+ t.datetime :seen_at
36
+ t.datetime :clicked_at
37
+ t.datetime :unsubscribed_at
38
+ t.datetime :bounced_at
39
+ t.string :ip_address
40
+ t.string :user_agent
41
+ t.timestamps
42
+ end
43
+ add_index "campagne_deliveries", ["token"], :name => "index_deliveries_on_token", :unique => true
44
+ add_index "campagne_campaigns_campagne_lists", ["campagne_campaign_id", "campagne_list_id"], :name => "index_campaigns_lists_on_c_and_l", :unique => true
45
+ add_index "campagne_contacts_campagne_lists", ["campagne_contact_id", "campagne_list_id"], :name => "index_contacts_lists_on_c_and_l", :unique => true
46
+ end
47
+
48
+ end
@@ -0,0 +1,100 @@
1
+ require 'resque/tasks'
2
+ require 'resque_scheduler/tasks'
3
+
4
+ def run_worker(queue, count = 1)
5
+ puts "Starting #{count} worker(s) with QUEUE: #{queue}"
6
+ ops = {:pgroup => true, :err => [(Rails.root + "log/workers_error.log").to_s, "a"],
7
+ :out => [(Rails.root + "log/workers.log").to_s, "a"]}
8
+ env_vars = {
9
+ "QUEUE" => queue.to_s,
10
+ "BACKGROUND" => "1",
11
+ "PIDFILE" => (Rails.root + "tmp/pids/resque.pid").to_s,
12
+ "VERBOSE" => "1"
13
+ }
14
+ count.times {
15
+ pid = spawn(env_vars, "rake resque:work", ops)
16
+ Process.detach(pid)
17
+ }
18
+ end
19
+
20
+ def run_scheduler
21
+ puts "Starting resque scheduler"
22
+ env_vars = {
23
+ "BACKGROUND" => "1",
24
+ "PIDFILE" => (Rails.root + "tmp/pids/resque_scheduler.pid").to_s,
25
+ "VERBOSE" => "1"
26
+ }
27
+ ops = {:pgroup => true, :err => [(Rails.root + "log/scheduler_error.log").to_s, "a"],
28
+ :out => [(Rails.root + "log/scheduler.log").to_s, "a"]}
29
+ pid = spawn(env_vars, "rake resque:scheduler", ops)
30
+ Process.detach(pid)
31
+ end
32
+
33
+ namespace :resque do
34
+ task :setup => :environment
35
+
36
+ desc "Restart running workers"
37
+ task :restart_workers => :environment do
38
+ Rake::Task['resque:stop_workers'].invoke
39
+ Rake::Task['resque:start_workers'].invoke
40
+ end
41
+
42
+ desc "Quit running workers"
43
+ task :stop_workers => :environment do
44
+ pids = Array.new
45
+ Resque.workers.each do |worker|
46
+ pids.concat(worker.worker_pids)
47
+ end
48
+ if pids.empty?
49
+ puts "No workers to kill"
50
+ else
51
+ syscmd = "kill -s QUIT #{pids.join(' ')}"
52
+ puts "Running syscmd: #{syscmd}"
53
+ system(syscmd)
54
+ end
55
+ end
56
+
57
+ desc "Start workers"
58
+ task :start_workers => :environment do
59
+ run_worker("default", 1)
60
+ end
61
+
62
+ desc "Restart scheduler"
63
+ task :restart_scheduler => :environment do
64
+ Rake::Task['resque:stop_scheduler'].invoke
65
+ Rake::Task['resque:start_scheduler'].invoke
66
+ end
67
+
68
+ desc "Quit scheduler"
69
+ task :stop_scheduler => :environment do
70
+ pidfile = Rails.root + "tmp/pids/resque_scheduler.pid"
71
+ if !File.exists?(pidfile)
72
+ puts "Scheduler not running"
73
+ else
74
+ pid = File.read(pidfile).to_i
75
+ syscmd = "kill -s QUIT #{pid}"
76
+ puts "Running syscmd: #{syscmd}"
77
+ system(syscmd)
78
+ FileUtils.rm_f(pidfile)
79
+ end
80
+ end
81
+
82
+ desc "Start scheduler"
83
+ task :start_scheduler => :environment do
84
+ run_scheduler
85
+ end
86
+
87
+ desc "Reload schedule"
88
+ task :reload_schedule => :environment do
89
+ pidfile = Rails.root + "tmp/pids/resque_scheduler.pid"
90
+
91
+ if !File.exists?(pidfile)
92
+ puts "Scheduler not running"
93
+ else
94
+ pid = File.read(pidfile).to_i
95
+ syscmd = "kill -s USR2 #{pid}"
96
+ puts "Running syscmd: #{syscmd}"
97
+ system(syscmd)
98
+ end
99
+ end
100
+ end
File without changes
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: campagne
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Arley Lobato
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-04-06 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rails
16
+ requirement: &70126380669840 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 3.2.3
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *70126380669840
25
+ - !ruby/object:Gem::Dependency
26
+ name: mysql2
27
+ requirement: &70126380668860 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 0.3.11
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *70126380668860
36
+ - !ruby/object:Gem::Dependency
37
+ name: jquery-rails
38
+ requirement: &70126380668040 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 2.0.2
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: *70126380668040
47
+ - !ruby/object:Gem::Dependency
48
+ name: resque
49
+ requirement: &70126380667220 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: 1.20.0
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: *70126380667220
58
+ - !ruby/object:Gem::Dependency
59
+ name: resque-scheduler
60
+ requirement: &70126380666460 !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ~>
64
+ - !ruby/object:Gem::Version
65
+ version: 1.9.9
66
+ type: :runtime
67
+ prerelease: false
68
+ version_requirements: *70126380666460
69
+ description: A simple Rails 3 engine gem to manage and send newsletters.
70
+ email:
71
+ - arleylobato@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - .gitignore
77
+ - .rvmrc
78
+ - Gemfile
79
+ - README.md
80
+ - Rakefile
81
+ - app/assets/.DS_Store
82
+ - app/assets/javascripts/campagne.js
83
+ - app/assets/stylesheets/campagne.css
84
+ - app/controllers/bulletin/.DS_Store
85
+ - app/controllers/bulletin/campagne_campaigns_controller.rb
86
+ - app/controllers/bulletin/campagne_deliveries_controller.rb
87
+ - app/controllers/bulletin/campagne_lists_controller.rb
88
+ - app/helpers/campagne/error_messages_helper.rb
89
+ - app/helpers/campagne/layout_helper.rb
90
+ - app/models/campagne/campagne_campaign.rb
91
+ - app/models/campagne/campagne_contact.rb
92
+ - app/models/campagne/campagne_delivery.rb
93
+ - app/models/campagne/campagne_list.rb
94
+ - app/models/campagne/sender.rb
95
+ - app/models/campagne/sender_job.rb
96
+ - app/validators/email_validator.rb
97
+ - app/views/campagne/.DS_Store
98
+ - app/views/campagne/campagne_campaigns/_form.html.erb
99
+ - app/views/campagne/campagne_campaigns/deliveries.html.erb
100
+ - app/views/campagne/campagne_campaigns/edit.html.erb
101
+ - app/views/campagne/campagne_campaigns/index.html.erb
102
+ - app/views/campagne/campagne_campaigns/new.html.erb
103
+ - app/views/campagne/campagne_campaigns/preview.html.erb
104
+ - app/views/campagne/campagne_campaigns/show.html.erb
105
+ - app/views/campagne/campagne_lists/_form.html.erb
106
+ - app/views/campagne/campagne_lists/import.html.erb
107
+ - app/views/campagne/campagne_lists/index.html.erb
108
+ - app/views/campagne/campagne_lists/new.html.erb
109
+ - app/views/campagne/campagne_lists/show.html.erb
110
+ - app/views/layouts/campagne/campagne.html.erb
111
+ - campagne.gemspec
112
+ - config/locales/en.yml
113
+ - config/routes.rb
114
+ - lib/bulletin.rb
115
+ - lib/campagne/engine.rb
116
+ - lib/campagne/version.rb
117
+ - lib/generators/campagne/campagne_generator.rb
118
+ - lib/generators/campagne/templates/1x1.gif
119
+ - lib/generators/campagne/templates/initializer.rb
120
+ - lib/generators/campagne/templates/migration.rb
121
+ - lib/tasks/resque.rake
122
+ - test/test_helper.rb
123
+ homepage: http://github.com/alobato/campagne
124
+ licenses: []
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ none: false
131
+ requirements:
132
+ - - ! '>='
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ segments:
136
+ - 0
137
+ hash: 1196017273243914426
138
+ required_rubygems_version: !ruby/object:Gem::Requirement
139
+ none: false
140
+ requirements:
141
+ - - ! '>='
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ segments:
145
+ - 0
146
+ hash: 1196017273243914426
147
+ requirements: []
148
+ rubyforge_project:
149
+ rubygems_version: 1.8.17
150
+ signing_key:
151
+ specification_version: 3
152
+ summary: campagne-0.0.2
153
+ test_files: []