shiplog 0.3.2 → 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 +36 -0
- data/README.md +170 -2
- 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 +6 -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/newsletter_helper.rb +28 -0
- data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
- data/app/helpers/shiplog/permalink_helper.rb +20 -2
- 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 +132 -1
- data/lib/shiplog/engine.rb +2 -0
- data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +32 -0
- data/lib/tasks/shiplog.rake +22 -0
- metadata +56 -1
|
@@ -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
|
|
@@ -59,6 +60,44 @@ module Shiplog
|
|
|
59
60
|
attr_reader :images, :max_images_per_entry
|
|
60
61
|
LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
|
|
61
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
|
+
|
|
62
101
|
EXTRA_PAGE_KEYS = %i[label path].freeze
|
|
63
102
|
|
|
64
103
|
# A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
|
|
@@ -78,7 +117,8 @@ module Shiplog
|
|
|
78
117
|
stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
|
|
79
118
|
extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
|
|
80
119
|
repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
|
|
81
|
-
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
|
|
82
122
|
]
|
|
83
123
|
Digest::SHA256.hexdigest(JSON.generate(data))
|
|
84
124
|
end
|
|
@@ -169,11 +209,90 @@ module Shiplog
|
|
|
169
209
|
@og_image = value
|
|
170
210
|
end
|
|
171
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
|
+
|
|
172
230
|
def on_changelog_seen=(value)
|
|
173
231
|
raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
|
|
174
232
|
@on_changelog_seen = value
|
|
175
233
|
end
|
|
176
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
|
+
|
|
177
296
|
# :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
|
|
178
297
|
# layout of config.admin_layout (compact sub-nav, no page chrome of its own).
|
|
179
298
|
def admin_chrome=(value)
|
|
@@ -245,6 +364,10 @@ module Shiplog
|
|
|
245
364
|
@images = true
|
|
246
365
|
@max_images_per_entry = 4
|
|
247
366
|
@locales = [].freeze
|
|
367
|
+
@announcements = :page
|
|
368
|
+
@newsletter = false
|
|
369
|
+
@newsletter_mailer_options = {}.freeze
|
|
370
|
+
@newsletter_batch_size = 500
|
|
248
371
|
end
|
|
249
372
|
|
|
250
373
|
def resolved_github_token
|
|
@@ -283,6 +406,14 @@ module Shiplog
|
|
|
283
406
|
|
|
284
407
|
private
|
|
285
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
|
+
|
|
286
417
|
def normalize_canonical_url(value)
|
|
287
418
|
return if value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
288
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
|
|
@@ -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
|
@@ -18,6 +18,38 @@ module Shiplog
|
|
|
18
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.
|
|
19
19
|
def unseen_count(since: nil) = Entry.unseen_count(since:)
|
|
20
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
|
+
|
|
21
53
|
ACTIVE_STORAGE_TABLES = %w[active_storage_attachments active_storage_blobs].freeze
|
|
22
54
|
|
|
23
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?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shiplog
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Trackberry
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- CHANGELOG.md
|
|
40
40
|
- MIT-LICENSE
|
|
41
41
|
- README.md
|
|
42
|
+
- app/controllers/shiplog/admin/announcements_controller.rb
|
|
42
43
|
- app/controllers/shiplog/admin/application_controller.rb
|
|
43
44
|
- app/controllers/shiplog/admin/area_detections_controller.rb
|
|
44
45
|
- app/controllers/shiplog/admin/area_translations_controller.rb
|
|
@@ -46,27 +47,39 @@ files:
|
|
|
46
47
|
- app/controllers/shiplog/admin/areas/moves_controller.rb
|
|
47
48
|
- app/controllers/shiplog/admin/areas_controller.rb
|
|
48
49
|
- app/controllers/shiplog/admin/boards_controller.rb
|
|
50
|
+
- app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb
|
|
49
51
|
- app/controllers/shiplog/admin/entries/approvals_controller.rb
|
|
50
52
|
- app/controllers/shiplog/admin/entries/discards_controller.rb
|
|
53
|
+
- app/controllers/shiplog/admin/entries/exports_controller.rb
|
|
51
54
|
- app/controllers/shiplog/admin/entries/images/moves_controller.rb
|
|
52
55
|
- app/controllers/shiplog/admin/entries/images_controller.rb
|
|
56
|
+
- app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb
|
|
57
|
+
- app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb
|
|
58
|
+
- app/controllers/shiplog/admin/entries/newsletters_controller.rb
|
|
53
59
|
- app/controllers/shiplog/admin/entries/rewrites_controller.rb
|
|
54
60
|
- app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb
|
|
55
61
|
- app/controllers/shiplog/admin/entries/translations_controller.rb
|
|
56
62
|
- app/controllers/shiplog/admin/entries_controller.rb
|
|
57
63
|
- app/controllers/shiplog/admin/inbox_filters.rb
|
|
64
|
+
- app/controllers/shiplog/admin/newsletter_subscribers_controller.rb
|
|
58
65
|
- app/controllers/shiplog/admin/syncs_controller.rb
|
|
59
66
|
- app/controllers/shiplog/application_controller.rb
|
|
60
67
|
- app/controllers/shiplog/changelog_entries_controller.rb
|
|
61
68
|
- app/controllers/shiplog/changelog_feeds_controller.rb
|
|
62
69
|
- app/controllers/shiplog/changelogs_controller.rb
|
|
63
70
|
- app/controllers/shiplog/frontends_controller.rb
|
|
71
|
+
- app/controllers/shiplog/newsletter/confirmations_controller.rb
|
|
72
|
+
- app/controllers/shiplog/newsletter/preferences_controller.rb
|
|
73
|
+
- app/controllers/shiplog/newsletter/subscriptions_controller.rb
|
|
74
|
+
- app/controllers/shiplog/newsletter/unsubscriptions_controller.rb
|
|
75
|
+
- app/controllers/shiplog/newsletter_controller.rb
|
|
64
76
|
- app/controllers/shiplog/public_controller.rb
|
|
65
77
|
- app/controllers/shiplog/roadmaps_controller.rb
|
|
66
78
|
- app/controllers/shiplog/webhooks_controller.rb
|
|
67
79
|
- app/frontend/shiplog/admin.css
|
|
68
80
|
- app/frontend/shiplog/board.js
|
|
69
81
|
- app/frontend/shiplog/inbox.js
|
|
82
|
+
- app/frontend/shiplog/once.js
|
|
70
83
|
- app/frontend/shiplog/shiplog.css
|
|
71
84
|
- app/helpers/shiplog/admin/entries_helper.rb
|
|
72
85
|
- app/helpers/shiplog/application_helper.rb
|
|
@@ -74,20 +87,28 @@ files:
|
|
|
74
87
|
- app/helpers/shiplog/frontend_helper.rb
|
|
75
88
|
- app/helpers/shiplog/head_helper.rb
|
|
76
89
|
- app/helpers/shiplog/host_routes_helper.rb
|
|
90
|
+
- app/helpers/shiplog/newsletter_helper.rb
|
|
91
|
+
- app/helpers/shiplog/newsletter_mailer_helper.rb
|
|
77
92
|
- app/helpers/shiplog/permalink_helper.rb
|
|
78
93
|
- app/helpers/shiplog/roadmap_helper.rb
|
|
79
94
|
- app/helpers/shiplog/structured_data_helper.rb
|
|
80
95
|
- app/helpers/shiplog/styling_helper.rb
|
|
81
96
|
- app/helpers/shiplog/switcher_helper.rb
|
|
82
97
|
- app/helpers/shiplog/unseen_helper.rb
|
|
98
|
+
- app/jobs/shiplog/announce_job.rb
|
|
83
99
|
- app/jobs/shiplog/application_job.rb
|
|
84
100
|
- app/jobs/shiplog/detect_areas_job.rb
|
|
101
|
+
- app/jobs/shiplog/draft_announcement_job.rb
|
|
102
|
+
- app/jobs/shiplog/purge_newsletter_job.rb
|
|
85
103
|
- app/jobs/shiplog/refresh_job.rb
|
|
86
104
|
- app/jobs/shiplog/rewrite_entry_job.rb
|
|
87
105
|
- app/jobs/shiplog/scan_changes_job.rb
|
|
106
|
+
- app/jobs/shiplog/send_newsletter_job.rb
|
|
88
107
|
- app/jobs/shiplog/sync_job.rb
|
|
89
108
|
- app/jobs/shiplog/translate_area_job.rb
|
|
90
109
|
- app/jobs/shiplog/translate_entry_job.rb
|
|
110
|
+
- app/mailers/shiplog/newsletter_mailer.rb
|
|
111
|
+
- app/models/shiplog/announcements.rb
|
|
91
112
|
- app/models/shiplog/application_record.rb
|
|
92
113
|
- app/models/shiplog/area.rb
|
|
93
114
|
- app/models/shiplog/area_detector.rb
|
|
@@ -106,10 +127,21 @@ files:
|
|
|
106
127
|
- app/models/shiplog/issue.rb
|
|
107
128
|
- app/models/shiplog/issue_form.rb
|
|
108
129
|
- app/models/shiplog/issue_link.rb
|
|
130
|
+
- app/models/shiplog/markdown.rb
|
|
131
|
+
- app/models/shiplog/newsletter.rb
|
|
132
|
+
- app/models/shiplog/newsletter/content.rb
|
|
133
|
+
- app/models/shiplog/newsletter/csv.rb
|
|
134
|
+
- app/models/shiplog/newsletter/recipients.rb
|
|
135
|
+
- app/models/shiplog/newsletter/tokens.rb
|
|
136
|
+
- app/models/shiplog/newsletter_delivery.rb
|
|
137
|
+
- app/models/shiplog/newsletter_subscriber.rb
|
|
138
|
+
- app/models/shiplog/period.rb
|
|
139
|
+
- app/models/shiplog/public_links.rb
|
|
109
140
|
- app/models/shiplog/pull_request.rb
|
|
110
141
|
- app/models/shiplog/pull_request_upgrade.rb
|
|
111
142
|
- app/models/shiplog/repository.rb
|
|
112
143
|
- app/models/shiplog/rewriter.rb
|
|
144
|
+
- app/models/shiplog/rewriter/announcement_prompt.rb
|
|
113
145
|
- app/models/shiplog/rewriter/prompt.rb
|
|
114
146
|
- app/models/shiplog/roadmap.rb
|
|
115
147
|
- app/models/shiplog/roadmap/column_resolver.rb
|
|
@@ -139,13 +171,18 @@ files:
|
|
|
139
171
|
- app/views/shiplog/admin/areas/index.html.erb
|
|
140
172
|
- app/views/shiplog/admin/boards/_card.html.erb
|
|
141
173
|
- app/views/shiplog/admin/boards/show.html.erb
|
|
174
|
+
- app/views/shiplog/admin/entries/_announcement.html.erb
|
|
142
175
|
- app/views/shiplog/admin/entries/_badges.html.erb
|
|
143
176
|
- app/views/shiplog/admin/entries/_entry_row.html.erb
|
|
144
177
|
- app/views/shiplog/admin/entries/_images.html.erb
|
|
178
|
+
- app/views/shiplog/admin/entries/_newsletter.html.erb
|
|
145
179
|
- app/views/shiplog/admin/entries/_translations.html.erb
|
|
146
180
|
- app/views/shiplog/admin/entries/edit.html.erb
|
|
147
181
|
- app/views/shiplog/admin/entries/index.html.erb
|
|
182
|
+
- app/views/shiplog/admin/entries/newsletters/show.html.erb
|
|
183
|
+
- app/views/shiplog/admin/newsletter_subscribers/index.html.erb
|
|
148
184
|
- app/views/shiplog/admin/syncs/show.html.erb
|
|
185
|
+
- app/views/shiplog/changelog_entries/announcement.html.erb
|
|
149
186
|
- app/views/shiplog/changelog_entries/show.html.erb
|
|
150
187
|
- app/views/shiplog/changelog_feeds/show.atom.builder
|
|
151
188
|
- app/views/shiplog/changelogs/_areas.html.erb
|
|
@@ -158,6 +195,21 @@ files:
|
|
|
158
195
|
- app/views/shiplog/head/_meta.html.erb
|
|
159
196
|
- app/views/shiplog/head/_open_graph.html.erb
|
|
160
197
|
- app/views/shiplog/head/_structured_data.html.erb
|
|
198
|
+
- app/views/shiplog/newsletter/_form.html.erb
|
|
199
|
+
- app/views/shiplog/newsletter/_page.html.erb
|
|
200
|
+
- app/views/shiplog/newsletter/confirmations/create.html.erb
|
|
201
|
+
- app/views/shiplog/newsletter/confirmations/show.html.erb
|
|
202
|
+
- app/views/shiplog/newsletter/invalid.html.erb
|
|
203
|
+
- app/views/shiplog/newsletter/preferences/destroy.html.erb
|
|
204
|
+
- app/views/shiplog/newsletter/preferences/show.html.erb
|
|
205
|
+
- app/views/shiplog/newsletter/rate_limited.html.erb
|
|
206
|
+
- app/views/shiplog/newsletter/subscriptions/show.html.erb
|
|
207
|
+
- app/views/shiplog/newsletter/unsubscriptions/create.html.erb
|
|
208
|
+
- app/views/shiplog/newsletter/unsubscriptions/show.html.erb
|
|
209
|
+
- app/views/shiplog/newsletter_mailer/announcement.html.erb
|
|
210
|
+
- app/views/shiplog/newsletter_mailer/announcement.text.erb
|
|
211
|
+
- app/views/shiplog/newsletter_mailer/confirmation.html.erb
|
|
212
|
+
- app/views/shiplog/newsletter_mailer/confirmation.text.erb
|
|
161
213
|
- app/views/shiplog/roadmaps/_card.html.erb
|
|
162
214
|
- app/views/shiplog/roadmaps/_column.html.erb
|
|
163
215
|
- app/views/shiplog/roadmaps/_shipped.html.erb
|
|
@@ -169,6 +221,8 @@ files:
|
|
|
169
221
|
- db/migrate/20261026000001_create_shiplog_tables.rb
|
|
170
222
|
- db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb
|
|
171
223
|
- db/migrate/20261027000002_shiplog_0_3_0.rb
|
|
224
|
+
- db/migrate/20261028000001_shiplog_0_4_0.rb
|
|
225
|
+
- db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb
|
|
172
226
|
- lib/generators/shiplog/install/install_generator.rb
|
|
173
227
|
- lib/generators/shiplog/install/templates/initializer.rb.tt
|
|
174
228
|
- lib/generators/shiplog/install/templates/shiplog-feature.yml
|
|
@@ -181,6 +235,7 @@ files:
|
|
|
181
235
|
- lib/shiplog/github/errors.rb
|
|
182
236
|
- lib/shiplog/github/queries.rb
|
|
183
237
|
- lib/shiplog/head_tags_helper.rb
|
|
238
|
+
- lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb
|
|
184
239
|
- lib/shiplog/version.rb
|
|
185
240
|
- lib/tasks/shiplog.rake
|
|
186
241
|
homepage: https://github.com/Trackberry/shiplog
|