shiplog 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -4
- data/README.md +178 -7
- data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
- data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
- data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
- data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
- data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
- data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
- data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
- data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
- data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/frontends_controller.rb +2 -1
- data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
- data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
- data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
- data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
- data/app/controllers/shiplog/newsletter_controller.rb +34 -0
- data/app/controllers/shiplog/public_controller.rb +10 -0
- data/app/frontend/shiplog/admin.css +5 -0
- data/app/frontend/shiplog/once.js +31 -0
- data/app/frontend/shiplog/shiplog.css +72 -0
- data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
- data/app/helpers/shiplog/head_helper.rb +1 -1
- data/app/helpers/shiplog/newsletter_helper.rb +28 -0
- data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
- data/app/helpers/shiplog/permalink_helper.rb +24 -5
- data/app/helpers/shiplog/unseen_helper.rb +4 -0
- data/app/jobs/shiplog/announce_job.rb +30 -0
- data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
- data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
- data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
- data/app/jobs/shiplog/translate_entry_job.rb +2 -0
- data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
- data/app/models/shiplog/announcements.rb +34 -0
- data/app/models/shiplog/area_detector.rb +2 -1
- data/app/models/shiplog/change_candidates.rb +4 -0
- data/app/models/shiplog/drafter.rb +4 -2
- data/app/models/shiplog/entry.rb +76 -6
- data/app/models/shiplog/markdown.rb +233 -0
- data/app/models/shiplog/newsletter/content.rb +172 -0
- data/app/models/shiplog/newsletter/csv.rb +32 -0
- data/app/models/shiplog/newsletter/recipients.rb +92 -0
- data/app/models/shiplog/newsletter/tokens.rb +77 -0
- data/app/models/shiplog/newsletter.rb +180 -0
- data/app/models/shiplog/newsletter_delivery.rb +83 -0
- data/app/models/shiplog/newsletter_subscriber.rb +158 -0
- data/app/models/shiplog/period.rb +129 -0
- data/app/models/shiplog/public_links.rb +47 -0
- data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
- data/app/models/shiplog/rewriter.rb +23 -3
- data/app/models/shiplog/translator/prompt.rb +2 -2
- data/app/models/shiplog/translator.rb +1 -1
- data/app/views/shiplog/admin/_nav.html.erb +3 -0
- data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
- data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
- data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
- data/app/views/shiplog/admin/entries/index.html.erb +9 -0
- data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
- data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
- data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
- data/app/views/shiplog/changelogs/_month.html.erb +10 -3
- data/app/views/shiplog/changelogs/show.html.erb +4 -1
- data/app/views/shiplog/newsletter/_form.html.erb +25 -0
- data/app/views/shiplog/newsletter/_page.html.erb +14 -0
- data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
- data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
- data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
- data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
- data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
- data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
- data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
- data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
- data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
- data/config/admin_routes.rb +12 -0
- data/config/locales/en.yml +147 -0
- data/config/locales/es.yml +147 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
- data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
- data/lib/generators/shiplog/install/install_generator.rb +14 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
- data/lib/shiplog/classes.rb +14 -1
- data/lib/shiplog/configuration.rb +135 -3
- data/lib/shiplog/engine.rb +2 -0
- data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
- data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +33 -0
- data/lib/tasks/shiplog.rake +22 -0
- metadata +57 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# The newsletter's tables (0.4.0, second part). Additive and reversible.
|
|
2
|
+
class Shiplog040Newsletter < ActiveRecord::Migration[8.1]
|
|
3
|
+
def change
|
|
4
|
+
# --- Newsletter ---
|
|
5
|
+
# Visitors who subscribed through Shiplog's form, and the addresses that unsubscribed (a suppression record,
|
|
6
|
+
# also for the host's own users, who are otherwise never stored). Double opt-in: confirmation_nonce is the
|
|
7
|
+
# single-use secret of the pending confirmation link, cleared when it is used; confirmed_at is set then;
|
|
8
|
+
# unsubscribed_at keeps an unsubscribed address suppressed; token reaches the preferences page without an
|
|
9
|
+
# account (rotated after each change there); source is the page the visitor signed up on (a consent record).
|
|
10
|
+
# Unique on the lowercased email.
|
|
11
|
+
create_table :shiplog_newsletter_subscribers do |t|
|
|
12
|
+
t.string :email, null: false
|
|
13
|
+
t.string :locale, null: false
|
|
14
|
+
t.string :token, null: false
|
|
15
|
+
t.string :confirmation_nonce
|
|
16
|
+
t.datetime :confirmed_at
|
|
17
|
+
t.datetime :unsubscribed_at
|
|
18
|
+
t.string :source
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
add_index :shiplog_newsletter_subscribers, "lower(email)", unique: true, name: "index_shiplog_newsletter_subscribers_on_lower_email"
|
|
22
|
+
add_index :shiplog_newsletter_subscribers, :token, unique: true
|
|
23
|
+
add_index :shiplog_newsletter_subscribers, :confirmation_nonce, unique: true
|
|
24
|
+
add_index :shiplog_newsletter_subscribers, [ :confirmed_at, :unsubscribed_at ], name: "index_shiplog_newsletter_subscribers_on_status"
|
|
25
|
+
|
|
26
|
+
# One row per announcement and recipient ("User/42" for the host's users, "Subscriber/7" for Shiplog's
|
|
27
|
+
# subscribers), inserted before the email is sent: a retried job or a second click never sends twice.
|
|
28
|
+
# status is pending (claimed), sent or failed (error says why, without the address, for the admin's "Retry
|
|
29
|
+
# failed"). No email address is kept here; rows older than 180 days are purged.
|
|
30
|
+
create_table :shiplog_newsletter_deliveries do |t|
|
|
31
|
+
t.references :announcement, null: false, foreign_key: { to_table: :shiplog_entries, on_delete: :cascade }, index: false
|
|
32
|
+
t.string :recipient_key, null: false
|
|
33
|
+
t.string :locale, null: false
|
|
34
|
+
t.string :status, null: false, default: "pending"
|
|
35
|
+
t.datetime :sent_at
|
|
36
|
+
t.text :error
|
|
37
|
+
t.timestamps
|
|
38
|
+
end
|
|
39
|
+
add_index :shiplog_newsletter_deliveries, [ :announcement_id, :recipient_key ], unique: true, name: "index_shiplog_newsletter_deliveries_on_announcement_and_key"
|
|
40
|
+
add_index :shiplog_newsletter_deliveries, [ :announcement_id, :status ], name: "index_shiplog_newsletter_deliveries_on_announcement_and_status"
|
|
41
|
+
add_index :shiplog_newsletter_deliveries, :recipient_key
|
|
42
|
+
add_index :shiplog_newsletter_deliveries, :created_at
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -68,7 +68,20 @@ module Shiplog
|
|
|
68
68
|
area_link: "rounded-full border border-gray-300 px-3 py-0.5 text-sm hover:border-indigo-500 dark:border-gray-700 [&.shiplog-areas__link--current]:border-indigo-600 [&.shiplog-areas__link--current]:bg-indigo-600 [&.shiplog-areas__link--current]:text-white",
|
|
69
69
|
entry_area: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
|
|
70
70
|
entry_images: "mt-3 grid w-full gap-3",
|
|
71
|
-
entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800"
|
|
71
|
+
entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800",
|
|
72
|
+
announcement: "rounded-xl border border-gray-200 border-l-4 border-l-indigo-600 bg-white p-5 dark:border-gray-800 dark:border-l-indigo-500 dark:bg-gray-900",
|
|
73
|
+
announcement_title: "mt-2 mb-4 text-2xl font-semibold text-gray-900 dark:text-white",
|
|
74
|
+
announcement_body: "leading-relaxed text-gray-700 dark:text-gray-300 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-indigo-600 [&_a]:underline dark:[&_a]:text-indigo-400 [&_p]:my-2",
|
|
75
|
+
month_announcement: "-mt-1 mb-3",
|
|
76
|
+
month_announcement_link: "text-sm font-semibold text-indigo-600 hover:underline dark:text-indigo-400",
|
|
77
|
+
newsletter: "my-10 rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-800 dark:bg-gray-900/50",
|
|
78
|
+
newsletter_title: "text-lg font-semibold",
|
|
79
|
+
newsletter_hint: "mt-1 mb-3 text-sm text-gray-500 dark:text-gray-400",
|
|
80
|
+
newsletter_form: "flex flex-wrap items-center gap-2",
|
|
81
|
+
newsletter_label: "text-sm font-medium",
|
|
82
|
+
newsletter_input: "min-w-0 flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-900",
|
|
83
|
+
newsletter_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 [&.shiplog-newsletter__button--secondary]:bg-transparent [&.shiplog-newsletter__button--secondary]:text-gray-700 [&.shiplog-newsletter__button--secondary]:ring-1 [&.shiplog-newsletter__button--secondary]:ring-gray-300 dark:[&.shiplog-newsletter__button--secondary]:text-gray-300",
|
|
84
|
+
newsletter_notice: "mb-3 text-sm [&.shiplog-newsletter__notice--error]:text-red-600"
|
|
72
85
|
}.freeze
|
|
73
86
|
|
|
74
87
|
def copy_initializer
|
|
@@ -174,6 +174,56 @@ Shiplog.configure do |config|
|
|
|
174
174
|
# decide which URLs set the locale (e.g. a /es scope). The default locale is dropped from the list.
|
|
175
175
|
# config.locales = [] # e.g. [:es]
|
|
176
176
|
|
|
177
|
+
# Monthly announcements: a short summary of what shipped in a month, drafted by config.rewriter from the month's
|
|
178
|
+
# approved entries and the small changes that got none, and approved in the admin like any entry. Schedule
|
|
179
|
+
# Shiplog::DraftAnnouncementJob early each month (it drafts the previous month), or use the inbox's
|
|
180
|
+
# "Draft announcement" buttons. Where it is published:
|
|
181
|
+
# :page (default) Shiplog renders /changelog/2026-10, links it from the month's heading and adds it to the feed
|
|
182
|
+
# :external your app publishes it (on_announcement_approved below); the month heading links to announcement_url
|
|
183
|
+
# false no announcements
|
|
184
|
+
# config.announcements = :page
|
|
185
|
+
# config.announcement_url = ->(announcement, locale) { main_app.news_path(announcement.slug) }
|
|
186
|
+
|
|
187
|
+
# Called with the announcement once it is approved and translated into every configured locale, once per approval
|
|
188
|
+
# (also sent as the `announcement_approved.shiplog` notification). The announcement offers display_title(locale),
|
|
189
|
+
# to_markdown(locale), to_html(locale), to_text(locale) and public_url, to publish it elsewhere or email it.
|
|
190
|
+
# config.on_announcement_approved = ->(announcement) { NewsletterJob.perform_later(announcement) }
|
|
191
|
+
|
|
192
|
+
# Newsletter: the approved announcement emailed, in each recipient's language, to your users who opted in and to
|
|
193
|
+
# the visitors who subscribed through Shiplog's form (double opt-in, stored in shiplog_newsletter_subscribers).
|
|
194
|
+
# Sending is an explicit admin action on the announcement's page, once per recipient. Needs newsletter_from,
|
|
195
|
+
# newsletter_address and canonical_url. Schedule Shiplog::PurgeNewsletterJob weekly (unconfirmed addresses).
|
|
196
|
+
# config.newsletter = false
|
|
197
|
+
|
|
198
|
+
# Your users who opted in: a callable returning objects (or a relation, read in batches) responding to #email,
|
|
199
|
+
# #locale and #id. Shiplog never stores them. Resolve the locale yourself (the user's, then a default).
|
|
200
|
+
# config.newsletter_recipients = -> { User.where(newsletter: true) }
|
|
201
|
+
|
|
202
|
+
# Your own unsubscribe or settings page for those users, linked in the footer as "Preferences". The one-click
|
|
203
|
+
# unsubscribe of mail providers always comes to Shiplog, which calls on_newsletter_unsubscribe below.
|
|
204
|
+
# config.newsletter_unsubscribe_url = ->(user) { Rails.application.routes.url_helpers.settings_url(host: "example.com") }
|
|
205
|
+
|
|
206
|
+
# Called with the lowercased email on every unsubscribe (one-click, the footer link, the preferences page), so your
|
|
207
|
+
# own setting follows: Shiplog suppresses the address itself either way, and skips it at send time whatever your
|
|
208
|
+
# list says. When a user opts in again in your app, call Shiplog::Newsletter.resubscribe(user.email) to lift the
|
|
209
|
+
# suppression. Errors are logged, never shown to the visitor.
|
|
210
|
+
# config.on_newsletter_unsubscribe = ->(email) { User.where("lower(email) = ?", email).update_all(newsletter: false) }
|
|
211
|
+
|
|
212
|
+
# The sender (also of the confirmation email) and the postal address in the footer (CAN-SPAM requires one).
|
|
213
|
+
# config.newsletter_from = "Acme <updates@example.com>"
|
|
214
|
+
# config.newsletter_address = "Acme Inc., 123 Main Street, Springfield"
|
|
215
|
+
|
|
216
|
+
# Branding: the logo (an http(s) URL, shown 140px wide) and the accent colour of buttons and links.
|
|
217
|
+
# config.newsletter_logo_url = "https://example.com/logo.png"
|
|
218
|
+
# config.newsletter_accent = "#0969da"
|
|
219
|
+
|
|
220
|
+
# Merged into every newsletter message (not the confirmation email): ActionMailer options or headers your
|
|
221
|
+
# delivery gem reads. With Postmark, a broadcast stream keeps complaints away from your transactional email.
|
|
222
|
+
# config.newsletter_mailer_options = { message_stream: "broadcast" }
|
|
223
|
+
|
|
224
|
+
# How many recipients a run claims and emails at a time (Postmark's batch limit by default).
|
|
225
|
+
# config.newsletter_batch_size = 500
|
|
226
|
+
|
|
177
227
|
# Callables above cannot be part of the public pages' ETag. Bump this when the output of a callable you
|
|
178
228
|
# configured changes (a new label, another path) so cached pages are refreshed.
|
|
179
229
|
# config.cache_version = nil
|
data/lib/shiplog/classes.rb
CHANGED
|
@@ -58,7 +58,20 @@ module Shiplog
|
|
|
58
58
|
area_link: "shiplog-areas__link",
|
|
59
59
|
entry_area: "shiplog-entry__area",
|
|
60
60
|
entry_images: "shiplog-entry__images",
|
|
61
|
-
entry_image: "shiplog-entry__image"
|
|
61
|
+
entry_image: "shiplog-entry__image",
|
|
62
|
+
announcement: "shiplog-announcement",
|
|
63
|
+
announcement_title: "shiplog-announcement__title",
|
|
64
|
+
announcement_body: "shiplog-announcement__body",
|
|
65
|
+
month_announcement: "shiplog-month__announcement",
|
|
66
|
+
month_announcement_link: "shiplog-month__announcement-link",
|
|
67
|
+
newsletter: "shiplog-newsletter",
|
|
68
|
+
newsletter_title: "shiplog-newsletter__title",
|
|
69
|
+
newsletter_hint: "shiplog-newsletter__hint",
|
|
70
|
+
newsletter_form: "shiplog-newsletter__form",
|
|
71
|
+
newsletter_label: "shiplog-newsletter__label",
|
|
72
|
+
newsletter_input: "shiplog-newsletter__input",
|
|
73
|
+
newsletter_button: "shiplog-newsletter__button",
|
|
74
|
+
newsletter_notice: "shiplog-newsletter__notice"
|
|
62
75
|
}.freeze
|
|
63
76
|
|
|
64
77
|
KEYS = BEM.keys.freeze
|
|
@@ -8,6 +8,7 @@ module Shiplog
|
|
|
8
8
|
:roadmap_path
|
|
9
9
|
|
|
10
10
|
ADMIN_CHROMES = %i[standalone hosted].freeze
|
|
11
|
+
ANNOUNCEMENT_MODES = [ :page, :external, false ].freeze
|
|
11
12
|
# Segments of only dots ("." and "..") are path traversal, not names.
|
|
12
13
|
REPOSITORY_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
|
|
13
14
|
REPOSITORY_OPTION_KEYS = %i[name changelog gem].freeze
|
|
@@ -46,8 +47,9 @@ module Shiplog
|
|
|
46
47
|
# current), on every visit, and may write: keep it cheap, e.g. write only when the stored time is older than
|
|
47
48
|
# the newest entry. Nil by default. Not part of the ETag: it doesn't change the markup.
|
|
48
49
|
attr_reader :on_changelog_seen
|
|
49
|
-
# Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path
|
|
50
|
-
# callables cannot be part of the ETag, so this is how cached public pages learn about it.
|
|
50
|
+
# Bump it when the output of a callable you configured changes (extra_pages, repository_labels, feedback_path,
|
|
51
|
+
# og_image): callables cannot be part of the ETag, so this is how cached public pages learn about it. Also
|
|
52
|
+
# bump it after changing the layout's head or a shiplog/head/* partial: ETags don't cover layouts.
|
|
51
53
|
attr_accessor :cache_version
|
|
52
54
|
attr_reader :extra_pages, :repository_labels, :roadmap_shipped_count
|
|
53
55
|
# How many product areas the AI may create (default 8). Past it, entries can only join existing areas.
|
|
@@ -58,6 +60,44 @@ module Shiplog
|
|
|
58
60
|
attr_reader :images, :max_images_per_entry
|
|
59
61
|
LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
|
|
60
62
|
|
|
63
|
+
# Monthly announcements (see Shiplog::Announcements). `announcements` is where they are published:
|
|
64
|
+
# :page (default) Shiplog renders /changelog/2026-10, links it from the changelog's month heading and
|
|
65
|
+
# puts it in the Atom feed;
|
|
66
|
+
# :external no Shiplog page: the month heading links to `announcement_url`, and the host publishes the text
|
|
67
|
+
# itself (on_announcement_approved);
|
|
68
|
+
# false no announcements at all: nothing is drafted, rendered or announced.
|
|
69
|
+
attr_reader :announcements
|
|
70
|
+
# Where an announcement is published when `announcements` is :external: a callable
|
|
71
|
+
# `->(announcement, locale) { url }` evaluated in the view (instance_exec, so `main_app` route helpers work).
|
|
72
|
+
# A nil or unsafe result renders no link. Not part of the ETag: bump cache_version when its output changes.
|
|
73
|
+
attr_reader :announcement_url
|
|
74
|
+
# Called with the announcement (a Shiplog::Entry) once it is approved and every configured locale has a current
|
|
75
|
+
# translation, from Shiplog::AnnounceJob. Once per approval: an announcement regenerated and approved again
|
|
76
|
+
# is announced again. An `announcement_approved.shiplog` ActiveSupport notification is sent at the same time.
|
|
77
|
+
attr_reader :on_announcement_approved
|
|
78
|
+
|
|
79
|
+
# The newsletter (see Shiplog::Newsletter): the approved announcement emailed to the host's users who opted in
|
|
80
|
+
# and to the visitors who subscribed through Shiplog's form. Off by default, and nothing without announcements.
|
|
81
|
+
attr_reader :newsletter
|
|
82
|
+
# A callable returning the host's recipients, each responding to #email, #locale and #id (an Active Record
|
|
83
|
+
# relation is read in batches with find_each). Shiplog never stores them.
|
|
84
|
+
attr_reader :newsletter_recipients
|
|
85
|
+
# `->(recipient) { url }`: the host's own unsubscribe or settings page, linked in the footer for its users. The
|
|
86
|
+
# one-click unsubscribe (List-Unsubscribe-Post) always goes through Shiplog's endpoint, which calls
|
|
87
|
+
# on_newsletter_unsubscribe. Without it the footer links Shiplog's unsubscribe page.
|
|
88
|
+
attr_reader :newsletter_unsubscribe_url
|
|
89
|
+
# `->(email) { ... }`, called on every unsubscribe (one-click, the footer link or the preferences page), so the
|
|
90
|
+
# host turns its own user's setting off. Errors are logged, never shown to the visitor.
|
|
91
|
+
attr_reader :on_newsletter_unsubscribe
|
|
92
|
+
# The sender, e.g. "Acme <updates@acme.com>" (also used by the confirmation email); the postal address the
|
|
93
|
+
# footer carries (CAN-SPAM requires one); the logo (an http(s) URL) and the accent colour ("#rrggbb") of the
|
|
94
|
+
# email; extra ActionMailer options for every newsletter message, e.g. { message_stream: "broadcast" } for a
|
|
95
|
+
# Postmark broadcast stream; how many recipients a job claims at a time (default 500).
|
|
96
|
+
attr_reader :newsletter_from, :newsletter_address, :newsletter_logo_url, :newsletter_accent,
|
|
97
|
+
:newsletter_mailer_options, :newsletter_batch_size
|
|
98
|
+
ACCENT_FORMAT = /\A#(?:\h{3}|\h{6})\z/
|
|
99
|
+
MAX_NEWSLETTER_BATCH_SIZE = 10_000
|
|
100
|
+
|
|
61
101
|
EXTRA_PAGE_KEYS = %i[label path].freeze
|
|
62
102
|
|
|
63
103
|
# A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
|
|
@@ -77,7 +117,8 @@ module Shiplog
|
|
|
77
117
|
stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
|
|
78
118
|
extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
|
|
79
119
|
repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
|
|
80
|
-
stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
|
|
120
|
+
stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image),
|
|
121
|
+
announcements.to_s, stable.(announcement_url), newsletter
|
|
81
122
|
]
|
|
82
123
|
Digest::SHA256.hexdigest(JSON.generate(data))
|
|
83
124
|
end
|
|
@@ -168,11 +209,90 @@ module Shiplog
|
|
|
168
209
|
@og_image = value
|
|
169
210
|
end
|
|
170
211
|
|
|
212
|
+
def announcements=(value)
|
|
213
|
+
mode = value.is_a?(String) ? value.to_sym : value
|
|
214
|
+
unless ANNOUNCEMENT_MODES.include?(mode)
|
|
215
|
+
raise ArgumentError, "announcements must be :page, :external or false, got #{value.inspect}"
|
|
216
|
+
end
|
|
217
|
+
@announcements = mode
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def announcement_url=(value)
|
|
221
|
+
raise ArgumentError, "announcement_url must be nil or a callable like ->(announcement, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
222
|
+
@announcement_url = value
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def on_announcement_approved=(value)
|
|
226
|
+
raise ArgumentError, "on_announcement_approved must be nil or a callable like ->(announcement) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
227
|
+
@on_announcement_approved = value
|
|
228
|
+
end
|
|
229
|
+
|
|
171
230
|
def on_changelog_seen=(value)
|
|
172
231
|
raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
173
232
|
@on_changelog_seen = value
|
|
174
233
|
end
|
|
175
234
|
|
|
235
|
+
def newsletter=(value)
|
|
236
|
+
raise ArgumentError, "newsletter must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
|
|
237
|
+
@newsletter = value
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def newsletter_recipients=(value)
|
|
241
|
+
raise ArgumentError, "newsletter_recipients must be nil or a callable like -> { User.where(newsletter: true) }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
242
|
+
@newsletter_recipients = value
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def newsletter_unsubscribe_url=(value)
|
|
246
|
+
raise ArgumentError, "newsletter_unsubscribe_url must be nil or a callable like ->(recipient) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
247
|
+
@newsletter_unsubscribe_url = value
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def on_newsletter_unsubscribe=(value)
|
|
251
|
+
raise ArgumentError, "on_newsletter_unsubscribe must be nil or a callable like ->(email) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
252
|
+
@on_newsletter_unsubscribe = value
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def newsletter_from=(value)
|
|
256
|
+
@newsletter_from = optional_string!(:newsletter_from, value, 'like "Acme <updates@acme.com>"')
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def newsletter_address=(value)
|
|
260
|
+
@newsletter_address = optional_string!(:newsletter_address, value, "(the sender's postal address)")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def newsletter_logo_url=(value)
|
|
264
|
+
url = optional_string!(:newsletter_logo_url, value, "with an http(s) URL")
|
|
265
|
+
raise ArgumentError, "newsletter_logo_url must be an http(s) URL, got #{value.inspect}" if url && !url.match?(%r{\Ahttps?://\S+\z}i)
|
|
266
|
+
@newsletter_logo_url = url
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def newsletter_accent=(value)
|
|
270
|
+
colour = optional_string!(:newsletter_accent, value, 'like "#0969da"')
|
|
271
|
+
raise ArgumentError, "newsletter_accent must be a hex colour like \"#0969da\", got #{value.inspect}" if colour && !ACCENT_FORMAT.match?(colour)
|
|
272
|
+
@newsletter_accent = colour&.downcase
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Merged into every newsletter message's `mail(...)` call: ActionMailer options (delivery_method,
|
|
276
|
+
# delivery_method_options) or headers your delivery gem reads (Postmark's message_stream). Keys that would
|
|
277
|
+
# change who receives the message or what it says are refused.
|
|
278
|
+
RESERVED_MAILER_OPTIONS = %i[to cc bcc from reply_to sender return_path subject body template_name template_path parts_order content_type charset mime_version date].freeze
|
|
279
|
+
|
|
280
|
+
def newsletter_mailer_options=(value)
|
|
281
|
+
options = value.nil? ? {} : value
|
|
282
|
+
raise ArgumentError, "newsletter_mailer_options must be a Hash like { message_stream: \"broadcast\" }, got #{value.inspect}" unless options.is_a?(Hash)
|
|
283
|
+
options = options.transform_keys { |key| key.to_s.to_sym }
|
|
284
|
+
reserved = options.keys & RESERVED_MAILER_OPTIONS
|
|
285
|
+
raise ArgumentError, "newsletter_mailer_options cannot set #{reserved.map(&:inspect).join(", ")}: the recipient, sender and content of a newsletter are Shiplog's" if reserved.any?
|
|
286
|
+
@newsletter_mailer_options = options.freeze
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def newsletter_batch_size=(value)
|
|
290
|
+
unless value.is_a?(Integer) && value.between?(1, MAX_NEWSLETTER_BATCH_SIZE)
|
|
291
|
+
raise ArgumentError, "newsletter_batch_size must be an Integer between 1 and #{MAX_NEWSLETTER_BATCH_SIZE}, got #{value.inspect}"
|
|
292
|
+
end
|
|
293
|
+
@newsletter_batch_size = value
|
|
294
|
+
end
|
|
295
|
+
|
|
176
296
|
# :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
|
|
177
297
|
# layout of config.admin_layout (compact sub-nav, no page chrome of its own).
|
|
178
298
|
def admin_chrome=(value)
|
|
@@ -244,6 +364,10 @@ module Shiplog
|
|
|
244
364
|
@images = true
|
|
245
365
|
@max_images_per_entry = 4
|
|
246
366
|
@locales = [].freeze
|
|
367
|
+
@announcements = :page
|
|
368
|
+
@newsletter = false
|
|
369
|
+
@newsletter_mailer_options = {}.freeze
|
|
370
|
+
@newsletter_batch_size = 500
|
|
247
371
|
end
|
|
248
372
|
|
|
249
373
|
def resolved_github_token
|
|
@@ -282,6 +406,14 @@ module Shiplog
|
|
|
282
406
|
|
|
283
407
|
private
|
|
284
408
|
|
|
409
|
+
# Nil (also for a blank String) or a stripped non-empty String; anything else raises.
|
|
410
|
+
def optional_string!(setting, value, hint)
|
|
411
|
+
return if value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
412
|
+
raise ArgumentError, "#{setting} must be nil or a String #{hint}, got #{value.inspect}" unless value.is_a?(String)
|
|
413
|
+
|
|
414
|
+
value.strip
|
|
415
|
+
end
|
|
416
|
+
|
|
285
417
|
def normalize_canonical_url(value)
|
|
286
418
|
return if value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
287
419
|
raise ArgumentError, "canonical_url must be a String like \"https://example.com\", got #{value.inspect}" unless value.is_a?(String)
|
data/lib/shiplog/engine.rb
CHANGED
|
@@ -9,5 +9,7 @@ module Shiplog
|
|
|
9
9
|
|
|
10
10
|
# Once per boot, after the host's initializers have set config.images: say why screenshots are off.
|
|
11
11
|
config.after_initialize { Shiplog.log_images_status }
|
|
12
|
+
# And why the newsletter, when on, cannot be sent yet.
|
|
13
|
+
config.after_initialize { Shiplog.log_newsletter_status }
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
module Shiplog
|
|
2
|
+
# Constants of shiplog_head_tags. Not included anywhere, so views of the host cannot see them.
|
|
3
|
+
module HeadTags
|
|
4
|
+
EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
|
|
5
|
+
end
|
|
6
|
+
|
|
2
7
|
# The one helper added to every view of the host (like a pagination gem's `paginate`), so a layout shared
|
|
3
8
|
# with the host's own pages can call `shiplog_head_tags`. The rest lives in Shiplog::HeadHelper, which only
|
|
4
9
|
# the engine's controllers include.
|
|
10
|
+
#
|
|
11
|
+
# This file is required by lib/shiplog.rb rather than autoloaded: the engine includes the module into
|
|
12
|
+
# ActionView::Base from an on_load hook, which can run during boot before the autoloaders are set up, and
|
|
13
|
+
# ActionView::Base never reloads, so the module must not either.
|
|
5
14
|
module HeadTagsHelper
|
|
6
|
-
EXCEPT_KEYS = %i[description canonical robots open_graph twitter feed structured_data].freeze
|
|
7
|
-
|
|
8
15
|
# The head tags of the current Shiplog page; an empty string on any other page. `except:` leaves groups
|
|
9
16
|
# out, for tags your layout already writes: :description, :canonical, :robots, :open_graph, :twitter,
|
|
10
17
|
# :feed, :structured_data. An unknown key raises ArgumentError.
|
|
11
18
|
#
|
|
19
|
+
# Call it in the layout, not inside a component (ViewComponent, Phlex): there it gives an empty string.
|
|
12
20
|
# Use it OR the older `content_for :shiplog_*` blocks in a layout, not both: both would emit the tags twice.
|
|
13
21
|
def shiplog_head_tags(except: [])
|
|
14
22
|
keys = Array(except).map { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
15
|
-
unknown = keys - EXCEPT_KEYS
|
|
23
|
+
unknown = keys - HeadTags::EXCEPT_KEYS
|
|
16
24
|
if unknown.any?
|
|
17
|
-
raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{EXCEPT_KEYS.map(&:inspect).join(", ")}"
|
|
25
|
+
raise ArgumentError, "Unknown shiplog_head_tags except: key(s) #{unknown.map(&:inspect).join(", ")}. Valid keys: #{HeadTags::EXCEPT_KEYS.map(&:inspect).join(", ")}"
|
|
18
26
|
end
|
|
19
|
-
|
|
27
|
+
# A marker, not is_a?(Shiplog::PublicController): that would autoload the controller (and the host's
|
|
28
|
+
# parent controller) on every host page. Views that don't have the engine's helper (components) skip too.
|
|
29
|
+
return "".html_safe unless controller.respond_to?(:shiplog_public_page?) && respond_to?(:shiplog_render_head_tags, true)
|
|
20
30
|
|
|
21
31
|
shiplog_render_head_tags(keys)
|
|
22
32
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Previews of the newsletter's emails, for hosts iterating on the design locally. Add the directory to your
|
|
3
|
+
# preview paths (config/environments/development.rb):
|
|
4
|
+
#
|
|
5
|
+
# config.action_mailer.preview_paths << Shiplog::Engine.root.join("lib/shiplog/previews").to_s
|
|
6
|
+
#
|
|
7
|
+
# then open /rails/mailers/shiplog/newsletter_mailer. The announcement is the newest approved one (with the
|
|
8
|
+
# month's real feature highlights), or a sample with sample highlights when there is none (no database rows
|
|
9
|
+
# needed); ?locale=es previews a configured locale.
|
|
10
|
+
class NewsletterMailerPreview < ActionMailer::Preview
|
|
11
|
+
SAMPLE_BODY = <<~MARKDOWN.freeze
|
|
12
|
+
This month we made imports faster and added a dark theme. Here is what shipped.
|
|
13
|
+
|
|
14
|
+
## Highlights
|
|
15
|
+
|
|
16
|
+
### Dark mode
|
|
17
|
+
|
|
18
|
+
Switch to a dark theme from your settings. Easier on the eyes at night.
|
|
19
|
+
|
|
20
|
+
### Faster imports
|
|
21
|
+
|
|
22
|
+
Spreadsheets with thousands of rows now import in seconds.
|
|
23
|
+
|
|
24
|
+
## Also improved
|
|
25
|
+
|
|
26
|
+
### Billing
|
|
27
|
+
|
|
28
|
+
- Invoices show the tax breakdown.
|
|
29
|
+
- Refunds appear on the next statement.
|
|
30
|
+
|
|
31
|
+
### Exports
|
|
32
|
+
|
|
33
|
+
- CSV files keep leading zeros.
|
|
34
|
+
MARKDOWN
|
|
35
|
+
|
|
36
|
+
SAMPLE_HIGHLIGHTS = [
|
|
37
|
+
{ title: "Dark mode", summary: "Switch to a dark theme from your settings, easier on the eyes at night.", slug: "dark-mode",
|
|
38
|
+
image_url: "https://placehold.co/1200x700/png?text=Dark+mode", image_width: 1200, image_height: 700 },
|
|
39
|
+
{ title: "Faster imports", summary: "Spreadsheets with thousands of rows now import in seconds.", slug: "faster-imports",
|
|
40
|
+
image_url: "https://placehold.co/1200x700/png?text=Faster+imports", image_width: 1200, image_height: 700 },
|
|
41
|
+
{ title: "Shared views", summary: "Save a filtered view and share it with your team.", slug: "shared-views", image_url: nil }
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
def announcement
|
|
45
|
+
entry = Entry.published_announcements.order(released_on: :desc).first
|
|
46
|
+
locale = (params["locale"] || params[:locale]).presence || I18n.default_locale
|
|
47
|
+
email = "preview@example.com"
|
|
48
|
+
content = entry ? nil : Newsletter::Content.new(sample_announcement, locale, highlights: sample_highlights)
|
|
49
|
+
NewsletterMailer.announcement(entry || content.entry, email, locale:, content:, unsubscribe_url: Newsletter.unsubscribe_url(email),
|
|
50
|
+
preferences_url: Newsletter.preferences_url(sample_subscriber))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def confirmation
|
|
54
|
+
NewsletterMailer.confirmation(sample_subscriber)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def sample_announcement
|
|
60
|
+
month = Date.current.prev_month.beginning_of_month
|
|
61
|
+
Entry.new(kind: "announcement", status: "approved", title: Period.month_name(month), body: SAMPLE_BODY,
|
|
62
|
+
released_on: month.end_of_month, slug: month.strftime("%Y-%m"), subject: Period.new(starts_on: month, ends_on: month.end_of_month))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Three cards, two with a (placeholder) screenshot, linking to sample permalinks under the email's origin.
|
|
66
|
+
def sample_highlights
|
|
67
|
+
SAMPLE_HIGHLIGHTS.map do |sample|
|
|
68
|
+
Newsletter::Content::Highlight.new(title: sample[:title], summary: sample[:summary], url: Newsletter.absolute_url(PublicLinks.path(:changelog_entry_path, sample[:slug])),
|
|
69
|
+
image_url: sample[:image_url], image_alt: sample[:title], image_width: sample[:image_width], image_height: sample[:image_height])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sample_subscriber
|
|
74
|
+
NewsletterSubscriber.new(email: "preview@example.com", locale: I18n.default_locale.to_s, token: "preview-token", confirmation_nonce: "preview-nonce")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/shiplog/version.rb
CHANGED
data/lib/shiplog.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "shiplog/configuration"
|
|
|
4
4
|
require "shiplog/github/errors"
|
|
5
5
|
require "shiplog/github/queries"
|
|
6
6
|
require "shiplog/github/client"
|
|
7
|
+
require "shiplog/head_tags_helper"
|
|
7
8
|
require "shiplog/engine"
|
|
8
9
|
require "shiplog/admin/engine"
|
|
9
10
|
|
|
@@ -17,6 +18,38 @@ module Shiplog
|
|
|
17
18
|
# at 99. Counted on approved_at (when the entry was approved), not released_on (the list's order). The host supplies the timestamp (Shiplog doesn't know its users): see config.on_changelog_seen.
|
|
18
19
|
def unseen_count(since: nil) = Entry.unseen_count(since:)
|
|
19
20
|
|
|
21
|
+
# The number of approved monthly announcements approved after `since` (all of them while it is nil), capped
|
|
22
|
+
# at 99: the rare "what's new" signal, where unseen_count counts every daily entry. 0 while announcements are off.
|
|
23
|
+
def unseen_announcements_count(since: nil) = announcements? ? Entry.unseen_announcements_count(since:) : 0
|
|
24
|
+
|
|
25
|
+
# Whether monthly announcements are on (config.announcements is :page or :external).
|
|
26
|
+
def announcements? = config.announcements != false
|
|
27
|
+
|
|
28
|
+
# Whether Shiplog renders announcement pages (config.announcements is :page).
|
|
29
|
+
def announcement_pages? = config.announcements == :page
|
|
30
|
+
|
|
31
|
+
# Whether the newsletter is on: config.newsletter, and announcements (there is nothing else to send).
|
|
32
|
+
def newsletter? = config.newsletter && announcements?
|
|
33
|
+
|
|
34
|
+
# Logged once at boot (see Shiplog::Engine), while the newsletter is on: what keeps it from sending, a
|
|
35
|
+
# recipients callable without the unsubscribe hook (unsubscribes would not reach the host's list), and a
|
|
36
|
+
# cache that counts nothing (the subscribe form's rate limits are then off).
|
|
37
|
+
def log_newsletter_status
|
|
38
|
+
return unless newsletter?
|
|
39
|
+
|
|
40
|
+
missing = []
|
|
41
|
+
missing << "newsletter_from" if Newsletter.from.blank?
|
|
42
|
+
missing << "newsletter_address" if config.newsletter_address.blank?
|
|
43
|
+
missing << "canonical_url (or action_mailer.default_url_options)" if Newsletter.base_url.blank?
|
|
44
|
+
Rails.logger&.warn("[shiplog] config.newsletter is on, but the newsletter cannot be sent until these are set: #{missing.join(", ")}.") if missing.any?
|
|
45
|
+
if config.newsletter_recipients && config.on_newsletter_unsubscribe.nil?
|
|
46
|
+
Rails.logger&.warn("[shiplog] config.newsletter_recipients is set but config.on_newsletter_unsubscribe is not: Shiplog suppresses an address that unsubscribes, but your own list will not know.")
|
|
47
|
+
end
|
|
48
|
+
if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
49
|
+
Rails.logger&.warn("[shiplog] Rails.cache is a null store, so the newsletter's subscribe form is not rate limited. Configure a cache store.")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
20
53
|
ACTIVE_STORAGE_TABLES = %w[active_storage_attachments active_storage_blobs].freeze
|
|
21
54
|
|
|
22
55
|
# Whether the host loaded Active Storage (its engine and the model macros). The declaration on Entry needs
|
data/lib/tasks/shiplog.rake
CHANGED
|
@@ -8,6 +8,28 @@ unless Rake::Task.task_defined?("shiplog:backfill_slugs")
|
|
|
8
8
|
puts "Shiplog: gave #{count} #{count == 1 ? "entry" : "entries"} a slug."
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
desc "Draft the announcement of a month (MONTH=2026-10; the previous month by default) and queue its rewrite"
|
|
12
|
+
task draft_announcement: :environment do
|
|
13
|
+
month = ENV["MONTH"].present? ? Date.strptime(ENV["MONTH"], "%Y-%m") : Date.current.prev_month
|
|
14
|
+
entry = Shiplog::Announcements.draft(month:)
|
|
15
|
+
label = month.strftime("%Y-%m")
|
|
16
|
+
if entry
|
|
17
|
+
puts "Shiplog: drafted the #{label} announcement (entry #{entry.id})."
|
|
18
|
+
elsif !Shiplog.announcements?
|
|
19
|
+
puts "Shiplog: config.announcements is false, nothing drafted."
|
|
20
|
+
else
|
|
21
|
+
puts "Shiplog: nothing drafted for #{label}: it has an announcement already, or no approved feature or change."
|
|
22
|
+
end
|
|
23
|
+
rescue Date::Error
|
|
24
|
+
abort "Shiplog: MONTH must look like 2026-10, got #{ENV["MONTH"].inspect}."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Delete the newsletter subscribers who never confirmed within 30 days and the delivery rows older than 180 days (what Shiplog::PurgeNewsletterJob does)"
|
|
28
|
+
task purge_newsletter: :environment do
|
|
29
|
+
counts = Shiplog::PurgeNewsletterJob.perform_now
|
|
30
|
+
puts "Shiplog: purged #{counts[:subscribers]} unconfirmed newsletter #{counts[:subscribers] == 1 ? "subscriber" : "subscribers"} and #{counts[:deliveries]} old delivery #{counts[:deliveries] == 1 ? "row" : "rows"}."
|
|
31
|
+
end
|
|
32
|
+
|
|
11
33
|
desc "Translate approved entries and area names that have no current translation into config.locales (entries approved before 0.3.0 get none). BATCH_SIZE=100"
|
|
12
34
|
task translate_approved: :environment do
|
|
13
35
|
if Shiplog.config.locales.empty?
|