Add markdown support to campaign content.

This commit is contained in:
Kailash Nadh 2021-04-11 16:13:43 +05:30
parent 4581e47c80
commit 1e59d53135
10 changed files with 65 additions and 23 deletions

View file

@ -155,16 +155,14 @@ func handlePreviewCampaign(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body")
camp = &models.Campaign{}
) )
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} }
err := app.queries.GetCampaignForPreview.Get(camp, id) var camp models.Campaign
err := app.queries.GetCampaignForPreview.Get(&camp, id)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
@ -177,6 +175,12 @@ func handlePreviewCampaign(c echo.Context) error {
"name", "{globals.terms.campaign}", "error", pqErrMsg(err))) "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} }
// There's a body in the request to preview instead of the body in the DB.
if c.Request().Method == http.MethodPost {
camp.ContentType = c.FormValue("content_type")
camp.Body = c.FormValue("body")
}
var sub models.Subscriber var sub models.Subscriber
// Get a random subscriber from the campaign. // Get a random subscriber from the campaign.
if err := app.queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil { if err := app.queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
@ -191,19 +195,14 @@ func handlePreviewCampaign(c echo.Context) error {
} }
} }
// Compile the template. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
if body != "" {
camp.Body = body
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err) app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error())) app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
} }
// Render the message body. // Render the message body.
m := app.manager.NewCampaignMessage(camp, sub) m := app.manager.NewCampaignMessage(&camp, sub)
if err := m.Render(); err != nil { if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err) app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, return echo.NewHTTPError(http.StatusBadRequest,

View file

@ -29,6 +29,7 @@ var migList = []migFunc{
{"v0.7.0", migrations.V0_7_0}, {"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0}, {"v0.8.0", migrations.V0_8_0},
{"v0.9.0", migrations.V0_9_0}, {"v0.9.0", migrations.V0_9_0},
{"v1.0.0", migrations.V1_0_0},
} }
// upgrade upgrades the database to the current version by running SQL migration files // upgrade upgrades the database to the current version by running SQL migration files

View file

@ -11,6 +11,7 @@
<section expanded class="modal-card-body preview"> <section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading> <b-loading :active="isLoading" :is-full-page="false"></b-loading>
<form v-if="body" method="post" :action="previewURL" target="iframe" ref="form"> <form v-if="body" method="post" :action="previewURL" target="iframe" ref="form">
<input type="hidden" name="content_type" :value="contentType" />
<input type="hidden" name="body" :value="body" /> <input type="hidden" name="body" :value="body" />
</form> </form>
@ -42,6 +43,7 @@ export default {
// campaign | template. // campaign | template.
type: String, type: String,
body: String, body: String,
contentType: String,
}, },
data() { data() {

View file

@ -13,6 +13,10 @@
@input="onChangeFormat" :disabled="disabled" name="format" @input="onChangeFormat" :disabled="disabled" name="format"
native-value="html" native-value="html"
data-cy="check-html">{{ $t('campaigns.rawHTML') }}</b-radio> data-cy="check-html">{{ $t('campaigns.rawHTML') }}</b-radio>
<b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format"
native-value="markdown"
data-cy="check-markdown">{{ $t('campaigns.markdown') }}</b-radio>
<b-radio v-model="form.radioFormat" <b-radio v-model="form.radioFormat"
@input="onChangeFormat" :disabled="disabled" name="format" @input="onChangeFormat" :disabled="disabled" name="format"
native-value="plain" native-value="plain"
@ -43,16 +47,18 @@
<div v-if="form.format === 'html'" <div v-if="form.format === 'html'"
ref="htmlEditor" id="html-editor" class="html-editor"></div> ref="htmlEditor" id="html-editor" class="html-editor"></div>
<!-- plain text editor //--> <!-- plain text / markdown editor //-->
<b-input v-if="form.format === 'plain'" v-model="form.body" @input="onEditorChange" <b-input v-if="form.format === 'plain' || form.format === 'markdown'"
v-model="form.body" @input="onEditorChange"
type="textarea" name="content" ref="plainEditor" class="plain-editor" /> type="textarea" name="content" ref="plainEditor" class="plain-editor" />
<!-- campaign preview //--> <!-- campaign preview //-->
<campaign-preview v-if="isPreviewing" <campaign-preview v-if="isPreviewing"
@close="onTogglePreview" @close="onTogglePreview"
type='campaign' type="campaign"
:id='id' :id="id"
:title='title' :title="title"
:contentType="form.format"
:body="form.body"></campaign-preview> :body="form.body"></campaign-preview>
<!-- image picker --> <!-- image picker -->
@ -198,7 +204,7 @@ export default {
}, },
() => { () => {
// On cancel, undo the radio selection. // On cancel, undo the radio selection.
this.form.radioFormat = format === 'richtext' ? 'html' : 'richtext'; this.form.radioFormat = this.form.format;
}, },
); );
}, },
@ -286,6 +292,10 @@ export default {
this.form.format = f; this.form.format = f;
this.form.radioFormat = f; this.form.radioFormat = f;
if (f === 'plain' || f === 'markdown') {
this.isReady = true;
}
// Trigger the change event so that the body and content type // Trigger the change event so that the body and content type
// are propagated to the parent on first load. // are propagated to the parent on first load.
this.onEditorChange(); this.onEditorChange();

1
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/rhnvrm/simples3 v0.5.0 github.com/rhnvrm/simples3 v0.5.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/yuin/goldmark v1.3.4 // indirect
golang.org/x/mod v0.3.0 golang.org/x/mod v0.3.0
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b

2
go.sum
View file

@ -102,6 +102,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/yuin/goldmark v1.3.4 h1:pd9FbZYGoTk0XaRHfu9oRrAiD8F5/MVZ1aMgLK2+S/w=
github.com/yuin/goldmark v1.3.4/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=

View file

@ -25,6 +25,7 @@
"campaigns.fromAddress": "From address", "campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>", "campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign", "campaigns.invalid": "Invalid campaign",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campaign needs a date to be scheduled.", "campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
"campaigns.newCampaign": "New campaign", "campaigns.newCampaign": "New campaign",
"campaigns.noKnownSubsToTest": "No known subscribers to test.", "campaigns.noKnownSubsToTest": "No known subscribers to test.",
@ -259,10 +260,10 @@
"public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.", "public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.",
"public.sub": "Subscribe", "public.sub": "Subscribe",
"public.subConfirmed": "Subscribed successfully.", "public.subConfirmed": "Subscribed successfully.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subConfirmedTitle": "Confirmed", "public.subConfirmedTitle": "Confirmed",
"public.subName": "Name (optional)", "public.subName": "Name (optional)",
"public.subNotFound": "Subscription not found.", "public.subNotFound": "Subscription not found.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subPrivateList": "Private list", "public.subPrivateList": "Private list",
"public.subTitle": "Subscribe", "public.subTitle": "Subscribe",
"public.unsub": "Unsubscribe", "public.unsub": "Unsubscribe",
@ -272,10 +273,7 @@
"public.unsubbedInfo": "You have unsubscribed successfully.", "public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed", "public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list", "public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?", "settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.updateAvailable": "A new update {version} is available.",
"settings.restart": "Restart",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}", "settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}", "settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled", "settings.errorNoSMTP": "At least one SMTP block should be enabled",
@ -326,6 +324,7 @@
"settings.messengers.url": "URL", "settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL of the Postback server.", "settings.messengers.urlHelp": "Root URL of the Postback server.",
"settings.messengers.username": "Username", "settings.messengers.username": "Username",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.performance.batchSize": "Batch size", "settings.performance.batchSize": "Batch size",
"settings.performance.batchSizeHelp": "The number of subscribers to pull from the database in a single iteration. Each iteration pulls subscribers from the database, sends messages to them, and then moves on to the next iteration to pull the next batch. This should ideally be higher than the maximum achievable throughput (concurrency * message_rate).", "settings.performance.batchSizeHelp": "The number of subscribers to pull from the database in a single iteration. Each iteration pulls subscribers from the database, sends messages to them, and then moves on to the next iteration to pull the next batch. This should ideally be higher than the maximum achievable throughput (concurrency * message_rate).",
"settings.performance.concurrency": "Concurrency", "settings.performance.concurrency": "Concurrency",
@ -352,6 +351,7 @@
"settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header", "settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header",
"settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.", "settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
"settings.privacy.name": "Privacy", "settings.privacy.name": "Privacy",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "Auth protocol", "settings.smtp.authProtocol": "Auth protocol",
"settings.smtp.customHeaders": "Custom headers", "settings.smtp.customHeaders": "Custom headers",
"settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", "settings.smtp.customHeadersHelp": "Optional array of e-mail headers to include in all messages sent from this server. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]",
@ -380,6 +380,7 @@
"settings.smtp.waitTimeout": "Wait timeout", "settings.smtp.waitTimeout": "Wait timeout",
"settings.smtp.waitTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).", "settings.smtp.waitTimeoutHelp": "Time to wait for new activity on a connection before closing it and removing it from the pool (s for second, m for minute).",
"settings.title": "Settings", "settings.title": "Settings",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Advanced", "subscribers.advancedQuery": "Advanced",
"subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes", "subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber attributes",
"subscribers.attribs": "Attributes", "subscribers.attribs": "Attributes",

