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 (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body")
camp = &models.Campaign{}
)
if id < 1 {
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 == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
@ -177,6 +175,12 @@ func handlePreviewCampaign(c echo.Context) error {
"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
// Get a random subscriber from the campaign.
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 body != "" {
camp.Body = body
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(camp, sub)
m := app.manager.NewCampaignMessage(&camp, sub)
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,

View file

@ -29,6 +29,7 @@ var migList = []migFunc{
{"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_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

View file

@ -11,6 +11,7 @@
<section expanded class="modal-card-body preview">
<b-loading :active="isLoading" :is-full-page="false"></b-loading>
<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" />
</form>
@ -42,6 +43,7 @@ export default {
// campaign | template.
type: String,
body: String,
contentType: String,
},
data() {

View file

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

1
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/rhnvrm/simples3 v0.5.0
github.com/spf13/pflag v1.0.5
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
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
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/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200414173820-0848c9571904 h1:bXoxMPcSLOq08zI3/c5dEBT6lE4eh+jOh886GHrn6V8=

View file

@ -25,6 +25,7 @@
"campaigns.fromAddress": "From address",
"campaigns.fromAddressPlaceholder": "Your Name <noreply@yoursite.com>",
"campaigns.invalid": "Invalid campaign",
"campaigns.markdown": "Markdown",
"campaigns.needsSendAt": "Campaign needs a date to be scheduled.",
"campaigns.newCampaign": "New campaign",
"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.sub": "Subscribe",
"public.subConfirmed": "Subscribed successfully.",
"public.subOptinPending": "An e-mail has been sent to you to confirm your subscription(s).",
"public.subConfirmedTitle": "Confirmed",
"public.subName": "Name (optional)",
"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.subTitle": "Subscribe",
"public.unsub": "Unsubscribe",
@ -272,10 +273,7 @@
"public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed",
"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.updateAvailable": "A new update {version} is available.",
"settings.restart": "Restart",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled",
@ -326,6 +324,7 @@
"settings.messengers.url": "URL",
"settings.messengers.urlHelp": "Root URL of the Postback server.",
"settings.messengers.username": "Username",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"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.concurrency": "Concurrency",
@ -352,6 +351,7 @@
"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.name": "Privacy",
"settings.restart": "Restart",
"settings.smtp.authProtocol": "Auth protocol",
"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\"}]",
@ -380,6 +380,7 @@
"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.title": "Settings",
"settings.updateAvailable": "A new update {version} is available.",
"subscribers.advancedQuery": "Advanced",
"subscribers.advancedQueryHelp": "Partial SQL expression to query subscriber 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
import (
"bytes"
"database/sql/driver"
"encoding/json"
"errors"
@ -12,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/lib/pq"
"github.com/yuin/goldmark"
null "gopkg.in/volatiletech/null.v6"
)
@ -39,6 +41,7 @@ const (
CampaignTypeOptin = "optin"
CampaignContentTypeRichtext = "richtext"
CampaignContentTypeHTML = "html"
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain"
// List.
@ -312,8 +315,18 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
return fmt.Errorf("error compiling base template: %v", err)
}
// Compile the campaign message.
// 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.
for _, r := range regTplFuncs {
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 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 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
DROP TABLE IF EXISTS subscribers CASCADE;