inkcite 1.14.0 → 1.15.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/assets/init/config.yml +2 -0
- data/assets/social/facebook.png +0 -0
- data/assets/social/instagram.png +0 -0
- data/assets/social/pintrest.png +0 -0
- data/assets/social/twitter.png +0 -0
- data/inkcite.gemspec +2 -2
- data/lib/inkcite.rb +1 -0
- data/lib/inkcite/cli/base.rb +32 -6
- data/lib/inkcite/cli/preview.rb +24 -28
- data/lib/inkcite/cli/server.rb +22 -19
- data/lib/inkcite/cli/test.rb +1 -1
- data/lib/inkcite/email.rb +8 -4
- data/lib/inkcite/facade/animation.rb +4 -0
- data/lib/inkcite/facade/keyframe.rb +26 -3
- data/lib/inkcite/image/base.rb +38 -0
- data/lib/inkcite/image/guetzli_minifier.rb +62 -0
- data/lib/inkcite/image/image_minifier.rb +143 -0
- data/lib/inkcite/image/image_optim_minifier.rb +90 -0
- data/lib/inkcite/image/mozjpeg_minifier.rb +92 -0
- data/lib/inkcite/mailer.rb +201 -112
- data/lib/inkcite/minifier.rb +2 -146
- data/lib/inkcite/post_processor.rb +13 -0
- data/lib/inkcite/renderer.rb +19 -0
- data/lib/inkcite/renderer/background.rb +53 -14
- data/lib/inkcite/renderer/base.rb +29 -15
- data/lib/inkcite/renderer/button.rb +1 -1
- data/lib/inkcite/renderer/carousel.rb +245 -0
- data/lib/inkcite/renderer/container_base.rb +10 -0
- data/lib/inkcite/renderer/div.rb +1 -3
- data/lib/inkcite/renderer/fireworks.rb +54 -40
- data/lib/inkcite/renderer/footnote.rb +22 -2
- data/lib/inkcite/renderer/image.rb +11 -0
- data/lib/inkcite/renderer/image_base.rb +3 -6
- data/lib/inkcite/renderer/in_browser.rb +4 -0
- data/lib/inkcite/renderer/link.rb +39 -12
- data/lib/inkcite/renderer/mobile_image.rb +1 -1
- data/lib/inkcite/renderer/responsive.rb +9 -1
- data/lib/inkcite/renderer/social.rb +31 -3
- data/lib/inkcite/renderer/special_effect.rb +22 -13
- data/lib/inkcite/renderer/sup.rb +32 -0
- data/lib/inkcite/renderer/table_base.rb +3 -0
- data/lib/inkcite/renderer/topic.rb +76 -0
- data/lib/inkcite/renderer/trademark.rb +47 -0
- data/lib/inkcite/renderer/video_preview.rb +3 -2
- data/lib/inkcite/uploader.rb +2 -3
- data/lib/inkcite/util.rb +51 -0
- data/lib/inkcite/version.rb +1 -1
- data/lib/inkcite/view.rb +140 -54
- data/lib/inkcite/view/context.rb +1 -31
- data/lib/inkcite/view/media_query.rb +6 -0
- data/test/animation_spec.rb +7 -0
- data/test/parser_spec.rb +1 -1
- data/test/renderer/background_spec.rb +16 -12
- data/test/renderer/div_spec.rb +11 -0
- data/test/renderer/footnote_spec.rb +5 -1
- data/test/renderer/image_spec.rb +51 -28
- data/test/renderer/link_spec.rb +20 -8
- data/test/renderer/lorem_spec.rb +2 -2
- data/test/renderer/mobile_image_spec.rb +6 -0
- data/test/renderer/mobile_style_spec.rb +3 -3
- data/test/renderer/redacted_spec.rb +2 -2
- data/test/renderer/social_spec.rb +6 -6
- data/test/renderer/table_spec.rb +4 -0
- data/test/renderer/topic_spec.rb +28 -0
- data/test/renderer/trademark_spec.rb +40 -0
- data/test/renderer/video_preview_spec.rb +1 -1
- data/test/test_helper.rb +14 -0
- data/test/view_spec.rb +4 -0
- metadata +26 -12
- data/assets/init/image_optim.yml +0 -37
@@ -104,9 +104,29 @@ module Inkcite
|
|
104
104
|
# appear in the {footnotes} rendering.
|
105
105
|
instance.active = true
|
106
106
|
|
107
|
+
unless id.blank?
|
108
|
+
|
109
|
+
# Check to see if the once flag is enabled. If enabled, check to see if
|
110
|
+
# this footnote has appeared before.
|
111
|
+
once = opt[:once]
|
112
|
+
return '' if once && !ctx.once?("#{id}-footnote")
|
113
|
+
|
114
|
+
end
|
115
|
+
|
107
116
|
# Allow footnotes to be defined without showing a symbol
|
108
117
|
hidden = opt[:hidden] || (opt[:hidden] == '1')
|
109
|
-
|
118
|
+
return '' if hidden
|
119
|
+
|
120
|
+
# Check to see if the footnote should be automatically surrounded
|
121
|
+
# by the superscript helper.
|
122
|
+
sup = opt[:sup] && !ctx.text?
|
123
|
+
|
124
|
+
html = ''
|
125
|
+
html << '{sup}' if sup
|
126
|
+
html << instance.symbol
|
127
|
+
html << '{/sup}' if sup
|
128
|
+
|
129
|
+
html
|
110
130
|
end
|
111
131
|
|
112
132
|
end
|
@@ -125,7 +145,7 @@ module Inkcite
|
|
125
145
|
# on the format of the email.
|
126
146
|
tmpl = opt[:tmpl] || opt[:template]
|
127
147
|
if tmpl.blank?
|
128
|
-
tmpl = ctx.text? ?
|
148
|
+
tmpl = ctx.text? ? '($symbol$) $text$\n\n' : '<sup>$symbol$</sup> $text$<br><br>'
|
129
149
|
|
130
150
|
elsif ctx.text?
|
131
151
|
|
@@ -11,6 +11,7 @@ module Inkcite
|
|
11
11
|
|
12
12
|
mix_background img, opt, ctx
|
13
13
|
mix_border img, opt, ctx
|
14
|
+
mix_margins img, opt, ctx
|
14
15
|
|
15
16
|
# Check to see if there is alt text specified for this image. We are
|
16
17
|
# testing against nil because sometimes the author desires an empty
|
@@ -66,12 +67,22 @@ module Inkcite
|
|
66
67
|
# vertically aligns the image with the text.
|
67
68
|
inline = (display == INLINE)
|
68
69
|
|
70
|
+
# Allow max-height to be specified.
|
71
|
+
max_height = opt[MAX_HEIGHT]
|
72
|
+
img.style[MAX_HEIGHT] = px(max_height) unless max_height.blank?
|
73
|
+
|
69
74
|
align = opt[:align] || ('absmiddle' if inline)
|
70
75
|
img[:align] = align unless align.blank?
|
71
76
|
|
72
77
|
valign = opt[:valign] || ('middle' if inline)
|
73
78
|
img.style[VERTICAL_ALIGN] = valign unless valign.blank?
|
74
79
|
|
80
|
+
# Fix for unexpected whitespace underneath images when emails
|
81
|
+
# are viewed in Outlook.com. Thanks to @HTeuMeuLeu
|
82
|
+
# https://emails.hteumeuleu.com/outlook-coms-latest-bug-and-how-to-fix-gaps-under-images-ee1816671461
|
83
|
+
id = ctx.unique_id :img
|
84
|
+
img[:id] = quote("OWATemporaryImageDivContainer#{id}")
|
85
|
+
|
75
86
|
html = ''
|
76
87
|
|
77
88
|
# Check to see if an outlook-specific image source has been
|
@@ -4,10 +4,6 @@ module Inkcite
|
|
4
4
|
|
5
5
|
protected
|
6
6
|
|
7
|
-
# Name of the property that allows an outlook-specific src to be specified
|
8
|
-
# for an image.
|
9
|
-
OUTLOOK_SRC = :'outlook-src'
|
10
|
-
|
11
7
|
# Display mode constants
|
12
8
|
BLOCK = 'block'
|
13
9
|
DEFAULT = 'default'
|
@@ -96,11 +92,12 @@ module Inkcite
|
|
96
92
|
end
|
97
93
|
|
98
94
|
def missing_dimensions? att
|
99
|
-
DIMENSIONS.any? { |dim| att[dim].
|
95
|
+
DIMENSIONS.any? { |dim| att[dim].blank? }
|
100
96
|
end
|
101
97
|
|
102
98
|
def mix_dimensions img, opt, ctx
|
103
|
-
|
99
|
+
super
|
100
|
+
DIMENSIONS.each { |dim| img[dim] = opt[dim] }
|
104
101
|
end
|
105
102
|
|
106
103
|
private
|
@@ -15,6 +15,10 @@ module Inkcite
|
|
15
15
|
# Make sure we're converting any embedded values in the host URL
|
16
16
|
url = Renderer.render(url, browser_view)
|
17
17
|
|
18
|
+
# Cache-bust the URL to ensure recipients see the most recent version of
|
19
|
+
# the uploaded HTML
|
20
|
+
Util::add_query_param(url, Time.now.to_i) if !ctx.production? && ctx.is_enabled?(Email::CACHE_BUST)
|
21
|
+
|
18
22
|
# Optional link override color.
|
19
23
|
color = opt[:color]
|
20
24
|
|
@@ -41,7 +41,12 @@ module Inkcite
|
|
41
41
|
# including font, background color, border, etc.
|
42
42
|
mix_all a, opt, ctx
|
43
43
|
|
44
|
-
|
44
|
+
# If no-tag is specified, override the default tagging
|
45
|
+
# behavior. Useful when the tagging interferes with
|
46
|
+
# behavior on dynamic pages.
|
47
|
+
add_tag = opt[:'no-tag'] != true
|
48
|
+
|
49
|
+
id, href, target_blank = Link.process(opt[:id], opt[:href], opt[:force], ctx, add_tag)
|
45
50
|
|
46
51
|
a[:target] = BLANK if target_blank
|
47
52
|
|
@@ -100,7 +105,7 @@ module Inkcite
|
|
100
105
|
html
|
101
106
|
end
|
102
107
|
|
103
|
-
def self.process id, href, force, ctx
|
108
|
+
def self.process id, href, force, ctx, add_tag=true
|
104
109
|
|
105
110
|
# Initially assume a blank target isn't needed
|
106
111
|
target_blank = false
|
@@ -163,7 +168,7 @@ module Inkcite
|
|
163
168
|
ctx.error('Link href appears to be invalid', { :id => id, :href => href }) unless force || valid?(href)
|
164
169
|
|
165
170
|
# Optionally tag the link's query string for post-send log analytics.
|
166
|
-
href = add_tagging(id, href, ctx)
|
171
|
+
href = add_tagging(id, href, ctx) if add_tag
|
167
172
|
|
168
173
|
if last_href.blank?
|
169
174
|
|
@@ -189,7 +194,7 @@ module Inkcite
|
|
189
194
|
|
190
195
|
end
|
191
196
|
|
192
|
-
[
|
197
|
+
[id, href, target_blank]
|
193
198
|
end
|
194
199
|
|
195
200
|
private
|
@@ -220,16 +225,38 @@ module Inkcite
|
|
220
225
|
def self.add_tagging id, href, ctx
|
221
226
|
|
222
227
|
# Check to see if we're tagging links.
|
223
|
-
|
224
|
-
unless
|
225
|
-
|
226
|
-
#
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
228
|
+
tag_map = ctx[TAG_LINKS]
|
229
|
+
unless tag_map.blank?
|
230
|
+
|
231
|
+
# This is the tag to be applied.
|
232
|
+
tag = nil
|
233
|
+
|
234
|
+
# Support for a map of tags where the matching domain is
|
235
|
+
# the key and the value is the tagging.
|
236
|
+
if tag_map.is_a?(Hash)
|
237
|
+
longest_domain = ''
|
238
|
+
|
239
|
+
tag_map.each do |domain, _tag|
|
240
|
+
if href =~ /^https?:\/\/[^\/]*#{domain}/
|
241
|
+
if domain.length > longest_domain.length
|
242
|
+
longest_domain = domain
|
243
|
+
tag = _tag
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
else
|
249
|
+
|
250
|
+
# Blank tag domain means tag all the links - otherwise, make sure the
|
251
|
+
# href matches the desired domain name.
|
252
|
+
tag_domain = ctx[TAG_LINKS_DOMAIN]
|
253
|
+
tag = tag_map if tag_domain.blank? || href =~ /^https?:\/\/[^\/]*#{tag_domain}/
|
254
|
+
|
231
255
|
end
|
232
256
|
|
257
|
+
# If a tag was identified then apply it to the href.
|
258
|
+
href = Util::add_query_param(href, replace_tag(tag, id, ctx)) unless tag.blank?
|
259
|
+
|
233
260
|
end
|
234
261
|
|
235
262
|
href
|
@@ -29,6 +29,7 @@ module Inkcite
|
|
29
29
|
|
30
30
|
# Other mobile-specific properties
|
31
31
|
MOBILE_HEIGHT = :'mobile-height'
|
32
|
+
MOBILE_MAX_WIDTH = :'mobile-max-width'
|
32
33
|
MOBILE_PADDING = :'mobile-padding'
|
33
34
|
MOBILE_WIDTH = :'mobile-width'
|
34
35
|
|
@@ -209,6 +210,13 @@ module Inkcite
|
|
209
210
|
mix_directional element, element.mobile_style, opt, ctx, MOBILE_BORDER, :border
|
210
211
|
end
|
211
212
|
|
213
|
+
def mix_dimensions element, opt, ctx
|
214
|
+
|
215
|
+
max_width = opt[MOBILE_MAX_WIDTH]
|
216
|
+
element.mobile_style[MAX_WIDTH] = px(max_width) unless max_width.blank?
|
217
|
+
|
218
|
+
end
|
219
|
+
|
212
220
|
def mix_font element, opt, ctx, parent=nil
|
213
221
|
|
214
222
|
# Let the super class do its thing and grab the name of the font
|
@@ -237,7 +245,7 @@ module Inkcite
|
|
237
245
|
font
|
238
246
|
end
|
239
247
|
|
240
|
-
def mix_margins element, opt, ctx
|
248
|
+
def mix_margins element, opt, ctx, outlookCompatible=true
|
241
249
|
super
|
242
250
|
mix_directional element, element.mobile_style, opt, ctx, MOBILE_MARGIN, :margin, true
|
243
251
|
end
|
@@ -43,7 +43,35 @@ module Inkcite
|
|
43
43
|
protected
|
44
44
|
|
45
45
|
def get_share_href url, text, opts, ctx
|
46
|
-
|
46
|
+
|
47
|
+
params = {}
|
48
|
+
params[:url] = url unless url.blank?
|
49
|
+
params[:text] = text
|
50
|
+
params[:hashtags] = opts[:hashtags]
|
51
|
+
|
52
|
+
val = "https://twitter.com/intent/tweet?"
|
53
|
+
val << Renderer.join_hash(params, '=', '&')
|
54
|
+
val
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
class Instagram < Social
|
60
|
+
|
61
|
+
def initialize
|
62
|
+
super(:src => 'instagram.png', :alt => 'Instagram', :cta => 'Post')
|
63
|
+
end
|
64
|
+
|
65
|
+
protected
|
66
|
+
|
67
|
+
def get_share_href url, text, opts, ctx
|
68
|
+
|
69
|
+
val = "https://www.instagram.com"
|
70
|
+
|
71
|
+
username = opts[:username]
|
72
|
+
val << "/#{username}" unless username.blank?
|
73
|
+
|
74
|
+
val
|
47
75
|
end
|
48
76
|
|
49
77
|
end
|
@@ -62,10 +90,10 @@ module Inkcite
|
|
62
90
|
id = opts[:id]
|
63
91
|
|
64
92
|
share_url = opts.delete(:href).to_s
|
65
|
-
ctx.error("Social sharing 'href' attribute can't be blank", :tag => tag, :id => id) if share_url.blank?
|
93
|
+
#ctx.error("Social sharing 'href' attribute can't be blank", :tag => tag, :id => id) if share_url.blank?
|
66
94
|
|
67
95
|
share_text = opts.delete(:text).to_s
|
68
|
-
ctx.error("Social sharing 'text' attribute can't be blank", :tag => tag, :id => id, :href => share_url) if share_text.blank?
|
96
|
+
#ctx.error("Social sharing 'text' attribute can't be blank", :tag => tag, :id => id, :href => share_url) if share_text.blank?
|
69
97
|
|
70
98
|
# Let the extending class format the fully-qualified URL to the sharing service.
|
71
99
|
service_href = get_share_href Util.escape(share_url), Util.escape(share_text), opts, ctx
|
@@ -165,18 +165,17 @@ module Inkcite
|
|
165
165
|
(min_speed..max_speed)
|
166
166
|
end
|
167
167
|
|
168
|
+
# An array of source images to be applied to the children or
|
169
|
+
# an empty array if there are none.
|
168
170
|
def src
|
169
|
-
|
171
|
+
@src ||= begin
|
170
172
|
|
171
|
-
|
172
|
-
|
173
|
+
# Check to see if a source image has been specified for the snowflakes.
|
174
|
+
# Split on a comma to treat the source listing as an array. Then verify
|
175
|
+
# that each of the referenced images exists
|
176
|
+
@opt[:src].to_s.split(',').select { |img| @ctx.assert_image_exists(img) }
|
173
177
|
|
174
|
-
|
175
|
-
# the project - this will cause the special effect to default to the
|
176
|
-
# non-image default behavior.
|
177
|
-
@src = nil if @src && !@ctx.assert_image_exists(@src)
|
178
|
-
|
179
|
-
@src
|
178
|
+
end
|
180
179
|
end
|
181
180
|
|
182
181
|
def position_range
|
@@ -304,9 +303,8 @@ module Inkcite
|
|
304
303
|
OPACITY_MAX = :'max-opacity'
|
305
304
|
OPACITY_CEIL = 1.0
|
306
305
|
|
307
|
-
#
|
308
|
-
|
309
|
-
ANIMATION_DURATION = :'animation-duration'
|
306
|
+
# For converting degrees to radians and back
|
307
|
+
PI_OVER_180 = Math::PI / 180.0
|
310
308
|
|
311
309
|
# The extending class can override this method to perform any
|
312
310
|
# additional configuration on the style that affects all
|
@@ -361,7 +359,7 @@ module Inkcite
|
|
361
359
|
color = sfx.color
|
362
360
|
style[BACKGROUND_COLOR] = color unless none?(color)
|
363
361
|
else
|
364
|
-
style[BACKGROUND_IMAGE] = "url(#{sfx.ctx.image_url(src)})"
|
362
|
+
style[BACKGROUND_IMAGE] = "url(#{sfx.ctx.image_url(src.first)})" unless src.length > 1
|
365
363
|
style[BACKGROUND_SIZE] = '100%'
|
366
364
|
end
|
367
365
|
|
@@ -377,6 +375,10 @@ module Inkcite
|
|
377
375
|
# CSS classes to them allowing each to be sized, animated uniquely.
|
378
376
|
def create_child_elements html, styles, sfx
|
379
377
|
|
378
|
+
# Get a local handle on the src array in case we need to apply
|
379
|
+
# a random one to each child (because there are multiple)
|
380
|
+
src = sfx.src
|
381
|
+
|
380
382
|
sfx.count.times do |n|
|
381
383
|
|
382
384
|
child_class_name = sfx.child_class_name(n)
|
@@ -387,6 +389,13 @@ module Inkcite
|
|
387
389
|
# This is the custom style to be applied to the child.
|
388
390
|
style = Inkcite::Renderer::Style.new(".#{child_class_name}", sfx.ctx)
|
389
391
|
|
392
|
+
# If there are multiple images, apply the next image based on the
|
393
|
+
# child's index.
|
394
|
+
if src.length > 1
|
395
|
+
src_index = n % src.length
|
396
|
+
style[BACKGROUND_IMAGE] = "url(#{sfx.ctx.image_url(src[src_index])})"
|
397
|
+
end
|
398
|
+
|
390
399
|
# This is the animation declaration (timing, duration, etc.) for this child.
|
391
400
|
animation = Inkcite::Animation.new(sfx.animation_class_name(n), sfx.ctx)
|
392
401
|
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Inkcite
|
2
|
+
module Renderer
|
3
|
+
class Sup < Base
|
4
|
+
|
5
|
+
def render tag, opt, ctx
|
6
|
+
|
7
|
+
html = ''
|
8
|
+
|
9
|
+
if tag == '/sup'
|
10
|
+
html << '</sup>'
|
11
|
+
|
12
|
+
else
|
13
|
+
|
14
|
+
sup = Element.new('sup', :style => { VERTICAL_ALIGN => :top })
|
15
|
+
|
16
|
+
font_size = (opt[FONT_SIZE] || 10).to_i
|
17
|
+
sup.style[FONT_SIZE] = px(font_size)
|
18
|
+
|
19
|
+
line_height = (opt[LINE_HEIGHT] || 10).to_i
|
20
|
+
sup.style[LINE_HEIGHT] = px(line_height)
|
21
|
+
|
22
|
+
html << sup.to_s
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
html
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
@@ -28,6 +28,9 @@ module Inkcite
|
|
28
28
|
# css isn't supported.
|
29
29
|
element[:bgcolor] = bgcolor unless bgcolor.nil?
|
30
30
|
|
31
|
+
# Allow background gradients on tables and tds.
|
32
|
+
mix_background_gradient element, opt, ctx
|
33
|
+
|
31
34
|
bgimage = opt[:background]
|
32
35
|
bgposition = opt[BACKGROUND_POSITION]
|
33
36
|
bgrepeat = opt[BACKGROUND_REPEAT]
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Inkcite
|
2
|
+
module Renderer
|
3
|
+
class Topic < Base
|
4
|
+
|
5
|
+
class Instance
|
6
|
+
|
7
|
+
# Name of the topic as it will appear in the topic list.
|
8
|
+
attr_reader :name
|
9
|
+
|
10
|
+
# Order of the topic, higher wins
|
11
|
+
attr_reader :priority
|
12
|
+
|
13
|
+
def initialize name, priority
|
14
|
+
@name = name
|
15
|
+
@priority = priority
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
def render tag, opt, ctx
|
21
|
+
|
22
|
+
name = opt[:name]
|
23
|
+
|
24
|
+
if name.blank?
|
25
|
+
ctx.error 'Every topic must have a name'
|
26
|
+
|
27
|
+
else
|
28
|
+
|
29
|
+
# Initialize the array of topic instances that live in the
|
30
|
+
# View's arbitrary data holder.
|
31
|
+
ctx.data[:topics] ||= []
|
32
|
+
|
33
|
+
# Push a topic instance onto the list
|
34
|
+
ctx.data[:topics] << Instance.new(name, opt[:priority].to_i)
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
nil
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
class TopicList < Base
|
44
|
+
|
45
|
+
include PostProcessor
|
46
|
+
|
47
|
+
def post_process html, ctx
|
48
|
+
|
49
|
+
topics = ctx.data[:topics]
|
50
|
+
if topics.blank?
|
51
|
+
ctx.error '{topic-list} included but no topics defined'
|
52
|
+
|
53
|
+
else
|
54
|
+
|
55
|
+
# Sort the topics highest priority first.
|
56
|
+
sorted_topics = topics.sort { |lhs, rhs| rhs.priority <=> lhs.priority }.collect(&:name).join(', ')
|
57
|
+
html.gsub!(TOC_INTERMEDIARY, sorted_topics)
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
html
|
62
|
+
end
|
63
|
+
|
64
|
+
def render tag, opt, ctx
|
65
|
+
ctx.post_processors << self
|
66
|
+
TOC_INTERMEDIARY
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
TOC_INTERMEDIARY = '%%TOPIC-LIST-PLACEHOLDER%%'
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|