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
|
@@ -37,13 +37,30 @@ module Shiplog
|
|
|
37
37
|
path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Where a month heading's "Read the summary" link goes: the announcement's page (config.announcements = :page),
|
|
41
|
+
# or what config.announcement_url returns for it (:external; evaluated in the view with the announcement and
|
|
42
|
+
# the locale). Nil, so no link, when there is none, it raises or it is not a safe link.
|
|
43
|
+
def shiplog_announcement_href(announcement)
|
|
44
|
+
return shiplog_entry_path(announcement) if Shiplog.announcement_pages?
|
|
45
|
+
|
|
46
|
+
callable = Shiplog.config.announcement_url
|
|
47
|
+
return unless Shiplog.config.announcements == :external && callable
|
|
48
|
+
|
|
49
|
+
value = callable.is_a?(Proc) ? instance_exec(announcement, I18n.locale, &callable) : callable.call(announcement, I18n.locale)
|
|
50
|
+
value if Shiplog::Configuration.safe_url?(value)
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
Rails.logger.error("[shiplog] config.announcement_url failed for entry #{announcement.id}: #{error.class}: #{error.message}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
# The id of a month's section on the changelog list.
|
|
41
57
|
def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
|
|
42
58
|
|
|
43
59
|
# The body, on one line and cut for a meta description. Plain text: tag helpers escape it. `escape: false`
|
|
44
60
|
# keeps the cut text raw (not HTML-escaped) for JSON, which escapes it its own way.
|
|
45
61
|
def shiplog_entry_description(entry, escape: true)
|
|
46
|
-
|
|
62
|
+
text = entry.announcement? ? Shiplog::Markdown.to_text(entry.display_body) : entry.display_body.to_s
|
|
63
|
+
truncate(text.squish, length: DESCRIPTION_LENGTH, separator: " ", escape:)
|
|
47
64
|
end
|
|
48
65
|
|
|
49
66
|
# The Open Graph meta tags of a permalink page, for `content_for :shiplog_og` (superseded by shiplog_head_tags).
|
|
@@ -55,15 +72,16 @@ module Shiplog
|
|
|
55
72
|
safe_join(tags.compact_blank.map { |property, content| tag.meta(property:, content:) }, "\n")
|
|
56
73
|
end
|
|
57
74
|
|
|
58
|
-
# The absolute URL of the image to show when the page is shared, or nil: the entry's first screenshot, else
|
|
75
|
+
# The absolute URL of the image to show when the page is shared, or nil: the entry's first screenshot with a URL, else
|
|
59
76
|
# what config.og_image returns for it. Computed once per entry per render, so a failing callable is logged
|
|
60
77
|
# once for a page.
|
|
61
78
|
def shiplog_entry_og_image(entry)
|
|
62
79
|
@shiplog_og_images ||= {}.compare_by_identity
|
|
63
80
|
return @shiplog_og_images[entry] if @shiplog_og_images.key?(entry)
|
|
64
81
|
|
|
65
|
-
image
|
|
66
|
-
|
|
82
|
+
# Same rule as the JSON-LD Article image: screenshots whose URL can't be built are skipped.
|
|
83
|
+
screenshot = shiplog_images(entry).lazy.filter_map { |image| shiplog_image_url(image) }.first
|
|
84
|
+
@shiplog_og_images[entry] = screenshot || shiplog_configured_og_image(entry)
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
# What config.og_image gives for an entry (called in the view with the entry and the locale), as an absolute
|
|
@@ -102,7 +120,8 @@ module Shiplog
|
|
|
102
120
|
url = shiplog_image_url(image)
|
|
103
121
|
tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
|
|
104
122
|
end
|
|
105
|
-
|
|
123
|
+
text = entry.announcement? ? Shiplog::Markdown.to_html(entry.display_body) : simple_format(h(entry.display_body))
|
|
124
|
+
safe_join([ text, *images ], "\n")
|
|
106
125
|
end
|
|
107
126
|
|
|
108
127
|
# An entry's images in order, or none while screenshots are off.
|
|
@@ -5,5 +5,9 @@ module Shiplog
|
|
|
5
5
|
module UnseenHelper
|
|
6
6
|
# The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
|
|
7
7
|
def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
|
|
8
|
+
|
|
9
|
+
# The number of approved monthly announcements approved after `since` (all of them while it is nil), at most 99:
|
|
10
|
+
# the rare signal for a badge, where shiplog_unseen_count counts every daily entry.
|
|
11
|
+
def shiplog_unseen_announcements_count(since) = Shiplog.unseen_announcements_count(since:)
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Calls config.on_announcement_approved with an approved announcement, and sends the
|
|
3
|
+
# `announcement_approved.shiplog` notification, once its translations into every configured locale are current
|
|
4
|
+
# (or cannot be made: no rewriter). Enqueued when an announcement is approved and after each translation of one;
|
|
5
|
+
# whichever run finds it ready announces it, once per approval (Entry#announce!).
|
|
6
|
+
#
|
|
7
|
+
# The hook runs inside the announcement's row lock and transaction: keep it fast (enqueue your own job). A hook
|
|
8
|
+
# that raises, or a worker that dies mid-hook, leaves the announcement unannounced, and the retry calls it again;
|
|
9
|
+
# after the last attempt the error is logged and the admin can "Notify now". `force: true` (that button) does not
|
|
10
|
+
# wait for translations.
|
|
11
|
+
class AnnounceJob < ApplicationJob
|
|
12
|
+
ATTEMPTS = 3
|
|
13
|
+
EVENT = "announcement_approved.shiplog".freeze
|
|
14
|
+
|
|
15
|
+
retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
|
|
16
|
+
Rails.logger.error("[shiplog] Giving up announcing entry #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
|
|
17
|
+
end
|
|
18
|
+
discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
|
|
19
|
+
|
|
20
|
+
def perform(entry, force: false)
|
|
21
|
+
return unless Shiplog.announcements?
|
|
22
|
+
|
|
23
|
+
entry.announce!(force:) do
|
|
24
|
+
ActiveSupport::Notifications.instrument(EVENT, announcement: entry) do
|
|
25
|
+
Shiplog.config.on_announcement_approved&.call(entry)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Drafts the announcement of a month (the previous one by default) and enqueues its rewrite. Schedule it once a
|
|
3
|
+
# month, early in the month (see the README's Scheduling section); running it again is harmless.
|
|
4
|
+
class DraftAnnouncementJob < ApplicationJob
|
|
5
|
+
def perform(month = nil)
|
|
6
|
+
Announcements.draft(month: month || Date.current.prev_month)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Housekeeping of the newsletter's tables, to schedule weekly like ScanChangesJob (see the README's Scheduling
|
|
3
|
+
# section): deletes the subscribers who never confirmed within 30 days (NewsletterSubscriber::PENDING_FOR: an
|
|
4
|
+
# address typed by someone else, or a confirmation email that never arrived) and the delivery rows older than
|
|
5
|
+
# 180 days (NewsletterDelivery::RETENTION: the idempotency record of a send long done). Confirmed and
|
|
6
|
+
# unsubscribed rows are never touched. Returns { subscribers:, deliveries: } counts.
|
|
7
|
+
class PurgeNewsletterJob < ApplicationJob
|
|
8
|
+
def perform
|
|
9
|
+
subscribers = NewsletterSubscriber.purge_pending
|
|
10
|
+
deliveries = NewsletterDelivery.purge
|
|
11
|
+
Rails.logger.info("[shiplog] Purged #{subscribers} unconfirmed newsletter #{subscribers == 1 ? "subscriber" : "subscribers"} and #{deliveries} old delivery #{deliveries == 1 ? "row" : "rows"}")
|
|
12
|
+
{ subscribers:, deliveries: }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Emails an approved announcement to every recipient (Newsletter::Recipients: the host's users, then the
|
|
3
|
+
# confirmed subscribers, minus the unsubscribed and the duplicates), once per recipient. For each chunk of
|
|
4
|
+
# NewsletterDelivery::CLAIM_SIZE it inserts the delivery rows first (one statement, skipping the rows that
|
|
5
|
+
# exist) and emails only the recipients it inserted, touching each row right before its send, so a retried
|
|
6
|
+
# job, a second click on Send or two workers never send twice: at most once per recipient. A failed delivery
|
|
7
|
+
# is recorded on its row (never raised: the chunk goes on) for the admin's "Retry failed", which runs the job
|
|
8
|
+
# with `retry_failed: true`: the failed and stalled rows are reclaimed in one statement, the ones reclaimed by
|
|
9
|
+
# this run are sent again, and the ones whose recipient is gone end as failed with the reason.
|
|
10
|
+
#
|
|
11
|
+
# The announcement's Markdown is rendered once per locale (Newsletter::Content); each message is then built with
|
|
12
|
+
# the recipient's own links. The job stops before anything is sent when the newsletter is off or the
|
|
13
|
+
# announcement cannot be sent (Newsletter.blockers: not approved, missing translations, no sender...).
|
|
14
|
+
class SendNewsletterJob < ApplicationJob
|
|
15
|
+
discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
|
|
16
|
+
|
|
17
|
+
def perform(entry, retry_failed: false)
|
|
18
|
+
return log_skip(entry, "the newsletter is off") unless Newsletter.enabled?
|
|
19
|
+
|
|
20
|
+
blockers = Newsletter.blockers(entry)
|
|
21
|
+
return log_skip(entry, "it cannot be sent: #{blockers.join(", ")}") if blockers.any?
|
|
22
|
+
|
|
23
|
+
reclaimed = retry_failed ? NewsletterDelivery.reclaim(entry).to_set : Set.new
|
|
24
|
+
contents = Hash.new { |hash, locale| hash[locale] = Newsletter::Content.new(entry, locale) }
|
|
25
|
+
sent = 0
|
|
26
|
+
Newsletter::Recipients.new.each_batch do |batch|
|
|
27
|
+
batch.each_slice(NewsletterDelivery::CLAIM_SIZE) do |chunk|
|
|
28
|
+
claimed = NewsletterDelivery.claim(entry, chunk).to_set
|
|
29
|
+
chunk.each do |recipient|
|
|
30
|
+
next unless claimed.include?(recipient.key) || reclaimed.delete?(recipient.key)
|
|
31
|
+
|
|
32
|
+
sent += 1 if deliver(entry, recipient, contents[recipient.locale])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
NewsletterDelivery.mark_missing(entry, reclaimed) # reclaimed, but no longer a recipient
|
|
37
|
+
Rails.logger.info("[shiplog] Newsletter of entry #{entry.id}: #{sent} emails sent in this run")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Sends one email and records the outcome on the delivery row. True when it was sent.
|
|
43
|
+
def deliver(entry, recipient, content)
|
|
44
|
+
NewsletterDelivery.mark_sending(entry, recipient.key)
|
|
45
|
+
NewsletterMailer.announcement(entry, recipient.email, locale: recipient.locale, content:,
|
|
46
|
+
unsubscribe_url: Newsletter.unsubscribe_url(recipient.email), preferences_url: preferences_url(recipient)).deliver_now
|
|
47
|
+
NewsletterDelivery.mark_sent(entry, recipient.key)
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError => error
|
|
50
|
+
NewsletterDelivery.mark_failed(entry, recipient.key, error)
|
|
51
|
+
Rails.logger.error("[shiplog] Newsletter of entry #{entry.id} to #{recipient.key} failed: #{error.class}: #{NewsletterDelivery.scrub(error.message)}")
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A subscriber's preferences page; a host user's own page when config.newsletter_unsubscribe_url gives one.
|
|
56
|
+
def preferences_url(recipient)
|
|
57
|
+
recipient.subscriber? ? Newsletter.preferences_url(recipient.record) : Newsletter.host_unsubscribe_url(recipient.record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def log_skip(entry, reason)
|
|
61
|
+
Rails.logger.info("[shiplog] Newsletter of entry #{entry.id} not sent: #{reason}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -17,6 +17,8 @@ module Shiplog
|
|
|
17
17
|
|
|
18
18
|
def perform(entry, locale, force: false)
|
|
19
19
|
Translator.new(entry, locale).call(force:)
|
|
20
|
+
# An approved announcement is announced once all its translations are current: this may have been the last.
|
|
21
|
+
AnnounceJob.perform_later(entry) if entry.announcement?
|
|
20
22
|
rescue StandardError => error
|
|
21
23
|
Rails.logger.error("[shiplog] Translating entry #{entry.id} into #{locale} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
|
|
22
24
|
raise
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The newsletter's emails: an announcement to one recipient, and the confirmation of a visitor's subscription.
|
|
3
|
+
# Both have an HTML part (one 600px table column, styles inline, see app/views/shiplog/newsletter_mailer) and a
|
|
4
|
+
# text part, in the recipient's language (the chrome from Shiplog's I18n keys, the announcement's text from its
|
|
5
|
+
# translation). Hosts override the templates like any engine view. The sender is config.newsletter_from.
|
|
6
|
+
class NewsletterMailer < ActionMailer::Base
|
|
7
|
+
helper Shiplog::NewsletterMailerHelper
|
|
8
|
+
|
|
9
|
+
default from: -> { sender! }
|
|
10
|
+
|
|
11
|
+
# The announcement for `email` in `locale`. `unsubscribe_url` is the one-click target (the List-Unsubscribe
|
|
12
|
+
# headers Gmail and Yahoo require; Newsletter.unsubscribe_url for the address when not given, so a queued test
|
|
13
|
+
# send carries no token), `preferences_url` the page the footer's "Preferences" opens (a subscriber's page, or
|
|
14
|
+
# the host's own for its users; nil shows the unsubscribe page instead). `content` is the locale's rendered
|
|
15
|
+
# Content when the caller has it (SendNewsletterJob renders once per locale). config.newsletter_mailer_options
|
|
16
|
+
# are merged into the message (e.g. Postmark's message_stream); they cannot change the recipient or the subject.
|
|
17
|
+
def announcement(entry, email, locale:, unsubscribe_url: nil, preferences_url: nil, content: nil)
|
|
18
|
+
@content = content || Newsletter::Content.new(entry, locale)
|
|
19
|
+
@unsubscribe_url = unsubscribe_url || Newsletter.unsubscribe_url(email)
|
|
20
|
+
@preferences_url = preferences_url || @unsubscribe_url
|
|
21
|
+
headers["List-Unsubscribe"] = "<#{@unsubscribe_url}>"
|
|
22
|
+
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
|
23
|
+
headers["Precedence"] = "bulk"
|
|
24
|
+
I18n.with_locale(Newsletter.i18n_locale(@content.locale)) do
|
|
25
|
+
mail(**Shiplog.config.newsletter_mailer_options, to: email, subject: @content.subject)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The double opt-in email: a single-use link (valid 7 days) the subscriber must open, in the locale of the
|
|
30
|
+
# page they subscribed on. `subscriber` is a NewsletterSubscriber, or its id when queued (deliver_later): a
|
|
31
|
+
# row deleted or confirmed in the meantime sends nothing, quietly. Transactional: no mailer options, no
|
|
32
|
+
# List-Unsubscribe.
|
|
33
|
+
def confirmation(subscriber)
|
|
34
|
+
@subscriber = subscriber.is_a?(NewsletterSubscriber) ? subscriber : NewsletterSubscriber.find_by(id: subscriber)
|
|
35
|
+
@confirmation_url = @subscriber && Newsletter.confirmation_url(@subscriber)
|
|
36
|
+
return unless @confirmation_url
|
|
37
|
+
|
|
38
|
+
@product_name = Newsletter.product_name
|
|
39
|
+
I18n.with_locale(Newsletter.i18n_locale(@subscriber.locale)) do
|
|
40
|
+
mail(to: @subscriber.email, subject: t("shiplog.newsletter.mail.confirm_subject", product: Newsletter.product_name))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The From address, or a clear error when none is configured (the job records it on the delivery row).
|
|
47
|
+
def sender!
|
|
48
|
+
Newsletter.from or raise ArgumentError, "[shiplog] config.newsletter_from is not set, so no newsletter email can be sent"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# Monthly announcements: a short, AI-drafted summary of what shipped in a month, approved by an admin like every
|
|
3
|
+
# other entry (kind "announcement", subject a Shiplog::Period). Where it is published is config.announcements.
|
|
4
|
+
module Announcements
|
|
5
|
+
# The slug used when a feature or change approved before 0.4.0 already took the month's own ("2026-10").
|
|
6
|
+
FALLBACK_SUFFIX = "-update".freeze
|
|
7
|
+
|
|
8
|
+
# Drafts the announcement of the month containing `month` and enqueues its rewrite. Returns the new draft, or
|
|
9
|
+
# nil when announcements are off, the month has no approved feature or change (nothing to announce), or it
|
|
10
|
+
# has an announcement already (whatever its status).
|
|
11
|
+
def self.draft(month:)
|
|
12
|
+
return unless Shiplog.announcements?
|
|
13
|
+
|
|
14
|
+
starts_on = month.to_date.beginning_of_month
|
|
15
|
+
return unless Period.changelog_entries_between(starts_on..starts_on.end_of_month).exists?
|
|
16
|
+
|
|
17
|
+
period = Period.month(starts_on)
|
|
18
|
+
Drafter.draft(period, "announcement", released_on: period.ends_on, slug: slug_for(period))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The months an admin can draft from the admin: the current one (so far) and the previous one.
|
|
22
|
+
def self.draftable_months(today = Date.current) = [ today.beginning_of_month, today.prev_month.beginning_of_month ]
|
|
23
|
+
|
|
24
|
+
# "2026-10", or when an older entry already has that slug "2026-10-update", then "2026-10-update-2", ...
|
|
25
|
+
def self.slug_for(period)
|
|
26
|
+
month = period.starts_on.strftime("%Y-%m")
|
|
27
|
+
candidates = Enumerator.new do |yielder|
|
|
28
|
+
yielder << month << "#{month}#{FALLBACK_SUFFIX}"
|
|
29
|
+
(2..).each { |suffix| yielder << "#{month}#{FALLBACK_SUFFIX}-#{suffix}" }
|
|
30
|
+
end
|
|
31
|
+
candidates.find { |slug| !Entry.where(slug:).where.not(subject: period).exists? }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -99,9 +99,10 @@ module Shiplog
|
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
# Entries that may still get an area: drafts and approved ones with a title, no area, none chosen by an admin,
|
|
102
|
-
# not asked about yet.
|
|
102
|
+
# not asked about yet. Announcements cover a whole month and never get one.
|
|
103
103
|
def candidates
|
|
104
104
|
Entry.where(status: %w[draft approved], area_id: nil, area_set_by_admin: false, area_detected_at: nil).where.not(title: [ nil, "" ])
|
|
105
|
+
.where.not(kind: "announcement")
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
# Ignores ids outside the batch and areas that don't exist. The update re-checks the conditions, so an
|
|
@@ -17,6 +17,10 @@ module Shiplog
|
|
|
17
17
|
repository.pull_requests.awaiting_upgrade_check.where(merged_at: since..).reorder(:merged_at).pick(:merged_at)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Node ids of the pull requests (of any repository) linked to an issue that is public or has a public ancestor:
|
|
21
|
+
# the feature entry of that issue covers them. The lookup reads no instance state.
|
|
22
|
+
def self.public_tree_pull_request_ids(pull_requests) = allocate.send(:pull_request_ids_in_public_tree, pull_requests)
|
|
23
|
+
|
|
20
24
|
def initialize(repository:, since:, before:)
|
|
21
25
|
@repository = repository
|
|
22
26
|
@window = since...before
|
|
@@ -14,12 +14,14 @@ module Shiplog
|
|
|
14
14
|
REQUEUE_AFTER = 1.hour
|
|
15
15
|
REQUEUE_LIMIT = 100
|
|
16
16
|
|
|
17
|
-
# Returns the new entry, or nil when one already exists for the subject and kind.
|
|
18
|
-
|
|
17
|
+
# Returns the new entry, or nil when one already exists for the subject and kind. `attributes` are set on a new
|
|
18
|
+
# entry only (an announcement's slug).
|
|
19
|
+
def self.draft(subject, kind, released_on: nil, **attributes)
|
|
19
20
|
entry = Entry.create_or_find_by!(subject:, kind:) do |new_entry|
|
|
20
21
|
new_entry.title = subject.title
|
|
21
22
|
new_entry.body = subject.body
|
|
22
23
|
new_entry.released_on = released_on
|
|
24
|
+
new_entry.assign_attributes(attributes)
|
|
23
25
|
end
|
|
24
26
|
return unless entry.previously_new_record?
|
|
25
27
|
|
data/app/models/shiplog/entry.rb
CHANGED
|
@@ -7,20 +7,31 @@ module Shiplog
|
|
|
7
7
|
belongs_to :subject, polymorphic: true
|
|
8
8
|
belongs_to :area, class_name: "Shiplog::Area", optional: true
|
|
9
9
|
has_many :translations, class_name: "Shiplog::EntryTranslation", dependent: :destroy, inverse_of: :entry
|
|
10
|
+
# The newsletter's deliveries of an announcement (see NewsletterDelivery); the database cascades the delete.
|
|
11
|
+
has_many :newsletter_deliveries, class_name: "Shiplog::NewsletterDelivery", foreign_key: :announcement_id, dependent: :delete_all, inverse_of: :announcement
|
|
10
12
|
|
|
11
|
-
enum :kind, %w[roadmap feature change].index_by(&:itself), validate: true
|
|
13
|
+
enum :kind, %w[roadmap feature change announcement].index_by(&:itself), validate: true
|
|
12
14
|
enum :status, %w[draft approved discarded].index_by(&:itself), default: "draft", validate: true
|
|
13
15
|
|
|
14
16
|
CHANGELOG_KINDS = %w[feature change].freeze
|
|
17
|
+
# The slug of a month's announcement ("2026-10"). Features and changes never get one of this shape, so an
|
|
18
|
+
# announcement's page URL is known before it is drafted.
|
|
19
|
+
ANNOUNCEMENT_SLUG = /\A\d{4}-\d{2}\z/
|
|
15
20
|
|
|
16
21
|
validates :released_on, presence: true, if: -> { approved? && !roadmap? }
|
|
17
22
|
validate :area_exists
|
|
18
23
|
validate :kind_change_allowed, on: :update
|
|
19
24
|
validate :kind_free_for_subject, on: :update
|
|
20
25
|
|
|
26
|
+
# An announcement that leaves "approved" may be announced again once it is approved again.
|
|
27
|
+
before_save :clear_announced_at, if: -> { announcement? && will_save_change_to_status? && !approved? }
|
|
28
|
+
|
|
21
29
|
# Approving an entry, or changing the English text of an approved one, translates it into every configured
|
|
22
30
|
# locale (see TranslateEntryJob). After the commit, so the job reads what was saved.
|
|
23
31
|
after_commit :enqueue_translations, on: :update, if: :translation_due?
|
|
32
|
+
# Approving an announcement calls config.on_announcement_approved once its translations are current (see
|
|
33
|
+
# AnnounceJob, which TranslateEntryJob enqueues too).
|
|
34
|
+
after_commit :enqueue_announcement, on: :update, if: -> { announcement? && approved? && saved_change_to_status? }
|
|
24
35
|
|
|
25
36
|
SEARCH_MAX_LENGTH = 100
|
|
26
37
|
SEARCH_MAX_WORDS = 5
|
|
@@ -84,11 +95,20 @@ module Shiplog
|
|
|
84
95
|
# at 99. One COUNT over the (status, approved_at) index. It filters on approved_at, not on released_on (the
|
|
85
96
|
# list's order): "new since you last looked" is about when an entry appeared, so an entry approved today
|
|
86
97
|
# with an earlier release date still counts as new.
|
|
87
|
-
def self.unseen_count(since: nil)
|
|
88
|
-
|
|
98
|
+
def self.unseen_count(since: nil) = count_approved_since(CHANGELOG_KINDS, since)
|
|
99
|
+
|
|
100
|
+
# The number of approved announcements approved after `since` (all of them while it is nil), capped at 99.
|
|
101
|
+
def self.unseen_announcements_count(since: nil) = count_approved_since("announcement", since)
|
|
102
|
+
|
|
103
|
+
def self.count_approved_since(kinds, since)
|
|
104
|
+
scope = approved.where(kind: kinds)
|
|
89
105
|
scope = scope.where("#{table_name}.approved_at > ?", since) if since
|
|
90
106
|
scope.limit(UNSEEN_COUNT_CAP).count
|
|
91
107
|
end
|
|
108
|
+
private_class_method :count_approved_since
|
|
109
|
+
|
|
110
|
+
# Approved announcements: the ones with a public page (config.announcements = :page) or a host link.
|
|
111
|
+
scope :published_announcements, -> { approved.where(kind: "announcement").where.not(released_on: nil) }
|
|
92
112
|
|
|
93
113
|
# Gives every published changelog entry that has no slug one (oldest first, so the oldest keeps the plain
|
|
94
114
|
# slug on duplicate titles). Returns how many it filled.
|
|
@@ -134,7 +154,11 @@ module Shiplog
|
|
|
134
154
|
self.kind = value if CHANGELOG_KINDS.include?(kind) && CHANGELOG_KINDS.include?(value)
|
|
135
155
|
end
|
|
136
156
|
|
|
157
|
+
# The digest of what a draft was written from: the GitHub title and body, or for an announcement the month's
|
|
158
|
+
# entries (Period#content_digest). A draft whose stored digest differs is flagged in the admin.
|
|
137
159
|
def self.digest_for(subject)
|
|
160
|
+
return subject.content_digest if subject.is_a?(Period)
|
|
161
|
+
|
|
138
162
|
Digest::SHA256.hexdigest([ subject.title, subject.body ].join("\n"))
|
|
139
163
|
end
|
|
140
164
|
|
|
@@ -200,6 +224,40 @@ module Shiplog
|
|
|
200
224
|
translated.blank? && body.present? ? body : (translated || body)
|
|
201
225
|
end
|
|
202
226
|
|
|
227
|
+
# Whether every configured locale has a current translation (always true without config.locales).
|
|
228
|
+
def translations_current? = Shiplog.config.locales.all? { |locale| translation(locale) }
|
|
229
|
+
|
|
230
|
+
# The title, Markdown body, HTML and plain text of an announcement in a locale (the default one when omitted):
|
|
231
|
+
# the current translation when there is one, else the English text. For config.on_announcement_approved and the
|
|
232
|
+
# admin's export.
|
|
233
|
+
def to_markdown(locale = I18n.default_locale) = display_body(locale).to_s
|
|
234
|
+
def to_html(locale = I18n.default_locale) = Markdown.to_html(display_body(locale))
|
|
235
|
+
def to_text(locale = I18n.default_locale) = Markdown.to_text(display_body(locale))
|
|
236
|
+
|
|
237
|
+
# The absolute URL of the entry's public page (see PublicLinks), or nil without a slug. For an announcement
|
|
238
|
+
# with config.announcements = :external, the host's own page is elsewhere (config.announcement_url).
|
|
239
|
+
def public_url = PublicLinks.entry_url(self)
|
|
240
|
+
|
|
241
|
+
# Whether the translations an announcement waits for are there: all current, or none can be made because there
|
|
242
|
+
# is no rewriter (then hosts get the English text, or an admin's hand-made translations).
|
|
243
|
+
def translations_ready? = Shiplog.config.rewriter.nil? || translations_current?
|
|
244
|
+
|
|
245
|
+
# Runs the block (the host's hook, see AnnounceJob) once for this approval, when this is an approved announcement
|
|
246
|
+
# not announced yet whose translations are ready (`force: true` skips that last check: the admin's "Notify
|
|
247
|
+
# now"). The block runs under the row lock and announced_at is stored in the same transaction, so two jobs
|
|
248
|
+
# never both run it, and a block that raises (or a worker that dies) stores nothing and is run again by the
|
|
249
|
+
# retry. Returns whether the block ran.
|
|
250
|
+
def announce!(force: false)
|
|
251
|
+
with_lock do
|
|
252
|
+
reload
|
|
253
|
+
next false unless announcement? && approved? && announced_at.nil? && (force || translations_ready?)
|
|
254
|
+
|
|
255
|
+
yield
|
|
256
|
+
update_columns(announced_at: Time.current)
|
|
257
|
+
true
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
203
261
|
def source_changed?
|
|
204
262
|
source_digest.present? && source_digest != self.class.digest_for(subject)
|
|
205
263
|
end
|
|
@@ -214,16 +272,28 @@ module Shiplog
|
|
|
214
272
|
Shiplog.config.locales.each { |locale| TranslateEntryJob.perform_later(self, locale) }
|
|
215
273
|
end
|
|
216
274
|
|
|
275
|
+
# Features and changes get a slug when approved; an announcement gets its month's slug when drafted.
|
|
217
276
|
def permalinked? = CHANGELOG_KINDS.include?(kind)
|
|
218
277
|
|
|
278
|
+
# Written whatever this instance holds: AnnounceJob sets the column with update_columns, so an instance loaded
|
|
279
|
+
# before it may still hold nil and would otherwise see no change to save.
|
|
280
|
+
def clear_announced_at
|
|
281
|
+
self.announced_at = nil
|
|
282
|
+
attribute_will_change!("announced_at")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def enqueue_announcement
|
|
286
|
+
AnnounceJob.perform_later(self)
|
|
287
|
+
end
|
|
288
|
+
|
|
219
289
|
# The title parameterized and cut at 80 characters, made unique with -2, -3, ...; entry-<id> when the title
|
|
220
|
-
# has nothing left.
|
|
221
|
-
# retrying_on_slug_conflict).
|
|
290
|
+
# has nothing left. A month's shape ("2026-10") is kept for announcements and gets a suffix too. The
|
|
291
|
+
# uniqueness check can lose a race, which the unique index catches (see retrying_on_slug_conflict).
|
|
222
292
|
def generate_slug
|
|
223
293
|
base = dashed_title.first(SLUG_MAX_LENGTH).gsub(/\A-+|-+\z/, "").presence || "entry-#{id}"
|
|
224
294
|
candidate = base
|
|
225
295
|
suffix = 2
|
|
226
|
-
while self.class.where(slug: candidate).where.not(id:).exists?
|
|
296
|
+
while ANNOUNCEMENT_SLUG.match?(candidate) || self.class.where(slug: candidate).where.not(id:).exists?
|
|
227
297
|
tail = "-#{suffix}"
|
|
228
298
|
candidate = "#{base.first(SLUG_MAX_LENGTH - tail.length).delete_suffix("-")}#{tail}"
|
|
229
299
|
suffix += 1
|