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.
@@ -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