Improve campaign content format conversion.

Previously, converting between formats simply copied over raw content.
This update does actual conversion between different formats. While
lossy, this seems to a good enough approximation for even reasonbly
rich HTML content. Closes #348.

- richtext, html => plain
  Strips HTML and converts content to plain text.

- richtext, html => markdown
  Uses turndown (JS) lib to convert HTML to Markdown.

- plain => richtext, html
  Converts line breaks in plain text to HTML breaks.

- richtext => html
  "Beautifies" the HTML generated by the WYSIWYG editor unlike the
  earlier behaviour of dumping one long line of HTML.

- markdown => richtext, html
  Makes an API call to the backend to use the Goldmark lib to convert
  Markdown to HTML.
This commit is contained in:
Kailash Nadh 2021-05-09 15:36:31 +05:30
parent 49c747d7d0
commit 65d25fc3f9
7 changed files with 160 additions and 18 deletions

View File

@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/jaytaylor/html2text"
"github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models" "github.com/knadh/listmonk/models"
@ -23,7 +22,8 @@ import (
null "gopkg.in/volatiletech/null.v6" null "gopkg.in/volatiletech/null.v6"
) )
// campaignReq is a wrapper over the Campaign model. // campaignReq is a wrapper over the Campaign model for receiving
// campaign creation and updation data from APIs.
type campaignReq struct { type campaignReq struct {
models.Campaign models.Campaign
@ -42,6 +42,14 @@ type campaignReq struct {
Type string `json:"type"` Type string `json:"type"`
} }
// campaignContentReq wraps params coming from API requests for converting
// campaign content formats.
type campaignContentReq struct {
models.Campaign
From string `json:"from"`
To string `json:"to"`
}
type campaignStats struct { type campaignStats struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
@ -201,15 +209,28 @@ func handlePreviewCampaign(c echo.Context) error {
return c.HTML(http.StatusOK, string(m.Body())) return c.HTML(http.StatusOK, string(m.Body()))
} }
// handleCampainBodyToText converts an HTML campaign body to plaintext. // handleCampaignContent handles campaign content (body) format conversions.
func handleCampainBodyToText(c echo.Context) error { func handleCampaignContent(c echo.Context) error {
out, err := html2text.FromString(c.FormValue("body"), var (
html2text.Options{PrettyTables: false}) app = c.Get("app").(*App)
if err != nil { id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var camp campaignContentReq
if err := c.Bind(&camp); err != nil {
return err return err
} }
return c.HTML(http.StatusOK, string(out)) out, err := camp.ConvertContent(camp.From, camp.To)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, okResp{out})
} }
// handleCreateCampaign handles campaign creation. // handleCreateCampaign handles campaign creation.

View File

@ -97,6 +97,7 @@ func registerHTTPHandlers(e *echo.Echo, app *App) {
g.GET("/api/campaigns/:id", handleGetCampaigns) g.GET("/api/campaigns/:id", handleGetCampaigns)
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/content", handleCampaignContent)
g.POST("/api/campaigns/:id/text", handlePreviewCampaign) g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
g.POST("/api/campaigns/:id/test", handleTestCampaign) g.POST("/api/campaigns/:id/test", handleTestCampaign)
g.POST("/api/campaigns", handleCreateCampaign) g.POST("/api/campaigns", handleCreateCampaign)

View File

@ -25,6 +25,7 @@
"quill-delta": "^4.2.2", "quill-delta": "^4.2.2",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"textversionjs": "^1.1.3", "textversionjs": "^1.1.3",
"turndown": "^7.0.0",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-c3": "^1.2.11", "vue-c3": "^1.2.11",
"vue-i18n": "^8.22.2", "vue-i18n": "^8.22.2",

View File

@ -156,6 +156,9 @@ export const getCampaignStats = async () => http.get('/api/campaigns/running/sta
export const createCampaign = async (data) => http.post('/api/campaigns', data, export const createCampaign = async (data) => http.post('/api/campaigns', data,
{ loading: models.campaigns }); { loading: models.campaigns });
export const convertCampaignContent = async (data) => http.post(`/api/campaigns/${data.id}/content`, data,
{ loading: models.campaigns });
export const testCampaign = async (data) => http.post(`/api/campaigns/${data.id}/test`, data, export const testCampaign = async (data) => http.post(`/api/campaigns/${data.id}/test`, data,
{ loading: models.campaigns }); { loading: models.campaigns });

View File

@ -78,6 +78,7 @@ import 'quill/dist/quill.core.css';
import { quillEditor, Quill } from 'vue-quill-editor'; import { quillEditor, Quill } from 'vue-quill-editor';
import CodeFlask from 'codeflask'; import CodeFlask from 'codeflask';
import TurndownService from 'turndown';
import CampaignPreview from './CampaignPreview.vue'; import CampaignPreview from './CampaignPreview.vue';
import Media from '../views/Media.vue'; import Media from '../views/Media.vue';
@ -98,6 +99,8 @@ const regLink = new RegExp(/{{(\s+)?TrackLink(\s+)?"(.+?)"(\s+)?}}/);
const Link = Quill.import('formats/link'); const Link = Quill.import('formats/link');
Link.sanitize = (l) => l.replace(regLink, '{{ TrackLink `$3`}}'); Link.sanitize = (l) => l.replace(regLink, '{{ TrackLink `$3`}}');
const turndown = new TurndownService();
// Custom class to override the default indent behaviour to get inline CSS // Custom class to override the default indent behaviour to get inline CSS
// style instead of classes. // style instead of classes.
class IndentAttributor extends Quill.import('parchment').Attributor.Style { class IndentAttributor extends Quill.import('parchment').Attributor.Style {
@ -191,6 +194,9 @@ export default {
}, },
}, },
}, },
// HTML editor.
flask: null,
}; };
}, },
@ -241,22 +247,25 @@ export default {
`; `;
this.$refs.htmlEditor.appendChild(el); this.$refs.htmlEditor.appendChild(el);
const flask = new CodeFlask(el.shadowRoot.getElementById('area'), { this.flask = new CodeFlask(el.shadowRoot.getElementById('area'), {
language: 'html', language: 'html',
lineNumbers: false, lineNumbers: false,
styleParent: el.shadowRoot, styleParent: el.shadowRoot,
readonly: this.disabled, readonly: this.disabled,
}); });
this.flask.onUpdate((b) => {
flask.updateCode(this.form.body);
flask.onUpdate((b) => {
this.form.body = b; this.form.body = b;
this.$emit('input', { contentType: this.form.format, body: this.form.body }); this.$emit('input', { contentType: this.form.format, body: this.form.body });
}); });
this.updateHTMLEditor();
this.isReady = true; this.isReady = true;
}, },
updateHTMLEditor() {
this.flask.updateCode(this.form.body);
},
onTogglePreview() { onTogglePreview() {
this.isPreviewing = !this.isPreviewing; this.isPreviewing = !this.isPreviewing;
}, },
@ -278,6 +287,46 @@ export default {
onMediaSelect(m) { onMediaSelect(m) {
this.$refs.quill.quill.insertEmbed(this.lastSel.index || 0, 'image', m.url); this.$refs.quill.quill.insertEmbed(this.lastSel.index || 0, 'image', m.url);
}, },
beautifyHTML(str) {
const div = document.createElement('div');
div.innerHTML = str.trim();
return this.formatHTMLNode(div, 0).innerHTML;
},
formatHTMLNode(node, level) {
const lvl = level + 1;
const indentBefore = new Array(lvl + 1).join(' ');
const indentAfter = new Array(lvl - 1).join(' ');
let textNode = null;
for (let i = 0; i < node.children.length; i += 1) {
textNode = document.createTextNode(`\n${indentBefore}`);
node.insertBefore(textNode, node.children[i]);
this.formatHTMLNode(node.children[i], lvl);
if (node.lastElementChild === node.children[i]) {
textNode = document.createTextNode(`\n${indentAfter}`);
node.appendChild(textNode);
}
}
return node;
},
trimLines(str, removeEmptyLines) {
const out = str.split('\n');
for (let i = 0; i < out.length; i += 1) {
const line = out[i].trim();
if (removeEmptyLines) {
out[i] = line;
} else if (line === '') {
out[i] = '';
}
}
return out.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n');
},
}, },
computed: { computed: {
@ -306,14 +355,45 @@ export default {
this.onEditorChange(); this.onEditorChange();
}, },
htmlFormat(f) { htmlFormat(to, from) {
if (f !== 'html') { // On switch to HTML, initialize the HTML editor.
return; if (to === 'html') {
this.$nextTick(() => {
this.initHTMLEditor();
});
} }
this.$nextTick(() => { if ((from === 'richtext' || from === 'html') && to === 'plain') {
this.initHTMLEditor(); // richtext, html => plain
});
// Preserve line breaks when converting HTML to plaintext. Quill produces
// HTML without any linebreaks.
const d = document.createElement('div');
d.innerHTML = this.beautifyHTML(this.form.body);
this.form.body = this.trimLines(d.innerText.trim(), true);
} else if ((from === 'richtext' || from === 'html') && to === 'markdown') {
// richtext, html => markdown
this.form.body = turndown.turndown(this.form.body).replace(/\n\n+/ig, '\n\n');
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
// plain => richtext, html
this.form.body = this.form.body.replace(/\n/ig, '<br>\n');
} else if (from === 'richtext' && to === 'html') {
// richtext => html
this.form.body = this.trimLines(this.beautifyHTML(this.form.body), false);
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
// markdown => richtext, html.
this.$api.convertCampaignContent({
id: 1, body: this.form.body, from, to,
}).then((data) => {
this.form.body = this.beautifyHTML(data.trim());
// Update the HTML editor.
if (to === 'html') {
this.updateHTMLEditor();
}
});
}
this.onEditorChange();
}, },
}, },

