txbr 1.1.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,7 +1,5 @@
1
1
  module Txbr
2
2
  module RequestMethods
3
- private
4
-
5
3
  def get_json(url, params = {})
6
4
  response = get(url, params)
7
5
  JSON.parse(response.body)
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Txbr
2
- VERSION = '1.1.1'
2
+ VERSION = '2.0.0'
3
3
  end
@@ -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