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
|
@@ -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
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# A small, dependency-free Markdown subset for announcement bodies (written by an LLM, edited by an admin, shown
|
|
3
|
+
# on public pages, in the feed and in emails). XSS-safe by construction: every piece of source text is escaped
|
|
4
|
+
# before it is placed in a tag we build ourselves, and raw HTML is never passed through.
|
|
5
|
+
module Markdown
|
|
6
|
+
HEADING = /\A {0,3}(\#{1,6})[ \t]+(.*?)(?:[ \t]+\#+)?[ \t]*\z/
|
|
7
|
+
RULE = /\A {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*\z/
|
|
8
|
+
ITEM = /\A([ \t]*)([-*+]|\d+[.)])[ \t]+(.*)\z/
|
|
9
|
+
QUOTE = /\A {0,3}>[ \t]?(.*)\z/
|
|
10
|
+
LINK = /\G\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
|
|
11
|
+
IMAGE = /\G!\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
|
|
12
|
+
ESCAPABLE = %w[* _ [ ] ( ) ` \\ # ! -].freeze
|
|
13
|
+
LINK_URL = %r{\A(?:https?://|mailto:|#|/(?![/\\]))}i
|
|
14
|
+
IMAGE_URL = %r{\A(?:https?://|/(?![/\\]))}i
|
|
15
|
+
CONTROL_CHARS = /[[:cntrl:]]/
|
|
16
|
+
|
|
17
|
+
# Block-level HTML for +text+. Heading levels are shifted by +heading_offset+ (clamped to h1..h6). +styles+
|
|
18
|
+
# maps tag names to inline styles ({ "p" => "margin:0 0 12px;" }) for emails, where stylesheets are ignored.
|
|
19
|
+
def self.to_html(text, heading_offset: 0, styles: {})
|
|
20
|
+
html = blocks(text).map { |block| render_block_html(block, heading_offset, styles) }.join("\n")
|
|
21
|
+
html.html_safe # every fragment was escaped or built by us
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Plain text (no HTML escaping) for emails and meta descriptions; blocks are separated by a blank line.
|
|
25
|
+
def self.to_text(text)
|
|
26
|
+
blocks(text).filter_map { |block| render_block_text(block) }.join("\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Splits the source into [:heading, level, text], [:paragraph, text], [:quote, text], [:list, ordered, items]
|
|
30
|
+
# and [:rule] blocks.
|
|
31
|
+
def self.blocks(text)
|
|
32
|
+
lines = text.to_s.gsub(/\r\n?/, "\n").split("\n")
|
|
33
|
+
result = []
|
|
34
|
+
until lines.empty?
|
|
35
|
+
line = lines.first
|
|
36
|
+
if line.strip.empty?
|
|
37
|
+
lines.shift
|
|
38
|
+
elsif (match = line.match(HEADING))
|
|
39
|
+
lines.shift
|
|
40
|
+
result << [ :heading, match[1].length, match[2] ]
|
|
41
|
+
elsif line.match?(RULE)
|
|
42
|
+
lines.shift
|
|
43
|
+
result << [ :rule ]
|
|
44
|
+
elsif line.match?(ITEM)
|
|
45
|
+
result << take_list(lines)
|
|
46
|
+
elsif line.match?(QUOTE)
|
|
47
|
+
result << [ :quote, take_while(lines) { |l| l.match(QUOTE)&.[](1) }.join("\n") ]
|
|
48
|
+
else
|
|
49
|
+
result << [ :paragraph, take_paragraph(lines).join("\n").strip ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
private_class_method :blocks
|
|
55
|
+
|
|
56
|
+
def self.block_start?(line)
|
|
57
|
+
line.match?(HEADING) || line.match?(RULE) || line.match?(ITEM) || line.match?(QUOTE)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :block_start?
|
|
60
|
+
|
|
61
|
+
# Shifts consecutive lines for which the block returns a (non-nil) value, and returns those values.
|
|
62
|
+
def self.take_while(lines)
|
|
63
|
+
taken = []
|
|
64
|
+
while lines.any? && (value = yield(lines.first))
|
|
65
|
+
taken << value
|
|
66
|
+
lines.shift
|
|
67
|
+
end
|
|
68
|
+
taken
|
|
69
|
+
end
|
|
70
|
+
private_class_method :take_while
|
|
71
|
+
|
|
72
|
+
def self.take_paragraph(lines)
|
|
73
|
+
[ lines.shift ] + take_while(lines) { |l| l unless l.strip.empty? || block_start?(l) }
|
|
74
|
+
end
|
|
75
|
+
private_class_method :take_paragraph
|
|
76
|
+
|
|
77
|
+
# Items of one list kind. Indented items continue the list, indented non-item lines continue the last item, and
|
|
78
|
+
# blank lines are skipped only when the next non-blank line is another item of the same kind.
|
|
79
|
+
def self.take_list(lines)
|
|
80
|
+
ordered = ordered_item?(lines.first)
|
|
81
|
+
items = []
|
|
82
|
+
until lines.empty?
|
|
83
|
+
line = lines.first
|
|
84
|
+
if line.strip.empty?
|
|
85
|
+
following = lines.find { |l| !l.strip.empty? }
|
|
86
|
+
break unless following&.match?(ITEM) && !following.match?(RULE) && ordered_item?(following) == ordered
|
|
87
|
+
lines.shift
|
|
88
|
+
elsif line.match?(RULE) || (line.match?(ITEM) && ordered_item?(line) != ordered)
|
|
89
|
+
break
|
|
90
|
+
elsif (match = line.match(ITEM))
|
|
91
|
+
items << match[3].strip
|
|
92
|
+
lines.shift
|
|
93
|
+
elsif line.match?(/\A[ \t]+\S/) && !line.match?(HEADING) && !line.match?(QUOTE)
|
|
94
|
+
items[-1] = "#{items.last}\n#{line.strip}"
|
|
95
|
+
lines.shift
|
|
96
|
+
else
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
[ :list, ordered, items ]
|
|
101
|
+
end
|
|
102
|
+
private_class_method :take_list
|
|
103
|
+
|
|
104
|
+
def self.ordered_item?(line) = line.match(ITEM)[2].match?(/\d/)
|
|
105
|
+
private_class_method :ordered_item?
|
|
106
|
+
|
|
107
|
+
# In HTML mode, +styles+ (a Hash, maybe empty) stands for the html flag of the inline renderers.
|
|
108
|
+
def self.render_block_html(block, heading_offset, styles)
|
|
109
|
+
case block.first
|
|
110
|
+
when :heading
|
|
111
|
+
level = (block[1] + heading_offset).clamp(1, 6)
|
|
112
|
+
"#{open("h#{level}", styles)}#{inline(block[2], styles)}</h#{level}>"
|
|
113
|
+
when :paragraph then "#{open("p", styles)}#{inline(block[1], styles)}</p>"
|
|
114
|
+
when :quote then "#{open("blockquote", styles)}#{open("p", styles)}#{inline(block[1], styles)}</p></blockquote>"
|
|
115
|
+
when :rule then open("hr", styles)
|
|
116
|
+
when :list
|
|
117
|
+
tag = block[1] ? "ol" : "ul"
|
|
118
|
+
items = block[2].map { |item| "#{open("li", styles)}#{inline(item, styles)}</li>" }.join("\n")
|
|
119
|
+
"#{open(tag, styles)}\n#{items}\n</#{tag}>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :render_block_html
|
|
123
|
+
|
|
124
|
+
# "<p>" or, with an inline style for the tag, "<p style="...">" (the style escaped like any attribute).
|
|
125
|
+
def self.open(tag, styles) = "<#{tag}#{style(tag, styles)}>"
|
|
126
|
+
private_class_method :open
|
|
127
|
+
|
|
128
|
+
def self.style(tag, styles)
|
|
129
|
+
value = styles[tag] if styles.is_a?(Hash)
|
|
130
|
+
value.present? ? %( style="#{escape(value, true)}") : ""
|
|
131
|
+
end
|
|
132
|
+
private_class_method :style
|
|
133
|
+
|
|
134
|
+
def self.render_block_text(block)
|
|
135
|
+
case block.first
|
|
136
|
+
when :heading then inline(block[2], false)
|
|
137
|
+
when :paragraph, :quote then inline(block[1], false)
|
|
138
|
+
when :list
|
|
139
|
+
block[2].each_with_index.map { |item, i| "#{block[1] ? "#{i + 1}." : "-"} #{inline(item, false)}" }.join("\n")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_class_method :render_block_text
|
|
143
|
+
|
|
144
|
+
# Scans left to right; whatever is not a recognised construct is literal (and escaped in HTML mode).
|
|
145
|
+
def self.inline(source, html)
|
|
146
|
+
out = +""
|
|
147
|
+
i = 0
|
|
148
|
+
while i < source.length
|
|
149
|
+
output, i = inline_token(source, i, html)
|
|
150
|
+
out << output
|
|
151
|
+
end
|
|
152
|
+
out
|
|
153
|
+
end
|
|
154
|
+
private_class_method :inline
|
|
155
|
+
|
|
156
|
+
# Consumes one construct (or one literal character) at index +i+; returns its output and the next index.
|
|
157
|
+
def self.inline_token(source, i, html)
|
|
158
|
+
char = source[i]
|
|
159
|
+
if char == "\\" && ESCAPABLE.include?(source[i + 1])
|
|
160
|
+
[ escape(source[i + 1], html), i + 2 ]
|
|
161
|
+
elsif char == "`" && source[i + 1] == "`"
|
|
162
|
+
run = source[i..][/\A`+/]
|
|
163
|
+
[ run, i + run.length ] # a run of backticks (such as a ``` fence) is literal text
|
|
164
|
+
elsif char == "`" && (close = source.index("`", i + 1))
|
|
165
|
+
code = source[(i + 1)...close]
|
|
166
|
+
[ html ? "#{open("code", html)}#{escape(code, true)}</code>" : code, close + 1 ]
|
|
167
|
+
elsif char == "!" && (match = source.match(IMAGE, i))
|
|
168
|
+
[ image(match[1], match[2], html), i + match[0].length ]
|
|
169
|
+
elsif char == "[" && (match = source.match(LINK, i))
|
|
170
|
+
[ link(match[1], match[2], html), i + match[0].length ]
|
|
171
|
+
elsif char == "*" && (emphasis = emphasis(source, i, html))
|
|
172
|
+
emphasis
|
|
173
|
+
else
|
|
174
|
+
[ escape(char, html), i + 1 ]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
private_class_method :inline_token
|
|
178
|
+
|
|
179
|
+
# **strong** or *em* when a closing marker exists and the content does not start with a space.
|
|
180
|
+
def self.emphasis(source, i, html)
|
|
181
|
+
marker = source[i, 2] == "**" ? "**" : "*"
|
|
182
|
+
start = i + marker.length
|
|
183
|
+
close = closing_marker(source, start, marker)
|
|
184
|
+
return if close.nil? || close == start || source[start].match?(/\s/)
|
|
185
|
+
|
|
186
|
+
inner = inline(source[start...close], html)
|
|
187
|
+
tag = marker == "**" ? "strong" : "em"
|
|
188
|
+
[ html ? "#{open(tag, html)}#{inner}</#{tag}>" : inner, close + marker.length ]
|
|
189
|
+
end
|
|
190
|
+
private_class_method :emphasis
|
|
191
|
+
|
|
192
|
+
# Index of the next unescaped +marker+ at or after +from+.
|
|
193
|
+
def self.closing_marker(source, from, marker)
|
|
194
|
+
i = from
|
|
195
|
+
while i < source.length
|
|
196
|
+
if source[i] == "\\"
|
|
197
|
+
i += 2
|
|
198
|
+
elsif source[i, marker.length] == marker
|
|
199
|
+
return source[i + marker.length] == "*" && marker == "**" ? i + 1 : i # "***": the last two close
|
|
200
|
+
else
|
|
201
|
+
i += 1
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
private_class_method :closing_marker
|
|
207
|
+
|
|
208
|
+
# An anchor for allowed URLs, otherwise just the (escaped) text. Plain text shows "text (url)".
|
|
209
|
+
def self.link(label, url, html)
|
|
210
|
+
text = inline(label, html)
|
|
211
|
+
return text unless allowed_url?(url, LINK_URL)
|
|
212
|
+
return (text == url ? text : "#{text} (#{url})") unless html
|
|
213
|
+
|
|
214
|
+
%(<a href="#{escape(url, true)}"#{style("a", html)}>#{text}</a>)
|
|
215
|
+
end
|
|
216
|
+
private_class_method :link
|
|
217
|
+
|
|
218
|
+
# An img tag for allowed URLs, otherwise just the escaped alt text.
|
|
219
|
+
def self.image(alt, url, html)
|
|
220
|
+
text = inline(alt, false)
|
|
221
|
+
return escape(text, html) unless html && allowed_url?(url, IMAGE_URL)
|
|
222
|
+
|
|
223
|
+
%(<img src="#{escape(url, true)}" alt="#{escape(text, true)}" loading="lazy"#{style("img", html)}>)
|
|
224
|
+
end
|
|
225
|
+
private_class_method :image
|
|
226
|
+
|
|
227
|
+
def self.allowed_url?(url, pattern) = url.match?(pattern) && !url.match?(CONTROL_CHARS)
|
|
228
|
+
private_class_method :allowed_url?
|
|
229
|
+
|
|
230
|
+
def self.escape(text, html) = html ? ERB::Util.html_escape(text).to_str : text
|
|
231
|
+
private_class_method :escape
|
|
232
|
+
end
|
|
233
|
+
end
|