12
frontend/yarn.lock vendored
View File

@ -3641,6 +3641,11 @@ domhandler@^2.3.0:
dependencies: dependencies:
domelementtype "1" domelementtype "1"
domino@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.6.tgz#fe4ace4310526e5e7b9d12c7de01b7f485a57ffe"
integrity sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==
domutils@1.5.1: domutils@1.5.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@ -9253,6 +9258,13 @@ tunnel-agent@^0.6.0:
dependencies: dependencies:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
turndown@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.0.0.tgz#19b2a6a2d1d700387a1e07665414e4af4fec5225"
integrity sha512-G1FfxfR0mUNMeGjszLYl3kxtopC4O9DRRiMlMDDVHvU1jaBkGFg4qxIyjIk2aiKLHyDyZvZyu4qBO2guuYBy3Q==
dependencies:
domino "^2.1.6"
tweetnacl@^0.14.3, tweetnacl@~0.14.0: tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"

View File

@ -383,6 +383,30 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
return nil return nil
} }
// ConvertContent converts a campaign's body from one format to another,
// for example, Markdown to HTML.
func (c *Campaign) ConvertContent(from, to string) (string, error) {
body := c.Body
for _, r := range regTplFuncs {
body = r.regExp.ReplaceAllString(body, r.replace)
}
// If the format is markdown, convert Markdown to HTML.
var out string
if from == CampaignContentTypeMarkdown &&
(to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) {
var b bytes.Buffer
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
return out, err
}
out = b.String()
} else {
return out, errors.New("unknown formats to convert")
}
return out, nil
}
// FirstName splits the name by spaces and returns the first chunk // FirstName splits the name by spaces and returns the first chunk
// of the name that's greater than 2 characters in length, assuming // of the name that's greater than 2 characters in length, assuming
// that it is the subscriber's first name. // that it is the subscriber's first name.