txbr 1.1.1 → 2.0.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.
- checksums.yaml +4 -4
- data/README.md +16 -10
- data/lib/txbr.rb +11 -23
- data/lib/txbr/braze_api.rb +4 -20
- data/lib/txbr/campaign.rb +39 -0
- data/lib/txbr/campaign_handler.rb +24 -0
- data/lib/txbr/campaigns_api.rb +29 -0
- data/lib/txbr/content_tag.rb +64 -0
- data/lib/txbr/email_template.rb +9 -40
- data/lib/txbr/email_template_handler.rb +1 -1
- data/lib/txbr/email_templates_api.rb +33 -0
- data/lib/txbr/liquid.rb +5 -0
- data/lib/txbr/liquid/connected_content_tag.rb +99 -0
- data/lib/txbr/metadata.rb +30 -0
- data/lib/txbr/request_methods.rb +0 -2
- data/lib/txbr/template.rb +54 -0
- data/lib/txbr/template_group.rb +50 -0
- data/lib/txbr/version.rb +1 -1
- data/spec/campaign_spec.rb +136 -0
- data/spec/campaigns_api_spec.rb +71 -0
- data/spec/email_template_spec.rb +25 -10
- data/spec/{braze_api_spec.rb → email_templates_api_spec.rb} +11 -48
- data/spec/shared_examples/api_errors.rb +40 -0
- data/spec/uploader_spec.rb +10 -4
- metadata +16 -4
- data/lib/txbr/email_template_component.rb +0 -96
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require 'liquid'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Txbr
|
|
6
|
+
module Liquid
|
|
7
|
+
class ConnectedContentTag < ::Liquid::Tag
|
|
8
|
+
IDENTIFIER = ::Liquid::Lexer::IDENTIFIER
|
|
9
|
+
|
|
10
|
+
# This regex is used to split apart the arguments provided in
|
|
11
|
+
# connected_content tags, which typically look like this:
|
|
12
|
+
#
|
|
13
|
+
# connected_content https://foo.com/strings.json?project_slug=myproj&resource_slug=myrsrc :save strings :retry
|
|
14
|
+
|
|
15
|
+
# This is a regular expression to pull out the variable in
|
|
16
|
+
# which to store the value returned from the call made by
|
|
17
|
+
# the connected_content filter. For example, if
|
|
18
|
+
# connected_content makes a request to http://foo.com and is
|
|
19
|
+
# told to store the results in a variable called "strings",
|
|
20
|
+
# the API response will then be accessible via the normal
|
|
21
|
+
# Liquid variable mechanism, i.e. {{...}}. Say the API at
|
|
22
|
+
# foo.com returned something like {"bar":"baz"}, then the
|
|
23
|
+
# template might contain {{strings.bar}}, which would print
|
|
24
|
+
# out "baz".
|
|
25
|
+
PARSE_RE = /(:#{IDENTIFIER})\s+(#{IDENTIFIER})/
|
|
26
|
+
ASSIGNS_RE = /\{\{(?:(?!\}\}).)*\}\}/
|
|
27
|
+
|
|
28
|
+
attr_reader :tag_name, :url, :arguments
|
|
29
|
+
|
|
30
|
+
def initialize(tag_name, arg, _context = nil)
|
|
31
|
+
@tag_name = tag_name
|
|
32
|
+
@url, @arguments = parse_arg(arg)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# this method is called inside Txbr::ContentTag#metadata
|
|
36
|
+
def render(context)
|
|
37
|
+
@metadata_hash =
|
|
38
|
+
Metadata::ASSIGNMENTS.each_with_object({}) do |assignment, ret|
|
|
39
|
+
ret[assignment] = context[assignment]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prefix
|
|
44
|
+
# we want to blow up if "save" isn't present
|
|
45
|
+
arguments.fetch('save').first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def metadata
|
|
49
|
+
@metadata ||= begin
|
|
50
|
+
query_hash = CGI.parse(uri.query)
|
|
51
|
+
|
|
52
|
+
Metadata.new(
|
|
53
|
+
query_hash
|
|
54
|
+
.each_with_object({}) { |(k, v), ret| ret[k] = v.first }
|
|
55
|
+
.merge('prefix' => prefix)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def uri
|
|
63
|
+
@uri ||= URI.parse(
|
|
64
|
+
url.gsub(ASSIGNS_RE) do |assign|
|
|
65
|
+
# remove curlies from beginning and end, look up in assigns hash
|
|
66
|
+
@metadata_hash[assign.gsub(/\A\{\{|\}\}\z/, '')]
|
|
67
|
+
end
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_arg(arg)
|
|
72
|
+
url, *components = arg.split(PARSE_RE)
|
|
73
|
+
|
|
74
|
+
url.strip!
|
|
75
|
+
components.map!(&:strip).reject!(&:empty?)
|
|
76
|
+
|
|
77
|
+
idx = 0
|
|
78
|
+
args = {}
|
|
79
|
+
|
|
80
|
+
while idx < components.size
|
|
81
|
+
if components[idx].start_with?(':')
|
|
82
|
+
key = components[idx][1..-1]
|
|
83
|
+
sub_args = []
|
|
84
|
+
idx += 1
|
|
85
|
+
|
|
86
|
+
while idx < components.size && !components[idx].start_with?(':')
|
|
87
|
+
sub_args << components[idx]
|
|
88
|
+
idx += 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
args[key] = sub_args
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
[url, args]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Txbr
|
|
2
|
+
class Metadata
|
|
3
|
+
ASSIGNMENTS = %w(project_slug resource_slug prefix)
|
|
4
|
+
|
|
5
|
+
attr_reader :project_slug, :resource_slug, :prefix
|
|
6
|
+
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
@project_slug = options.fetch('project_slug')
|
|
9
|
+
@resource_slug = options.fetch('resource_slug')
|
|
10
|
+
@prefix = options.fetch('prefix')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ==(other)
|
|
14
|
+
project_slug == other.project_slug &&
|
|
15
|
+
resource_slug == other.resource_slug &&
|
|
16
|
+
prefix == prefix
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def eql?(other)
|
|
20
|
+
hash == other.hash
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def hash
|
|
24
|
+
h = 7
|
|
25
|
+
h = 31 * h + project_slug.hash
|
|
26
|
+
h = 31 * h + resource_slug.hash
|
|
27
|
+
h = 31 * h + prefix.hash
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/txbr/request_methods.rb
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Txbr
|
|
2
|
+
class Template
|
|
3
|
+
attr_reader :id, :liquid_template
|
|
4
|
+
|
|
5
|
+
def initialize(id, liquid_template)
|
|
6
|
+
@id = id
|
|
7
|
+
@liquid_template = liquid_template
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def each_content_tag
|
|
11
|
+
return to_enum(__method__) unless block_given?
|
|
12
|
+
|
|
13
|
+
content_tags.each do |content_tag|
|
|
14
|
+
next unless translation_enabled?
|
|
15
|
+
yield content_tag
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def translation_enabled?
|
|
22
|
+
return @translation_enabled unless @translation_enabled.nil?
|
|
23
|
+
|
|
24
|
+
liquid_template.root.nodelist.each do |node|
|
|
25
|
+
if node.is_a?(::Liquid::Assign)
|
|
26
|
+
variable = node.instance_variable_get(:@to)
|
|
27
|
+
value = node.instance_variable_get(:@from).name
|
|
28
|
+
|
|
29
|
+
if variable == 'translation_enabled'
|
|
30
|
+
@translation_enabled = value
|
|
31
|
+
break
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@translation_enabled = true if @translation_enabled.nil?
|
|
37
|
+
@translation_enabled
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_tags
|
|
41
|
+
@content_tags ||= connected_content_tags.map do |tag|
|
|
42
|
+
ContentTag.new(liquid_template, tag)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def connected_content_tags
|
|
47
|
+
# this assumes these are basically at the top of the template and not
|
|
48
|
+
# nested inside other liquid tags
|
|
49
|
+
@connected_content_tags ||= liquid_template.root.nodelist.select do |node|
|
|
50
|
+
node.is_a?(Txbr::Liquid::ConnectedContentTag)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Txbr
|
|
2
|
+
class TemplateGroup
|
|
3
|
+
attr_reader :template_name, :templates, :project
|
|
4
|
+
|
|
5
|
+
def initialize(template_name, templates, project)
|
|
6
|
+
@template_name = template_name
|
|
7
|
+
@templates = templates
|
|
8
|
+
@project = project
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def each_resource
|
|
12
|
+
return to_enum(__method__) unless block_given?
|
|
13
|
+
|
|
14
|
+
templates
|
|
15
|
+
.flat_map { |tmpl| tmpl.each_content_tag.to_a }
|
|
16
|
+
.group_by(&:metadata)
|
|
17
|
+
.each do |metadata, content_tags|
|
|
18
|
+
strings = content_tags.inject(StringsManifest.new) do |manifest, content_tag|
|
|
19
|
+
manifest.merge(content_tag.strings_manifest)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
yield to_resource(metadata, strings)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def to_resource(metadata, strings_manifest)
|
|
29
|
+
phrases = strings_manifest.each_string
|
|
30
|
+
.reject { |_, value| value.nil? }
|
|
31
|
+
.map do |path, value|
|
|
32
|
+
{ 'key' => path.join('.'), 'string' => value }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return nil if phrases.empty?
|
|
36
|
+
|
|
37
|
+
resource = Txgh::TxResource.new(
|
|
38
|
+
metadata.project_slug,
|
|
39
|
+
metadata.resource_slug,
|
|
40
|
+
project.strings_format,
|
|
41
|
+
project.source_lang,
|
|
42
|
+
template_name,
|
|
43
|
+
{}, # lang_map (none)
|
|
44
|
+
nil # translation_file (none)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
Txgh::ResourceContents.from_phrase_list(resource, phrases)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/txbr/version.rb
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'support/standard_setup'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
describe Txbr::Campaign do
|
|
6
|
+
include_context 'standard setup'
|
|
7
|
+
|
|
8
|
+
let(:campaign_id) { 'abc123' }
|
|
9
|
+
let(:campaign) { described_class.new(project, campaign_id) }
|
|
10
|
+
|
|
11
|
+
let(:first_message) do
|
|
12
|
+
<<~MESSAGE
|
|
13
|
+
{% assign project_slug = "my_project" %}
|
|
14
|
+
{% assign resource_slug = "my_resource" %}
|
|
15
|
+
{% assign translation_enabled = true %}
|
|
16
|
+
{% connected_content http://my_strings_api.com?project_slug={{project_slug}}&resource_slug={{resource_slug}} :save strings %}
|
|
17
|
+
{% connected_content http://my_strings_api.com?project_slug=my_project&resource_slug=my_footer_resource :save footer %}
|
|
18
|
+
|
|
19
|
+
{{strings.header | default: 'Buy our stuff!'}}
|
|
20
|
+
{% if user.gets_discount? %}
|
|
21
|
+
{{strings.discount | default: 'You get a discount'}}
|
|
22
|
+
{% else %}
|
|
23
|
+
{{strings.no_discount | default: 'You get no discount'}}
|
|
24
|
+
{% endif %}
|
|
25
|
+
{{footer.company | default: 'Megamarketing Corp'}}
|
|
26
|
+
MESSAGE
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
let(:second_message) do
|
|
30
|
+
<<~HTML
|
|
31
|
+
{% assign project_slug = "my_project" %}
|
|
32
|
+
{% assign resource_slug = "my_resource" %}
|
|
33
|
+
{% assign translation_enabled = true %}
|
|
34
|
+
{% connected_content http://my_strings_api.com?project_slug={{project_slug}}&resource_slug={{resource_slug}} :save strings %}
|
|
35
|
+
{{strings.meta.subject_line | default: 'You lucky duck maybe'}}
|
|
36
|
+
HTML
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#each_resource' do
|
|
40
|
+
let(:braze_interactions) do
|
|
41
|
+
[{
|
|
42
|
+
request: {
|
|
43
|
+
verb: 'get',
|
|
44
|
+
url: Txbr::CampaignsApi::CAMPAIGN_DETAILS_PATH,
|
|
45
|
+
params: { campaign_id: campaign_id }
|
|
46
|
+
},
|
|
47
|
+
response: {
|
|
48
|
+
status: 200,
|
|
49
|
+
body: {
|
|
50
|
+
name: 'World Domination',
|
|
51
|
+
messages: {
|
|
52
|
+
abc123: { name: 'Subliminal Messaging', message: first_message },
|
|
53
|
+
def456: { name: 'Propaganda', message: second_message }
|
|
54
|
+
}
|
|
55
|
+
}.to_json
|
|
56
|
+
}
|
|
57
|
+
}]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'extracts and groups all strings with the same project, resource, and prefix' do
|
|
61
|
+
resource = campaign.each_resource.to_a.first
|
|
62
|
+
expect(resource.tx_resource.project_slug).to eq('my_project')
|
|
63
|
+
expect(resource.tx_resource.resource_slug).to eq('my_resource')
|
|
64
|
+
|
|
65
|
+
# notice how it combined strings from both messages,
|
|
66
|
+
expect(resource.phrases).to eq([
|
|
67
|
+
{ 'key' => 'header', 'string' => 'Buy our stuff!' },
|
|
68
|
+
{ 'key' => 'discount', 'string' => 'You get a discount' },
|
|
69
|
+
{ 'key' => 'no_discount', 'string' => 'You get no discount' },
|
|
70
|
+
{ 'key' => 'meta.subject_line', 'string' => 'You lucky duck maybe' }
|
|
71
|
+
])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'constructs a txgh resource' do
|
|
75
|
+
resource = campaign.each_resource.to_a.first
|
|
76
|
+
tx_resource = resource.tx_resource
|
|
77
|
+
|
|
78
|
+
expect(tx_resource.project_slug).to eq('my_project')
|
|
79
|
+
expect(tx_resource.resource_slug).to eq('my_resource')
|
|
80
|
+
expect(tx_resource.source_file).to eq('World Domination')
|
|
81
|
+
expect(tx_resource.source_lang).to eq(project.source_lang)
|
|
82
|
+
expect(tx_resource.type).to eq(project.strings_format)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'constructs a separate resource for the footer' do
|
|
86
|
+
footer = campaign.each_resource.to_a.last
|
|
87
|
+
expect(footer.tx_resource.project_slug).to eq('my_project')
|
|
88
|
+
expect(footer.tx_resource.resource_slug).to eq('my_footer_resource')
|
|
89
|
+
|
|
90
|
+
expect(footer.phrases).to eq([
|
|
91
|
+
{ 'key' => 'company', 'string' => 'Megamarketing Corp' }
|
|
92
|
+
])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'with translations disabled for the first message' do
|
|
96
|
+
let(:first_message) do
|
|
97
|
+
super().tap do |subj|
|
|
98
|
+
subj.sub!('translation_enabled = true', 'translation_enabled = false')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'does not include translations for the first message' do
|
|
103
|
+
expect(campaign.each_resource.to_a.first.phrases).to_not(
|
|
104
|
+
include({ 'key' => 'header', 'string' => 'Buy our stuff!' })
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context 'when the message comes from a separate resource' do
|
|
110
|
+
let(:first_message) do
|
|
111
|
+
super().tap do |subj|
|
|
112
|
+
subj.sub!(
|
|
113
|
+
'resource_slug = "my_resource"',
|
|
114
|
+
'resource_slug = "my_other_resource"'
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'includes the additional resource' do
|
|
120
|
+
resources = campaign.each_resource.to_a
|
|
121
|
+
expect(resources.size).to eq(3)
|
|
122
|
+
|
|
123
|
+
expect(resources.first.phrases).to_not(
|
|
124
|
+
include({ 'key' => 'meta.subject_line', 'string' => 'You lucky duck maybe' })
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(resources.last.phrases).to eq(
|
|
128
|
+
[{ 'key' => 'meta.subject_line', 'string' => 'You lucky duck maybe' }]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(resources.first.tx_resource.project_slug).to eq('my_project')
|
|
132
|
+
expect(resources.first.tx_resource.resource_slug).to eq('my_other_resource')
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'support/fake_connection'
|
|
3
|
+
require 'shared_examples/api_errors'
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
describe Txbr::CampaignsApi do
|
|
8
|
+
let(:api_key) { 'abc123' }
|
|
9
|
+
let(:api_url) { 'https://somewhere.braze.com' }
|
|
10
|
+
let(:connection) { FakeConnection.new(interactions) }
|
|
11
|
+
let(:braze_api) { Txbr::BrazeApi.new(api_key, api_url, connection: connection) }
|
|
12
|
+
let(:client) { described_class.new(braze_api) }
|
|
13
|
+
|
|
14
|
+
describe '#each' do
|
|
15
|
+
subject { client.each.to_a }
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
stub_const("#{described_class.name}::CAMPAIGN_BATCH_SIZE", 1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
let(:interactions) do
|
|
22
|
+
[{
|
|
23
|
+
request: { verb: 'get', url: described_class::CAMPAIGN_LIST_PATH, params: { page: 0 } },
|
|
24
|
+
response: { status: 200, body: { campaigns: [{ id: '123abc' }] }.to_json }
|
|
25
|
+
}, {
|
|
26
|
+
request: { verb: 'get', url: described_class::CAMPAIGN_LIST_PATH, params: { page: 1 } },
|
|
27
|
+
response: { status: 200, body: { campaigns: [{ id: '456def' }] }.to_json }
|
|
28
|
+
}, {
|
|
29
|
+
request: { verb: 'get', url: described_class::CAMPAIGN_LIST_PATH, params: { page: 2 } },
|
|
30
|
+
response: { status: 200, body: { campaigns: [] }.to_json }
|
|
31
|
+
}]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'yields each template' do
|
|
35
|
+
expect(subject).to eq([
|
|
36
|
+
{ 'id' => '123abc' }, { 'id' => '456def' }
|
|
37
|
+
])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it_behaves_like 'a client request that handles errors'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#details' do
|
|
44
|
+
subject { client.details(campaign_id: campaign_id) }
|
|
45
|
+
let(:campaign_id) { 'abc123' }
|
|
46
|
+
|
|
47
|
+
let(:details) do
|
|
48
|
+
{
|
|
49
|
+
'name' => 'World Domination',
|
|
50
|
+
'messages' => [{
|
|
51
|
+
'message' => 'Today vegetables. Tomorrow the world.'
|
|
52
|
+
}, {
|
|
53
|
+
'message' => 'I haz teh power.'
|
|
54
|
+
}]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
let(:interactions) do
|
|
59
|
+
[{
|
|
60
|
+
request: { verb: 'get', url: described_class::CAMPAIGN_DETAILS_PATH, params: { campaign_id: campaign_id } },
|
|
61
|
+
response: { status: 200, body: details.to_json }
|
|
62
|
+
}]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'retrieves the template details' do
|
|
66
|
+
expect(subject).to eq(details)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it_behaves_like 'a client request that handles errors'
|
|
70
|
+
end
|
|
71
|
+
end
|