From 3498a727f5d664846abf6d8932ab58757f6e3c28 Mon Sep 17 00:00:00 2001
From: Kailash Nadh
Date: Sat, 19 Dec 2020 16:25:52 +0530
Subject: [PATCH] WIP: Add i18n support
---
Makefile | 3 +-
cmd/admin.go | 53 ++-
cmd/campaigns.go | 148 ++++---
cmd/handlers.go | 41 +-
cmd/i18n.go | 44 +++
cmd/import.go | 23 +-
cmd/init.go | 28 +-
cmd/lists.go | 30 +-
cmd/main.go | 8 +-
cmd/media.go | 32 +-
cmd/public.go | 108 +++---
cmd/settings.go | 38 +-
cmd/subscribers.go | 112 +++---
cmd/templates.go | 67 ++--
cmd/upgrade.go | 1 +
frontend/package.json | 1 +
frontend/src/App.vue | 26 +-
frontend/src/components/EmptyPlaceholder.vue | 2 +-
frontend/src/main.js | 12 +
frontend/src/views/Campaign.vue | 76 ++--
frontend/src/views/Campaigns.vue | 56 +--
frontend/src/views/Dashboard.vue | 41 +-
frontend/src/views/Forms.vue | 21 +-
frontend/src/views/Import.vue | 44 +--
frontend/src/views/ListForm.vue | 44 +--
frontend/src/views/Lists.vue | 41 +-
frontend/src/views/Logs.vue | 2 +-
frontend/src/views/Media.vue | 8 +-
frontend/src/views/Settings.vue | 269 ++++++-------
frontend/src/views/SubscriberBulkList.vue | 16 +-
frontend/src/views/SubscriberForm.vue | 47 +--
frontend/src/views/Subscribers.vue | 62 +--
frontend/src/views/TemplateForm.vue | 23 +-
frontend/src/views/Templates.vue | 28 +-
frontend/yarn.lock | 5 +
go.mod | 2 +
i18n/en.json | 363 ++++++++++++++++++
internal/i18n/i18n.go | 161 ++++++++
internal/migrations/v0.9.0.go | 16 +
schema.sql | 1 +
static/email-templates/campaign-status.html | 10 +-
static/email-templates/default.tpl | 5 +-
static/email-templates/import-status.html | 8 +-
static/email-templates/subscriber-data.html | 5 +-
.../subscriber-optin-campaign.html | 8 +-
static/email-templates/subscriber-optin.html | 12 +-
static/public/templates/optin.html | 12 +-
47 files changed, 1483 insertions(+), 680 deletions(-)
create mode 100644 cmd/i18n.go
create mode 100644 i18n/en.json
create mode 100644 internal/i18n/i18n.go
create mode 100644 internal/migrations/v0.9.0.go
diff --git a/Makefile b/Makefile
index 9ef68aa..f2d4718 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,8 @@ STATIC := config.toml.sample \
static/public:/public \
static/email-templates \
frontend/dist/favicon.png:/frontend/favicon.png \
- frontend/dist/frontend:/frontend
+ frontend/dist/frontend:/frontend \
+ i18n:/i18n
# Install dependencies for building.
.PHONY: deps
diff --git a/cmd/admin.go b/cmd/admin.go
index e2e80af..9bdf574 100644
--- a/cmd/admin.go
+++ b/cmd/admin.go
@@ -14,12 +14,14 @@ import (
)
type configScript struct {
- RootURL string `json:"rootURL"`
- FromEmail string `json:"fromEmail"`
- Messengers []string `json:"messengers"`
- MediaProvider string `json:"mediaProvider"`
- NeedsRestart bool `json:"needsRestart"`
- Update *AppUpdate `json:"update"`
+ RootURL string `json:"rootURL"`
+ FromEmail string `json:"fromEmail"`
+ Messengers []string `json:"messengers"`
+ MediaProvider string `json:"mediaProvider"`
+ NeedsRestart bool `json:"needsRestart"`
+ Update *AppUpdate `json:"update"`
+ Langs []i18nLang `json:"langs"`
+ Lang json.RawMessage `json:"lang"`
}
// handleGetConfigScript returns general configuration as a Javascript
@@ -34,6 +36,17 @@ func handleGetConfigScript(c echo.Context) error {
}
)
+ // Language list.
+ langList, err := geti18nLangList(app.constants.Lang, app)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ fmt.Sprintf("Error loading language list: %v", err))
+ }
+ out.Langs = langList
+
+ // Current language.
+ out.Lang = json.RawMessage(app.i18n.JSON())
+
// Sort messenger names with `email` always as the first item.
var names []string
for name := range app.messengers {
@@ -51,13 +64,19 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update
app.Unlock()
- var (
- b = bytes.Buffer{}
- j = json.NewEncoder(&b)
- )
+ // Write the Javascript variable opening;
+ b := bytes.Buffer{}
b.Write([]byte(`var CONFIG = `))
- _ = j.Encode(out)
- return c.Blob(http.StatusOK, "application/javascript", b.Bytes())
+
+ // Encode the config payload as JSON and write as the variable's value assignment.
+ j := json.NewEncoder(&b)
+ if err := j.Encode(out); err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts("admin.errorMarshallingConfig", map[string]string{
+ "error": err.Error(),
+ }))
+ }
+ return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes())
}
// handleGetDashboardCharts returns chart data points to render ont he dashboard.
@@ -69,7 +88,10 @@ func handleGetDashboardCharts(c echo.Context) error {
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching dashboard stats: %s", pqErrMsg(err)))
+ app.i18n.Ts("globals.messages.errorFetching", map[string]string{
+ "name": "dashboard charts",
+ "error": pqErrMsg(err),
+ }))
}
return c.JSON(http.StatusOK, okResp{out})
@@ -84,7 +106,10 @@ func handleGetDashboardCounts(c echo.Context) error {
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching dashboard statsc counts: %s", pqErrMsg(err)))
+ app.i18n.Ts("globals.messages.errorFetching", map[string]string{
+ "name": "dashboard stats",
+ "error": pqErrMsg(err),
+ }))
}
return c.JSON(http.StatusOK, okResp{out})
diff --git a/cmd/campaigns.go b/cmd/campaigns.go
index b3b66be..dbf312a 100644
--- a/cmd/campaigns.go
+++ b/cmd/campaigns.go
@@ -106,10 +106,12 @@ func handleGetCampaigns(c echo.Context) error {
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaigns: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if single && len(out.Results) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("campaigns.notFound", "name", "{globals.terms.campaign}"))
}
if len(out.Results) == 0 {
out.Results = []models.Campaign{}
@@ -131,7 +133,8 @@ func handleGetCampaigns(c echo.Context) error {
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign stats: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if single {
@@ -157,18 +160,20 @@ func handlePreviewCampaign(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetCampaignForPreview.Get(camp, id)
if err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
var sub models.Subscriber
@@ -180,7 +185,8 @@ func handlePreviewCampaign(c echo.Context) error {
} else {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
}
@@ -192,7 +198,7 @@ func handlePreviewCampaign(c echo.Context) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error compiling template: %v", err))
+ app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
@@ -200,7 +206,7 @@ func handlePreviewCampaign(c echo.Context) error {
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error rendering message: %v", err))
+ app.i18n.Ts2("templates.errorRendering", "error", err.Error()))
}
return c.HTML(http.StatusOK, string(m.Body()))
@@ -237,7 +243,8 @@ func handleCreateCampaign(c echo.Context) error {
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorUUID", "error", err.Error()))
}
// Insert and read ID.
@@ -257,13 +264,13 @@ func handleCreateCampaign(c echo.Context) error {
o.ListIDs,
); err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest,
- "There aren't any subscribers in the target lists to create the campaign.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
}
app.log.Printf("error creating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error creating campaign: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@@ -281,23 +288,25 @@ func handleUpdateCampaign(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
+
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if isCampaignalMutable(cm.Status) {
- return echo.NewHTTPError(http.StatusBadRequest,
- "Cannot update a running or a finished campaign.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate"))
}
// Incoming params.
@@ -327,7 +336,8 @@ func handleUpdateCampaign(c echo.Context) error {
if err != nil {
app.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return handleGetCampaigns(c)
@@ -341,18 +351,23 @@ func handleUpdateCampaignStatus(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts("", map[string]string{
+ "name": "{globals.terms.campaign}",
+ }))
}
app.log.Printf("error fetching campaign: %v", err)
+
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Incoming params.
@@ -365,27 +380,27 @@ func handleUpdateCampaignStatus(c echo.Context) error {
switch o.Status {
case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled {
- errMsg = "Only scheduled campaigns can be saved as drafts"
+ errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft {
- errMsg = "Only draft campaigns can be scheduled"
+ errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
}
if !cm.SendAt.Valid {
- errMsg = "Campaign needs a `send_at` date to be scheduled"
+ errMsg = app.i18n.T("campaigns.needsSendAt")
}
case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
- errMsg = "Only paused campaigns and drafts can be started"
+ errMsg = app.i18n.T("campaigns.onlyPausedDraft")
}
case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning {
- errMsg = "Only active campaigns can be paused"
+ errMsg = app.i18n.T("campaigns.onlyActivePause")
}
case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
- errMsg = "Only active campaigns can be cancelled"
+ errMsg = app.i18n.T("campaigns.onlyActiveCancel")
}
}
@@ -396,12 +411,16 @@ func handleUpdateCampaignStatus(c echo.Context) error {
res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
if err != nil {
app.log.Printf("error updating campaign status: %v", err)
+
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating campaign status: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
return handleGetCampaigns(c)
@@ -416,24 +435,29 @@ func handleDeleteCampaign(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
app.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error deleting campaign: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorDeleting",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
+
}
return c.JSON(http.StatusOK, okResp{true})
@@ -453,7 +477,8 @@ func handleGetRunningCampaignStats(c echo.Context) error {
app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign stats: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} else if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
@@ -488,7 +513,7 @@ func handleTestCampaign(c echo.Context) error {
)
if campID < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid campaign ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
}
// Get and validate fields.
@@ -503,7 +528,7 @@ func handleTestCampaign(c echo.Context) error {
req = c
}
if len(req.SubscriberEmails) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "No subscribers to target.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubsToTest"))
}
// Get the subscribers.
@@ -514,21 +539,25 @@ func handleTestCampaign(c echo.Context) error {
if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
app.log.Printf("error fetching subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching subscribers: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} else if len(subs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "No known subscribers given.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest"))
}
// The campaign.
var camp models.Campaign
if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
if err == sql.ErrNoRows {
- return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound",
+ "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Override certain values in the DB with incoming values.
@@ -544,8 +573,8 @@ func handleTestCampaign(c echo.Context) error {
for _, s := range subs {
sub := s
if err := sendTestMessage(sub, &camp, app); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error sending test: %v", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("campaigns.errorSendTest", "error", err.Error()))
}
}
@@ -556,15 +585,16 @@ func handleTestCampaign(c echo.Context) error {
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
- return fmt.Errorf("Error compiling template: %v", err)
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
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,
- fmt.Sprintf("Error rendering message: %v", err))
+ return echo.NewHTTPError(http.StatusNotFound,
+ app.i18n.Ts2("templates.errorRendering", "error", err.Error()))
}
return app.messengers[camp.Messenger].Push(messenger.Message{
@@ -584,15 +614,15 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
c.FromEmail = app.constants.FromEmail
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !subimporter.IsEmail(c.FromEmail) {
- return c, errors.New("invalid `from_email`")
+ return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail"))
}
}
if !strHasLen(c.Name, 1, stdInputMaxLen) {
- return c, errors.New("invalid length for `name`")
+ return c, errors.New(app.i18n.T("campaigns.fieldInvalidName"))
}
if !strHasLen(c.Subject, 1, stdInputMaxLen) {
- return c, errors.New("invalid length for `subject`")
+ return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject"))
}
// if !hasLen(c.Body, 1, bodyMaxLen) {
@@ -602,21 +632,21 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
// If there's a "send_at" date, it should be in the future.
if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) {
- return c, errors.New("`send_at` date should be in the future")
+ return c, errors.New(app.i18n.T("campaigns.fieldInvalidSendAt"))
}
}
if len(c.ListIDs) == 0 {
- return c, errors.New("no lists selected")
+ return c, errors.New(app.i18n.T("campaigns.fieldInvalidListIDs"))
}
if !app.manager.HasMessenger(c.Messenger) {
- return c, fmt.Errorf("unknown messenger %s", c.Messenger)
+ return c, errors.New(app.i18n.Ts2("campaigns.fieldInvalidMessenger", "name", c.Messenger))
}
camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
- return c, fmt.Errorf("error compiling campaign body: %v", err)
+ return c, errors.New(app.i18n.Ts2("campaigns.fieldInvalidBody", "error", err.Error()))
}
return c, nil
@@ -633,7 +663,7 @@ func isCampaignalMutable(status string) bool {
// makeOptinCampaignMessage makes a default opt-in campaign message body.
func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
if len(o.ListIDs) == 0 {
- return o, echo.NewHTTPError(http.StatusBadRequest, "Invalid list IDs.")
+ return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.fieldInvalidListIDs"))
}
// Fetch double opt-in lists from the given list IDs.
@@ -642,13 +672,13 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
if err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return o, echo.NewHTTPError(http.StatusInternalServerError,
- "Error fetching opt-in lists.")
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
// No opt-in lists.
if len(lists) == 0 {
- return o, echo.NewHTTPError(http.StatusBadRequest,
- "No opt-in lists found to create campaign.")
+ return o, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noOptinLists"))
}
// Construct the opt-in URL with list IDs.
@@ -666,8 +696,8 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil {
app.log.Printf("error compiling 'optin-campaign' template: %v", err)
- return o, echo.NewHTTPError(http.StatusInternalServerError,
- "Error compiling opt-in campaign template.")
+ return o, echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
}
o.Body = b.String()
diff --git a/cmd/handlers.go b/cmd/handlers.go
index b699854..295a321 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -2,6 +2,8 @@ package main
import (
"crypto/subtle"
+ "encoding/json"
+ "fmt"
"net/http"
"net/url"
"regexp"
@@ -31,7 +33,10 @@ type pagination struct {
Limit int `json:"limit"`
}
-var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
+var (
+ reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
+ reLangCode = regexp.MustCompile("[^a-zA-Z_0-9]")
+)
// registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) {
@@ -40,6 +45,7 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript)
+ g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@@ -154,6 +160,23 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
+// handleLoadLanguage returns the JSON language pack given the language code.
+func handleLoadLanguage(c echo.Context) error {
+ app := c.Get("app").(*App)
+
+ lang := c.Param("lang")
+ if len(lang) > 6 || reLangCode.MatchString(lang) {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
+ }
+
+ b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
+ }
+
+ return c.JSON(http.StatusOK, okResp{json.RawMessage(b)})
+}
+
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)
@@ -174,11 +197,13 @@ func basicAuth(username, password string, c echo.Context) (bool, error) {
// validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
+ app := c.Get("app").(*App)
+
for _, p := range params {
if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl("Invalid request", "",
- `One or more UUIDs in the request are invalid.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.T("globals.messages.invalidUUID")))
}
}
return next(c)
@@ -198,14 +223,14 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc
if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "",
- `Error processing request. Please retry.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.T("public.errorProcessingRequest")))
}
if !exists {
- return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl("Not found", "",
- `Subscription not found.`))
+ return c.Render(http.StatusNotFound, tplMessage,
+ makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
+ app.i18n.T("public.subNotFound")))
}
return next(c)
}
diff --git a/cmd/i18n.go b/cmd/i18n.go
new file mode 100644
index 0000000..f91b2f8
--- /dev/null
+++ b/cmd/i18n.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type i18nLang struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type i18nLangRaw struct {
+ Code string `json:"_.code"`
+ Name string `json:"_.name"`
+}
+
+// geti18nLangList returns the list of available i18n languages.
+func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
+ list, err := app.fs.Glob("/i18n/*.json")
+ if err != nil {
+ return nil, err
+ }
+
+ var out []i18nLang
+ for _, l := range list {
+ b, err := app.fs.Get(l)
+ if err != nil {
+ return out, fmt.Errorf("error reading lang file: %s: %v", l, err)
+ }
+
+ var lang i18nLangRaw
+ if err := json.Unmarshal(b.ReadBytes(), &lang); err != nil {
+ return out, fmt.Errorf("error parsing lang file: %s: %v", l, err)
+ }
+
+ out = append(out, i18nLang{
+ Code: lang.Code,
+ Name: lang.Name,
+ })
+ }
+
+ return out, nil
+}
diff --git a/cmd/import.go b/cmd/import.go
index d295970..a107c20 100644
--- a/cmd/import.go
+++ b/cmd/import.go
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
- "fmt"
"io"
"io/ioutil"
"net/http"
@@ -27,30 +26,28 @@ func handleImportSubscribers(c echo.Context) error {
// Is an import already running?
if app.importer.GetStats().Status == subimporter.StatusImporting {
- return echo.NewHTTPError(http.StatusBadRequest,
- "An import is already running. Wait for it to finish or stop it before trying again.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning"))
}
// Unmarsal the JSON params.
var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Invalid `params` field: %v", err))
+ app.i18n.Ts2("import.invalidParams", "error", err.Error()))
}
if r.Mode != subimporter.ModeSubscribe && r.Mode != subimporter.ModeBlocklist {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid `mode`")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidMode"))
}
if len(r.Delim) != 1 {
- return echo.NewHTTPError(http.StatusBadRequest,
- "`delim` should be a single character")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidDelim"))
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Invalid `file`: %v", err))
+ app.i18n.Ts2("import.invalidFile", "error", err.Error()))
}
src, err := file.Open()
@@ -62,20 +59,20 @@ func handleImportSubscribers(c echo.Context) error {
out, err := ioutil.TempFile("", "listmonk")
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error copying uploaded file: %v", err))
+ app.i18n.Ts2("import.errorCopyingFile", "error", err.Error()))
}
defer out.Close()
if _, err = io.Copy(out, src); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error copying uploaded file: %v", err))
+ app.i18n.Ts2("import.errorCopyingFile", "error", err.Error()))
}
// Start the importer session.
impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs)
if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error starting import session: %v", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("import.errorStarting", "error", err.Error()))
}
go impSess.Start()
@@ -91,7 +88,7 @@ func handleImportSubscribers(c echo.Context) error {
dir, files, err := impSess.ExtractZIP(out.Name(), 1)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error processing ZIP file: %v", err))
+ app.i18n.Ts2("import.errorProcessingZIP", "error", err.Error()))
}
go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0]))
}
diff --git a/cmd/init.go b/cmd/init.go
index 3effbc3..3a18cb7 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -20,6 +20,7 @@ import (
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
+ "github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/media/providers/filesystem"
@@ -44,6 +45,7 @@ type constants struct {
FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
+ Lang string `koanf:"lang"`
Privacy struct {
IndividualTracking bool `koanf:"individual_tracking"`
AllowBlocklist bool `koanf:"allow_blocklist"`
@@ -131,6 +133,7 @@ func initFS(staticDir string) stuffbin.FileSystem {
// Alias all files inside dist/ and dist/frontend to frontend/*.
"frontend/dist/favicon.png:/frontend/favicon.png",
"frontend/dist/frontend:/frontend",
+ "i18n:/i18n",
}
fs, err = stuffbin.NewLocalFS("/", files...)
@@ -230,6 +233,7 @@ func initConstants() *constants {
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
+ c.Lang = ko.String("app.lang")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider")
@@ -251,6 +255,22 @@ func initConstants() *constants {
return &c
}
+// initI18n initializes a new i18n instance with the selected language map
+// loaded from the filesystem.
+func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18nLang {
+ b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
+ if err != nil {
+ lo.Fatalf("error loading i18n language file: %v", err)
+ }
+
+ i, err := i18n.New(lang, b)
+ if err != nil {
+ lo.Fatalf("error unmarshalling i18n language: %v", err)
+ }
+
+ return i
+}
+
// initCampaignManager initializes the campaign manager.
func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
campNotifCB := func(subject string, data interface{}) error {
@@ -407,7 +427,7 @@ func initMediaStore() media.Store {
// initNotifTemplates compiles and returns e-mail notification templates that are
// used for sending ad-hoc notifications to admins and subscribers.
-func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *template.Template {
+func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18nLang, cs *constants) *template.Template {
// Register utility functions that the e-mail templates can use.
funcs := template.FuncMap{
"RootURL": func() string {
@@ -415,7 +435,11 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, cs *constants) *tem
},
"LogoURL": func() string {
return cs.LogoURL
- }}
+ },
+ "L": func() *i18n.I18nLang {
+ return i
+ },
+ }
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
if err != nil {
diff --git a/cmd/lists.go b/cmd/lists.go
index 9c3c20a..53cac81 100644
--- a/cmd/lists.go
+++ b/cmd/lists.go
@@ -53,10 +53,12 @@ func handleGetLists(c echo.Context) error {
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.GetLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching lists: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.lists", "error", pqErrMsg(err)))
}
if single && len(out.Results) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "List not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.list}"))
}
if len(out.Results) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
@@ -93,14 +95,14 @@ func handleCreateList(c echo.Context) error {
// Validate.
if !strHasLen(o.Name, 1, stdInputMaxLen) {
- return echo.NewHTTPError(http.StatusBadRequest,
- "Invalid length for the name field.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
}
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts("globals.messages.errorUUID", map[string]string{"error": err.Error()}))
}
// Insert and read ID.
@@ -114,7 +116,8 @@ func handleCreateList(c echo.Context) error {
pq.StringArray(normalizeTags(o.Tags))); err != nil {
app.log.Printf("error creating list: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error creating list: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@@ -131,7 +134,7 @@ func handleUpdateList(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Incoming params.
@@ -144,12 +147,14 @@ func handleUpdateList(c echo.Context) error {
o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags)))
if err != nil {
app.log.Printf("error updating list: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error updating list: %s", pqErrMsg(err)))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "List not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.list}"))
}
return handleGetLists(c)
@@ -165,7 +170,7 @@ func handleDeleteLists(c echo.Context) error {
)
if id < 1 && len(ids) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if id > 0 {
@@ -175,7 +180,8 @@ func handleDeleteLists(c echo.Context) error {
if _, err := app.queries.DeleteLists.Exec(ids); err != nil {
app.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error deleting: %v", err))
+ app.i18n.Ts2("globals.messages.errorDeleting",
+ "name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
diff --git a/cmd/main.go b/cmd/main.go
index c2d2b4d..afb1ae3 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -17,6 +17,7 @@ import (
"github.com/knadh/koanf"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/listmonk/internal/buflog"
+ "github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/internal/media"
"github.com/knadh/listmonk/internal/messenger"
@@ -39,6 +40,7 @@ type App struct {
importer *subimporter.Importer
messengers map[string]messenger.Messenger
media media.Store
+ i18n *i18n.I18nLang
notifTpls *template.Template
log *log.Logger
bufLog *buflog.BufLog
@@ -148,10 +150,14 @@ func main() {
log: lo,
bufLog: bufLog,
}
+
+ // Load i18n language map.
+ app.i18n = initI18n(app.constants.Lang, fs)
+
_, app.queries = initQueries(queryFilePath, db, fs, true)
app.manager = initCampaignManager(app.queries, app.constants, app)
app.importer = initImporter(app.queries, db, app)
- app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.constants)
+ app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
// Initialize the default SMTP (`email`) messenger.
app.messengers[emailMsgr] = initSMTPMessenger(app.manager)
diff --git a/cmd/media.go b/cmd/media.go
index c34cbd4..db10e6b 100644
--- a/cmd/media.go
+++ b/cmd/media.go
@@ -2,7 +2,6 @@ package main
import (
"bytes"
- "fmt"
"mime/multipart"
"net/http"
"strconv"
@@ -35,14 +34,14 @@ func handleUploadMedia(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Invalid file uploaded: %v", err))
+ app.i18n.Ts2("media.invalidFile", "error", err.Error()))
}
// Validate MIME type with the list of allowed types.
var typ = file.Header.Get("Content-type")
if ok := validateMIME(typ, imageMimes); !ok {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Unsupported file type (%s) uploaded.", typ))
+ app.i18n.Ts2("media.unsupportedFileType", "type", typ))
}
// Generate filename
@@ -51,8 +50,8 @@ func handleUploadMedia(c echo.Context) error {
// Read file contents in memory
src, err := file.Open()
if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error reading file: %s", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("media.errorReadingFile", "error", err.Error()))
}
defer src.Close()
@@ -62,7 +61,7 @@ func handleUploadMedia(c echo.Context) error {
app.log.Printf("error uploading file: %v", err)
cleanUp = true
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error uploading file: %s", err))
+ app.i18n.Ts2("media.errorUploading", "error", err.Error()))
}
defer func() {
@@ -80,7 +79,7 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true
app.log.Printf("error resizing image: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error resizing image: %s", err))
+ app.i18n.Ts2("media.errorResizing", "error", err.Error()))
}
// Upload thumbnail.
@@ -89,13 +88,14 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true
app.log.Printf("error saving thumbnail: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error saving thumbnail: %s", err))
+ app.i18n.Ts2("media.errorSavingThumbnail", "error", err.Error()))
}
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError, "Error generating UUID")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorUUID", "error", err.Error()))
}
// Write to the DB.
@@ -103,7 +103,8 @@ func handleUploadMedia(c echo.Context) error {
cleanUp = true
app.log.Printf("error inserting uploaded file to db: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error saving uploaded file to db: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "globals.terms.media", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
}
@@ -117,7 +118,8 @@ func handleGetMedia(c echo.Context) error {
if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching media list: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.media", "error", pqErrMsg(err)))
}
for i := 0; i < len(out); i++ {
@@ -136,13 +138,14 @@ func handleDeleteMedia(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var m media.Media
if err := app.queries.DeleteMedia.Get(&m, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error deleting media: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorDeleting",
+ "name", "globals.terms.media", "error", pqErrMsg(err)))
}
app.media.Delete(m.Filename)
@@ -160,8 +163,7 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
img, err := imaging.Decode(src)
if err != nil {
- return nil, echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error decoding image: %v", err))
+ return nil, err
}
// Encode the image into a byte slice as PNG.
diff --git a/cmd/public.go b/cmd/public.go
index 1d488c0..72b489a 100644
--- a/cmd/public.go
+++ b/cmd/public.go
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
+ "github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
@@ -38,6 +39,7 @@ type tplData struct {
LogoURL string
FaviconURL string
Data interface{}
+ L *i18n.I18nLang
}
type publicTpl struct {
@@ -82,6 +84,7 @@ func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.
LogoURL: t.LogoURL,
FaviconURL: t.FaviconURL,
Data: data,
+ L: c.Get("app").(*App).i18n,
})
}
@@ -99,12 +102,14 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl("Not found", "", `The e-mail campaign was not found.`))
+ makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
+ app.i18n.T("public.campaignNotFound")))
}
app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", `Error fetching e-mail campaign.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorFetchingCampaign")))
}
// Get the subscriber.
@@ -112,19 +117,22 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl("Not found", "", `The e-mail message was not found.`))
+ makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
+ app.i18n.T("public.errorFetchingEmail")))
}
app.log.Printf("error fetching campaign subscriber: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", `Error fetching e-mail message.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorFetchingCampaign")))
}
// Compile the template.
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", `Error compiling e-mail template.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorFetchingCampaign")))
}
// Render the message body.
@@ -132,7 +140,8 @@ func handleViewCampaignMessage(c echo.Context) error {
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", `Error rendering e-mail message.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorFetchingCampaign")))
}
return c.HTML(http.StatusOK, string(m.Body()))
@@ -151,7 +160,7 @@ func handleSubscriptionPage(c echo.Context) error {
out = unsubTpl{}
)
out.SubUUID = subUUID
- out.Title = "Unsubscribe from mailing list"
+ out.Title = app.i18n.T("public.unsubscribeTitle")
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
out.AllowExport = app.constants.Privacy.AllowExport
out.AllowWipe = app.constants.Privacy.AllowWipe
@@ -166,13 +175,13 @@ func handleSubscriptionPage(c echo.Context) error {
if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "",
- `Error processing request. Please retry.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl("Unsubscribed", "",
- `You have been successfully unsubscribed.`))
+ makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
+ app.i18n.T("public.unsubbedInfo")))
}
return c.Render(http.StatusOK, "subscription", out)
@@ -189,7 +198,7 @@ func handleOptinPage(c echo.Context) error {
out = optinTpl{}
)
out.SubUUID = subUUID
- out.Title = "Confirm subscriptions"
+ out.Title = app.i18n.T("public.confirmOptinSubTitle")
out.SubUUID = subUUID
// Get and validate fields.
@@ -202,8 +211,8 @@ func handleOptinPage(c echo.Context) error {
for _, l := range out.ListUUIDs {
if !reUUID.MatchString(l) {
return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl("Invalid request", "",
- `One or more UUIDs in the request are invalid.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.T("globals.messages.invalidUUID")))
}
}
}
@@ -212,15 +221,17 @@ func handleOptinPage(c echo.Context) error {
if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
+
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorFetchingLists")))
}
// There are no lists to confirm.
if len(out.Lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("No subscriptions", "",
- `There are no subscriptions to confirm.`))
+ makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
+ app.i18n.Ts2("public.noSubInfo")))
}
// Confirm.
@@ -228,12 +239,13 @@ func handleOptinPage(c echo.Context) error {
if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "",
- `Error processing request. Please retry.`))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
+
return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl("Confirmed", "",
- `Your subscriptions have been confirmed.`))
+ makeMsgTpl(app.i18n.T("public.subsConfirmedTitle"), "",
+ app.i18n.Ts2("public.subConfirmed")))
}
return c.Render(http.StatusOK, "optin", out)
@@ -253,9 +265,9 @@ func handleSubscriptionForm(c echo.Context) error {
}
if len(req.SubListUUIDs) == 0 {
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "",
- `No lists to subscribe to.`))
+ return c.Render(http.StatusBadRequest, tplMessage,
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.T("globals.messages.invalidUUID")))
}
// If there's no name, use the name bit from the e-mail.
@@ -267,7 +279,7 @@ func handleSubscriptionForm(c echo.Context) error {
// Validate fields.
if err := subimporter.ValidateFields(req.SubReq); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", err.Error()))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
}
// Insert the subscriber into the DB.
@@ -275,11 +287,12 @@ func handleSubscriptionForm(c echo.Context) error {
req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
if _, err := insertSubscriber(req.SubReq, app); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error", "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
}
return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl("Done", "", `Subscribed successfully.`))
+ makeMsgTpl(app.i18n.T("public.subsConfirmedTitle"), "",
+ app.i18n.Ts2("public.subConfirmed")))
}
// handleLinkRedirect redirects a link UUID to its original underlying link
@@ -302,12 +315,14 @@ func handleLinkRedirect(c echo.Context) error {
if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl("Invalid link", "", "The requested link is invalid."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.invalidLink")))
}
app.log.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error opening link", "", "There was an error opening the link. Please try later."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
return c.Redirect(http.StatusTemporaryRedirect, url)
@@ -352,7 +367,8 @@ func handleSelfExportSubscriberData(c echo.Context) error {
// Is export allowed?
if !app.constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl("Invalid request", "", "The feature is not available."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.invalidFeature")))
}
// Get the subscriber's data. A single query that gets the profile,
@@ -362,18 +378,17 @@ func handleSelfExportSubscriberData(c echo.Context) error {
if err != nil {
app.log.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error processing request", "",
- "There was an error processing your request. Please try later."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
// Prepare the attachment e-mail.
var msg bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
- app.log.Printf("error compiling notification template '%s': %v",
- notifSubscriberData, err)
+ app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error preparing data", "",
- "There was an error preparing your data. Please try later."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
// Send the data as a JSON attachment to the subscriber.
@@ -393,12 +408,13 @@ func handleSelfExportSubscriberData(c echo.Context) error {
}); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error e-mailing data", "",
- "There was an error e-mailing your data. Please try later."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
+
return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl("Data e-mailed", "",
- `Your data has been e-mailed to you as an attachment.`))
+ makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
+ app.i18n.T("public.dataSent")))
}
// handleWipeSubscriberData allows a subscriber to delete their data. The
@@ -413,20 +429,20 @@ func handleWipeSubscriberData(c echo.Context) error {
// Is wiping allowed?
if !app.constants.Privacy.AllowWipe {
return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl("Invalid request", "",
- "The feature is not available."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.invalidFeature")))
}
if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
app.log.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl("Error processing request", "",
- "There was an error processing your request. Please try later."))
+ makeMsgTpl(app.i18n.T("public.errorTitle"), "",
+ app.i18n.Ts2("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl("Data removed", "",
- `Your subscriptions and all associated data has been removed.`))
+ makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
+ app.i18n.T("public.dataRemoved")))
}
// drawTransparentImage draws a transparent PNG of given dimensions
diff --git a/cmd/settings.go b/cmd/settings.go
index ae416dc..3ec84a7 100644
--- a/cmd/settings.go
+++ b/cmd/settings.go
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
- "fmt"
"net/http"
"regexp"
"strings"
@@ -15,15 +14,17 @@ import (
)
type settings struct {
- AppRootURL string `json:"app.root_url"`
- AppLogoURL string `json:"app.logo_url"`
- AppFaviconURL string `json:"app.favicon_url"`
- AppFromEmail string `json:"app.from_email"`
- AppNotifyEmails []string `json:"app.notify_emails"`
- AppBatchSize int `json:"app.batch_size"`
- AppConcurrency int `json:"app.concurrency"`
- AppMaxSendErrors int `json:"app.max_send_errors"`
- AppMessageRate int `json:"app.message_rate"`
+ AppRootURL string `json:"app.root_url"`
+ AppLogoURL string `json:"app.logo_url"`
+ AppFaviconURL string `json:"app.favicon_url"`
+ AppFromEmail string `json:"app.from_email"`
+ AppNotifyEmails []string `json:"app.notify_emails"`
+ AppLang string `json:"app.lang"`
+
+ AppBatchSize int `json:"app.batch_size"`
+ AppConcurrency int `json:"app.concurrency"`
+ AppMaxSendErrors int `json:"app.max_send_errors"`
+ AppMessageRate int `json:"app.message_rate"`
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
@@ -144,8 +145,7 @@ func handleUpdateSettings(c echo.Context) error {
}
}
if !has {
- return echo.NewHTTPError(http.StatusBadRequest,
- "At least one SMTP block should be enabled.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
}
// Validate and sanitize postback Messenger names. Duplicates are disallowed
@@ -169,10 +169,10 @@ func handleUpdateSettings(c echo.Context) error {
name := reAlphaNum.ReplaceAllString(strings.ToLower(m.Name), "")
if _, ok := names[name]; ok {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Duplicate messenger name `%s`.", name))
+ app.i18n.Ts2("settings.duplicateMessengerName", "name", name))
}
if len(name) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid messenger name.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.invalidMessengerName"))
}
set.Messengers[i].Name = name
@@ -188,13 +188,14 @@ func handleUpdateSettings(c echo.Context) error {
b, err := json.Marshal(set)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error encoding settings: %v", err))
+ app.i18n.Ts2("settings.errorEncoding", "error", err.Error()))
}
// Update the settings in the DB.
if _, err := app.queries.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating settings: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "globals.terms.settings", "error", pqErrMsg(err)))
}
// If there are any active campaigns, don't do an auto reload and
@@ -232,13 +233,14 @@ func getSettings(app *App) (settings, error) {
if err := app.queries.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching settings: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.settings", "error", pqErrMsg(err)))
}
// Unmarshall the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error parsing settings: %v", err))
+ app.i18n.Ts2("settings.errorEncoding", "error", err.Error()))
}
return out, nil
diff --git a/cmd/subscribers.go b/cmd/subscribers.go
index dbe461c..e0c0cf9 100644
--- a/cmd/subscribers.go
+++ b/cmd/subscribers.go
@@ -101,7 +101,7 @@ func handleQuerySubscribers(c echo.Context) error {
listIDs := pq.Int64Array{}
if listID < 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid `list_id`.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
} else if listID > 0 {
listIDs = append(listIDs, int64(listID))
}
@@ -126,22 +126,24 @@ func handleQuerySubscribers(c echo.Context) error {
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error preparing subscriber query: %v", pqErrMsg(err)))
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
// Run the query. stmt is the raw SQL query.
if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.subscribers", "error", pqErrMsg(err)))
}
// Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error fetching subscriber lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.subscribers", "error", pqErrMsg(err)))
}
out.Query = query
@@ -196,13 +198,13 @@ func handleUpdateSubscriber(c echo.Context) error {
}
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if req.Email != "" && !subimporter.IsEmail(req.Email) {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid `email`.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
}
if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid length for `name`.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}
_, err := app.queries.UpdateSubscriber.Exec(req.ID,
@@ -214,7 +216,8 @@ func handleUpdateSubscriber(c echo.Context) error {
if err != nil {
app.log.Printf("error updating subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating subscriber: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
// Send a confirmation e-mail (if there are any double opt-in lists).
@@ -236,7 +239,7 @@ func handleSubscriberSendOptin(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Fetch the subscriber.
@@ -244,15 +247,17 @@ func handleSubscriberSendOptin(c echo.Context) error {
if err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.subscribers", "error", pqErrMsg(err)))
}
if len(out) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := sendOptinConfirmation(out[0], nil, app); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- "Error sending opt-in e-mail.")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.T("subscribers.errorSendingOptin"))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -271,7 +276,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else {
@@ -279,7 +284,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("One or more invalid IDs given: %v", err))
+ app.i18n.Ts2("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
@@ -291,7 +296,7 @@ func handleBlocklistSubscribers(c echo.Context) error {
if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error blocklisting: %v", err))
+ app.i18n.Ts2("subscribers.errorBlocklisting", "error", err.Error()))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -311,7 +316,7 @@ func handleManageSubscriberLists(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
}
@@ -319,17 +324,16 @@ func handleManageSubscriberLists(c echo.Context) error {
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("One or more invalid IDs given: %v", err))
+ app.i18n.Ts2("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest,
- "No IDs given.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
}
if len(IDs) == 0 {
IDs = req.SubscriberIDs
}
if len(req.TargetListIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "No lists given.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
}
// Action.
@@ -342,13 +346,14 @@ func handleManageSubscriberLists(c echo.Context) error {
case "unsubscribe":
_, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
default:
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error processing lists: %v", err))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.subscribers}", "error", err.Error()))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -367,7 +372,7 @@ func handleDeleteSubscribers(c echo.Context) error {
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else {
@@ -375,11 +380,11 @@ func handleDeleteSubscribers(c echo.Context) error {
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("One or more invalid IDs given: %v", err))
+ app.i18n.Ts2("subscribers.errorInvalidIDs", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
- "No IDs given.")
+ app.i18n.Ts2("subscribers.errorNoIDs", "error", err.Error()))
}
IDs = i
}
@@ -387,7 +392,8 @@ func handleDeleteSubscribers(c echo.Context) error {
if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error deleting subscribers: %v", err))
+ app.i18n.Ts2("globals.messages.errorDeleting",
+ "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -409,9 +415,10 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
app.queries.DeleteSubscribersByQuery,
req.ListIDs, app.db)
if err != nil {
- app.log.Printf("error querying subscribers: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error: %v", err))
+ app.log.Printf("error deleting subscribers: %v", err)
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorDeleting",
+ "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -434,8 +441,8 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
req.ListIDs, app.db)
if err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error: %v", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -453,7 +460,8 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
return err
}
if len(req.TargetListIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "No lists given.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.T("subscribers.errorNoListsGiven"))
}
// Action.
@@ -466,15 +474,16 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
case "unsubscribe":
stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
default:
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid action.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
stmt, req.ListIDs, app.db, req.TargetListIDs)
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error: %v", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
return c.JSON(http.StatusOK, okResp{true})
@@ -491,7 +500,7 @@ func handleExportSubscriberData(c echo.Context) error {
)
id, _ := strconv.ParseInt(pID, 10, 64)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Get the subscriber's data. A single query that gets the profile,
@@ -500,8 +509,9 @@ func handleExportSubscriberData(c echo.Context) error {
_, b, err := exportSubscriberData(id, "", app.constants.Privacy.Exportable, app)
if err != nil {
app.log.Printf("error exporting subscriber data: %s", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- "Error exporting subscriber data.")
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.subscribers", "error", err.Error()))
}
c.Response().Header().Set("Cache-Control", "no-cache")
@@ -527,12 +537,14 @@ func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, erro
req.ListUUIDs)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
- return req.Subscriber, echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
+ return req.Subscriber,
+ echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error inserting subscriber: %v", err))
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
// Fetch the subscriber's full data.
@@ -553,21 +565,25 @@ func getSubscriber(id int, app *App) (models.Subscriber, error) {
)
if id < 1 {
- return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
+ return models.Subscriber{},
+ echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if err := app.queries.GetSubscriber.Select(&out, id, nil); err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.subscriber", "error", pqErrMsg(err)))
}
if len(out) == 0 {
- return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
+ return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error loading subscriber lists: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
- "Error loading subscriber lists.")
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.lists", "error", pqErrMsg(err)))
}
return out[0], nil
@@ -647,8 +663,8 @@ func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) err
// Send the e-mail.
if err := app.sendNotification([]string{sub.Email},
- "Confirm subscription", notifSubscriberOptin, out); err != nil {
- app.log.Printf("error e-mailing subscriber profile: %s", err)
+ app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
+ app.log.Printf("error sending opt-in e-mail: %s", err)
return err
}
return nil
diff --git a/cmd/templates.go b/cmd/templates.go
index b31aa27..4568c38 100644
--- a/cmd/templates.go
+++ b/cmd/templates.go
@@ -50,16 +50,17 @@ func handleGetTemplates(c echo.Context) error {
err := app.queries.GetTemplates.Select(&out, id, noBody)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.templates", "error", pqErrMsg(err)))
}
if single && len(out) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.template}"))
}
if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
- }
- if single {
+ } else if single {
return c.JSON(http.StatusOK, okResp{out[0]})
}
@@ -79,21 +80,23 @@ func handlePreviewTemplate(c echo.Context) error {
if body != "" {
if !regexpTplTag.MatchString(body) {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Template body should contain the %s placeholder exactly once", tplTag))
+ app.i18n.Ts2("templates.placeholderHelp", "placeholder", tplTag))
}
} else {
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetTemplates.Select(&tpls, id, false)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorFetching",
+ "name", "globals.terms.templates", "error", pqErrMsg(err)))
}
if len(tpls) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.template}"))
}
body = tpls[0].Body
}
@@ -101,22 +104,23 @@ func handlePreviewTemplate(c echo.Context) error {
// Compile the template.
camp := models.Campaign{
UUID: dummyUUID,
- Name: "Dummy Campaign",
- Subject: "Dummy Campaign Subject",
+ Name: app.i18n.T("templates.dummyName"),
+ Subject: app.i18n.T("templates.dummySubject"),
FromEmail: "dummy-campaign@listmonk.app",
TemplateBody: body,
Body: dummyTpl,
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err := m.Render(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error rendering message: %v", err))
+ app.i18n.Ts2("templates.errorRendering", "error", err.Error()))
}
return c.HTML(http.StatusOK, string(m.Body()))
@@ -133,7 +137,7 @@ func handleCreateTemplate(c echo.Context) error {
return err
}
- if err := validateTemplate(o); err != nil {
+ if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
@@ -143,7 +147,8 @@ func handleCreateTemplate(c echo.Context) error {
o.Name,
o.Body); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error template user: %v", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
@@ -160,7 +165,7 @@ func handleUpdateTemplate(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var o models.Template
@@ -168,7 +173,7 @@ func handleUpdateTemplate(c echo.Context) error {
return err
}
- if err := validateTemplate(o); err != nil {
+ if err := validateTemplate(o, app); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
@@ -176,11 +181,13 @@ func handleUpdateTemplate(c echo.Context) error {
res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("globals.messages.notFound", "name", "{globals.terms.template}"))
}
return handleGetTemplates(c)
@@ -194,13 +201,14 @@ func handleTemplateSetDefault(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
_, err := app.queries.SetDefaultTemplate.Exec(id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
- fmt.Sprintf("Error updating template: %s", pqErrMsg(err)))
+ app.i18n.Ts2("globals.messages.errorUpdating",
+ "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
return handleGetTemplates(c)
@@ -214,9 +222,10 @@ func handleDeleteTemplate(c echo.Context) error {
)
if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
+ return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} else if id == 1 {
- return echo.NewHTTPError(http.StatusBadRequest, "Cannot delete the primordial template.")
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.T("templates.cantDeleteDefault"))
}
var delID int
@@ -226,26 +235,28 @@ func handleDeleteTemplate(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
- return echo.NewHTTPError(http.StatusBadRequest,
- fmt.Sprintf("Error deleting template: %v", err))
+ return echo.NewHTTPError(http.StatusInternalServerError,
+ app.i18n.Ts2("globals.messages.errorCreating",
+ "name", "{globals.terms.template}", "error", pqErrMsg(err)))
}
if delID == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
- "Cannot delete the last, default, or non-existent template.")
+ app.i18n.T("templates.cantDeleteDefault"))
}
return c.JSON(http.StatusOK, okResp{true})
}
// validateTemplate validates template fields.
-func validateTemplate(o models.Template) error {
+func validateTemplate(o models.Template, app *App) error {
if !strHasLen(o.Name, 1, stdInputMaxLen) {
- return errors.New("invalid length for `name`")
+ return errors.New(app.i18n.T("campaigns.fieldInvalidName"))
}
if !regexpTplTag.MatchString(o.Body) {
- return fmt.Errorf("template body should contain the %s placeholder exactly once", tplTag)
+ return echo.NewHTTPError(http.StatusBadRequest,
+ app.i18n.Ts2("templates.placeholderHelp", "placeholder", tplTag))
}
return nil
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index 44eb4be..8fb7603 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -28,6 +28,7 @@ var migList = []migFunc{
{"v0.4.0", migrations.V0_4_0},
{"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0},
+ {"v0.9.0", migrations.V0_9_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
diff --git a/frontend/package.json b/frontend/package.json
index 1a4f980..5f0c3dc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,7 @@
"sass-loader": "^8.0.2",
"vue": "^2.6.11",
"vue-c3": "^1.2.11",
+ "vue-i18n": "^8.22.2",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.2.0",
"vuex": "^3.4.0"
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 7b5c9aa..4c5ed9e 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -28,16 +28,16 @@
+ icon="view-dashboard-variant-outline" :label="$t('menu.dashboard')">
+ icon="format-list-bulleted-square" :label="$t('globals.terms.lists')">
+ icon="format-list-bulleted-square" :label="$t('menu.allLists')">
+ icon="account-multiple" :label="$t('globals.terms.subscribers')">
+ icon="account-multiple" :label="$t('menu.allSubscribers')">
+ icon="rocket-launch-outline" :label="$t('globals.terms.campaigns')">
+ icon="rocket-launch-outline" :label="$t('menu.allCampaigns')">
+ icon="plus" :label="$t('menu.newCampaign')">
+ icon="image-outline" :label="$t('menu.media')">
+ icon="file-image-outline" :label="$t('globals.terms.templates')">
+ icon="cog-outline" :label="$t('menu.settings')">
+ icon="cog-outline" :label="$t('menu.settings')">
+ icon="newspaper-variant-outline" :label="$t('menu.logs')">
diff --git a/frontend/src/components/EmptyPlaceholder.vue b/frontend/src/components/EmptyPlaceholder.vue
index 0500e11..75d651a 100644
--- a/frontend/src/components/EmptyPlaceholder.vue
+++ b/frontend/src/components/EmptyPlaceholder.vue
@@ -4,7 +4,7 @@
- {{ !label ? 'Nothing here' : label }}
+ {{ !label ? $t('globals.messages.emptyState') : label }}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 99cd0c9..2f30736 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Buefy from 'buefy';
import humps from 'humps';
+import VueI18n from 'vue-i18n';
import App from './App.vue';
import router from './router';
@@ -9,6 +10,12 @@ import * as api from './api';
import utils from './utils';
import { models } from './constants';
+// Internationalisation.
+Vue.use(VueI18n);
+
+// Create VueI18n instance with options
+const i18n = new VueI18n();
+
Vue.use(Buefy, {});
Vue.config.productionTip = false;
@@ -36,10 +43,15 @@ Vue.prototype.$reloadServerConfig = () => {
if (window.CONFIG) {
store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
+
+ // Load language.
+ i18n.locale = window.CONFIG.lang['_.code'];
+ i18n.setLocaleMessage(i18n.locale, window.CONFIG.lang);
}
new Vue({
router,
store,
+ i18n,
render: (h) => h(App),
}).$mount('#app');
diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue
index 279ff20..60cd976 100644
--- a/frontend/src/views/Campaign.vue
+++ b/frontend/src/views/Campaign.vue
@@ -6,25 +6,27 @@
{{ data.status }}
{{ data.type }}
- ID: {{ data.id }} / UUID: {{ data.uuid }}
+ {{ $t('globals.fields.id') }}: {{ data.id }} /
+ {{ $t('globals.fields.uuid') }}: {{ data.uuid }}
{{ data.name }}
- New campaign
+ {{ $t('campaigns.newCampaign') }}
Save changes
-
+ type="is-primary" icon-left="content-save-outline">
+ {{ $t('globals.buttons.saveChanges') }}
+
- Start campaign
+ {{ $t('campaigns.start') }}
- Schedule campaign
+ {{ $t('campaigns.schedule') }}
@@ -33,24 +35,25 @@
-
+