Skip to content

Commit

Permalink
Add 'slug' (permalink) support for campaign archives. Closes #1394.
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Jan 9, 2024
1 parent 3335171 commit 0d319ad
Show file tree
Hide file tree
Showing 43 changed files with 235 additions and 76 deletions.
21 changes: 18 additions & 3 deletions cmd/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"html/template"
"net/http"
"net/url"

"github.com/gorilla/feeds"
"github.com/knadh/listmonk/internal/manager"
Expand Down Expand Up @@ -120,10 +121,19 @@ func handleCampaignArchivesPage(c echo.Context) error {
func handleCampaignArchivePage(c echo.Context) error {
var (
app = c.Get("app").(*App)
uuid = c.Param("uuid")
id = c.Param("id")
uuid = ""
slug = ""
)

pubCamp, err := app.core.GetArchivedCampaign(0, uuid)
// ID can be the UUID or slug.
if reUUID.MatchString(id) {
uuid = id
} else {
slug = id
}

pubCamp, err := app.core.GetArchivedCampaign(0, uuid, slug)
if err != nil || pubCamp.Type != models.CampaignTypeRegular {
notFound := false
if er, ok := err.(*echo.HTTPError); ok {
Expand Down Expand Up @@ -202,7 +212,12 @@ func getCampaignArchives(offset, limit int, renderBody bool, app *App) ([]campAr
Subject: camp.Subject,
CreatedAt: camp.CreatedAt,
SendAt: camp.SendAt,
URL: app.constants.ArchiveURL + "/" + camp.UUID,
}

if camp.ArchiveSlug.Valid {
archive.URL, _ = url.JoinPath(app.constants.ArchiveURL, camp.ArchiveSlug.String)
} else {
archive.URL, _ = url.JoinPath(app.constants.ArchiveURL, camp.UUID)
}

if renderBody {
Expand Down
37 changes: 30 additions & 7 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
"gopkg.in/volatiletech/null.v6"
)

// campaignReq is a wrapper over the Campaign model for receiving
Expand Down Expand Up @@ -48,6 +49,7 @@ type campaignContentReq struct {

var (
regexFromAddress = regexp.MustCompile(`((.+?)\s)?<(.+?)@(.+?)>`)
regexSlug = regexp.MustCompile(`[^\p{L}\p{M}\p{N}]`)
)

// handleGetCampaigns handles retrieval of campaigns.
Expand Down Expand Up @@ -99,7 +101,7 @@ func handleGetCampaign(c echo.Context) error {
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)

out, err := app.core.GetCampaign(id, "")
out, err := app.core.GetCampaign(id, "", "")
if err != nil {
return err
}
Expand Down Expand Up @@ -244,7 +246,7 @@ func handleUpdateCampaign(c echo.Context) error {

}

cm, err := app.core.GetCampaign(id, "")
cm, err := app.core.GetCampaign(id, "", "")
if err != nil {
return err
}
Expand Down Expand Up @@ -314,21 +316,30 @@ func handleUpdateCampaignArchive(c echo.Context) error {
)

req := struct {
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
Archive bool `json:"archive"`
TemplateID int `json:"archive_template_id"`
Meta models.JSON `json:"archive_meta"`
ArchiveSlug string `json:"archive_slug"`
}{}

// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}

if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta); err != nil {
if req.ArchiveSlug != "" {
// Format the slug to be alpha-numeric-dash.
s := strings.ToLower(req.ArchiveSlug)
s = strings.TrimSpace(regexSlug.ReplaceAllString(s, " "))
s = regexpSpaces.ReplaceAllString(s, "-")
req.ArchiveSlug = s
}

if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta, req.ArchiveSlug); err != nil {
return err
}

return c.JSON(http.StatusOK, okResp{true})
return c.JSON(http.StatusOK, okResp{req})
}

// handleDeleteCampaign handles campaign deletion.
Expand Down Expand Up @@ -571,6 +582,18 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
c.ArchiveMeta = json.RawMessage("{}")
}

if c.ArchiveSlug.String != "" {
// Format the slug to be alpha-numeric-dash.
s := strings.ToLower(c.ArchiveSlug.String)
s = strings.TrimSpace(regexSlug.ReplaceAllString(s, " "))
s = regexpSpaces.ReplaceAllString(s, "-")

c.ArchiveSlug = null.NewString(s, true)
} else {
// If there's no slug set, set it to NULL in the DB.
c.ArchiveSlug.Valid = false
}

return c, nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
if app.constants.EnablePublicArchive {
e.GET("/archive", handleCampaignArchivesPage)
e.GET("/archive.xml", handleGetCampaignArchivesFeed)
e.GET("/archive/:uuid", handleCampaignArchivePage)
e.GET("/archive/:id", handleCampaignArchivePage)
e.GET("/archive/latest", handleCampaignArchivePageLatest)
}

Expand Down
1 change: 1 addition & 0 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
campTplID,
pq.Int64Array{1},
false,
"welcome-to-listmonk",
archiveTplID,
`{"name": "Subscriber"}`,
nil,
Expand Down
2 changes: 1 addition & 1 deletion cmd/manager_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error)
// GetCampaign fetches a campaign from the database.
func (s *store) GetCampaign(campID int) (*models.Campaign, error) {
var out = &models.Campaign{}
err := s.queries.GetCampaign.Get(out, campID, nil, "default")
err := s.queries.GetCampaign.Get(out, campID, nil, nil, "default")
return out, err
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func handleViewCampaignMessage(c echo.Context) error {
)

// Get the campaign.
camp, err := app.core.GetCampaign(0, campUUID)
camp, err := app.core.GetCampaign(0, campUUID, "")
if err != nil {
if er, ok := err.(*echo.HTTPError); ok {
if er.Code == http.StatusBadRequest {
Expand Down
54 changes: 41 additions & 13 deletions frontend/cypress/e2e/archive.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,68 @@ describe('Archive', () => {
cy.get('.modal input').clear().type('clone').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);

cy.loginAndVisit('/campaigns');
cy.get('[data-cy=btn-clone]').first().click();
cy.get('.modal input').clear().type('clone2').click();
cy.get('.modal button.is-primary').click();
cy.wait(250);

cy.clickMenu('all-campaigns');
});

it('Starts un-archived campaign', () => {
it('Starts campaigns', () => {
cy.get('td[data-label=Status] a').eq(0).click();
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();

cy.get('td[data-label=Status] a').eq(1).click();
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
});

it('Enables archive on one campaign', () => {
it('Enables archive on one campaign (no slug)', () => {
cy.loginAndVisit('/campaigns');
cy.wait(250);
cy.get('td[data-label=Status] a').eq(1).click();
cy.get('td[data-label=Status] a').eq(0).click();

// Switch to archive tab and enable archive.
cy.get('.b-tabs nav a').eq(2).click();
cy.wait(500);
cy.get('[data-cy=btn-archive] .check').click();
cy.get('[data-cy=archive-slug]').clear();
cy.get('[data-cy=archive-meta]').clear()
.type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { 'parseSpecialCharSequences': false });

// Start the campaign.
.type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { parseSpecialCharSequences: false });
cy.get('[data-cy=btn-save]').click();
cy.wait(250);
});

it('Enables archive on one campaign', () => {
cy.loginAndVisit('/campaigns');
cy.wait(250);
cy.get('td[data-label=Status] a').eq(1).click();

// Switch to archive tab and enable archive.
cy.get('.b-tabs nav a').eq(2).click();
cy.wait(500);
cy.get('[data-cy=btn-start]').click();
cy.get('.modal button.is-primary').click();
cy.wait(1000);
cy.get('[data-cy=btn-archive] .check').click();
cy.get('[data-cy=archive-slug]').clear().type('my-archived-campaign');
cy.get('[data-cy=btn-save]').click();
cy.wait(250);
});

it('Opens campaign archive page', () => {
cy.loginAndVisit(`${apiUrl}/archive`);
cy.get('li a').click();
cy.get('h3').contains('Hi Archive!');
cy.get('p').eq(0).contains('Bengaluru');
for (let i = 0; i < 2; i++) {
cy.loginAndVisit(`${apiUrl}/archive`);
cy.get('li a').eq(i).click();
cy.wait(250);
if (i === 0) {
cy.get('h3').contains('Hi Archive!');
cy.get('p').eq(0).contains('Bengaluru');
} else {
cy.get('h3').contains('Hi Subscriber!');
}
}
});
});
62 changes: 41 additions & 21 deletions frontend/src/views/Campaign.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
<form @submit.prevent="() => onSubmit(isNew ? 'create' : 'update')">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" :ref="'focus'" v-model="form.name" name="name" :disabled="!canEdit"
:placeholder="$t('globals.fields.name')" required />
:placeholder="$t('globals.fields.name')" required autofocus />
</b-field>

<b-field :label="$t('campaigns.subject')" label-position="on-border">
Expand Down Expand Up @@ -201,19 +201,32 @@

<b-tab-item :label="$t('campaigns.archive')" icon="newspaper-variant-outline" value="archive" :disabled="isNew">
<section class="wrap">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive" :message="$t('campaigns.archiveHelp')">
<div class="columns">
<div class="column">
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" />
</a>
</div>
<div class="columns">
<div class="column is-4">
<b-field :label="$t('campaigns.archiveEnable')" data-cy="btn-archive"
:message="$t('campaigns.archiveHelp')">
<div class="columns">
<div class="column">
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" />
</a>
</div>
</div>
</b-field>
</div>
</b-field>
<div class="column is-8 has-text-right">
<b-field v-if="!canEdit && canArchive">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
</div>
</div>

<div class="columns">
<div class="column is-8">
Expand All @@ -234,18 +247,18 @@
@click.prevent="onFillArchiveMeta">{}</a>
</div>
</div>
<b-field>
<b-field :label="$t('campaigns.archiveSlug')" label-position="on-border"
:message="$t('campaigns.archiveSlugHelp')">
<b-input :maxlength="200" :ref="'focus'" v-model="form.archiveSlug" name="archive_slug"
data-cy="archive-slug" :disabled="!canArchive || !form.archive" />
</b-field>
</b-field>
<b-field :label="$t('campaigns.archiveMeta')" :message="$t('campaigns.archiveMetaHelp')"
label-position="on-border">
<b-input v-model="form.archiveMetaStr" name="archive_meta" type="textarea" data-cy="archive-meta"
:disabled="!canArchive || !form.archive" rows="20" />
</b-field>

<b-field v-if="!canEdit && canArchive">
<b-button @click="onUpdateCampaignArchive" :loading="loading.campaigns" type="is-primary"
icon-left="content-save-outline" data-cy="btn-archive-save">
{{ $t('globals.buttons.saveChanges') }}
</b-button>
</b-field>
</section>
</b-tab-item><!-- archive -->
</b-tabs>
Expand Down Expand Up @@ -295,6 +308,7 @@ export default Vue.extend({
// Binds form input values.
form: {
archiveSlug: null,
name: '',
subject: '',
fromEmail: '',
Expand Down Expand Up @@ -470,6 +484,7 @@ export default Vue.extend({
createCampaign() {
const data = {
archiveSlug: this.form.subject,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
Expand All @@ -494,6 +509,7 @@ export default Vue.extend({
async updateCampaign(typ) {
const data = {
archive_slug: this.form.archiveSlug,
name: this.form.name,
subject: this.form.subject,
lists: this.form.lists.map((l) => l.id),
Expand Down Expand Up @@ -523,6 +539,7 @@ export default Vue.extend({
return new Promise((resolve) => {
this.$api.updateCampaign(this.data.id, data).then((d) => {
this.data = d;
this.form.archiveSlug = d.archiveSlug;
this.$utils.toast(this.$t(typMsg, { name: d.name }));
resolve();
});
Expand All @@ -538,9 +555,12 @@ export default Vue.extend({
archive: this.form.archive,
archive_template_id: this.form.archiveTemplateId,
archive_meta: JSON.parse(this.form.archiveMetaStr),
archive_slug: this.form.archiveSlug,
};
this.$api.updateCampaignArchive(this.data.id, data);
this.$api.updateCampaignArchive(this.data.id, data).then((d) => {
this.form.archiveSlug = d.archiveSlug;
});
},
// Starts or schedule a campaign.
Expand Down
Loading

0 comments on commit 0d319ad

Please sign in to comment.