View file

@ -0,0 +1,13 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf"
"github.com/knadh/stuffbin"
)
// V1_0_0 performs the DB migrations for v.1.0.0.
func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`)
return err
}

View file

@ -1,6 +1,7 @@
package models package models
import ( import (
"bytes"
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "errors"
@ -12,6 +13,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types" "github.com/jmoiron/sqlx/types"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/yuin/goldmark"
null "gopkg.in/volatiletech/null.v6" null "gopkg.in/volatiletech/null.v6"
) )
@ -39,6 +41,7 @@ const (
CampaignTypeOptin = "optin" CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext" CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html" CampaignContentTypeHTML = "html"
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain" CampaignContentTypePlain = "plain"
// List. // List.
@ -312,8 +315,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
return fmt.Errorf("error compiling base template: %v", err) return fmt.Errorf("error compiling base template: %v", err)
} }
// If the format is markdown, convert Markdown to HTML.
if c.ContentType == CampaignContentTypeMarkdown {
var b bytes.Buffer
if err := goldmark.Convert([]byte(c.Body), &b); err != nil {
return err
}
body = b.String()
} else {
body = c.Body
}
// Compile the campaign message. // Compile the campaign message.
body = c.Body
for _, r := range regTplFuncs { for _, r := range regTplFuncs {
body = r.regExp.ReplaceAllString(body, r.replace) body = r.regExp.ReplaceAllString(body, r.replace)
} }

View file

@ -4,7 +4,7 @@ DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS
DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed'); DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed');
DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished'); DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished');
DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin'); DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin');
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain'); DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
-- subscribers -- subscribers
DROP TABLE IF EXISTS subscribers CASCADE; DROP TABLE IF EXISTS subscribers CASCADE;