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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +170 -2
  4. data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
  5. data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
  6. data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
  7. data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
  8. data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
  9. data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
  10. data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
  11. data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
  12. data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
  13. data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
  14. data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
  15. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  16. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  17. data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
  18. data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
  19. data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
  20. data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
  21. data/app/controllers/shiplog/newsletter_controller.rb +34 -0
  22. data/app/controllers/shiplog/public_controller.rb +6 -0
  23. data/app/frontend/shiplog/admin.css +5 -0
  24. data/app/frontend/shiplog/once.js +31 -0
  25. data/app/frontend/shiplog/shiplog.css +72 -0
  26. data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
  27. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  28. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  29. data/app/helpers/shiplog/permalink_helper.rb +20 -2
  30. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  31. data/app/jobs/shiplog/announce_job.rb +30 -0
  32. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  33. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  34. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  35. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  36. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  37. data/app/models/shiplog/announcements.rb +34 -0
  38. data/app/models/shiplog/area_detector.rb +2 -1
  39. data/app/models/shiplog/change_candidates.rb +4 -0
  40. data/app/models/shiplog/drafter.rb +4 -2
  41. data/app/models/shiplog/entry.rb +76 -6
  42. data/app/models/shiplog/markdown.rb +233 -0
  43. data/app/models/shiplog/newsletter/content.rb +172 -0
  44. data/app/models/shiplog/newsletter/csv.rb +32 -0
  45. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  46. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  47. data/app/models/shiplog/newsletter.rb +180 -0
  48. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  49. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  50. data/app/models/shiplog/period.rb +129 -0
  51. data/app/models/shiplog/public_links.rb +47 -0
  52. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  53. data/app/models/shiplog/rewriter.rb +23 -3
  54. data/app/models/shiplog/translator/prompt.rb +2 -2
  55. data/app/models/shiplog/translator.rb +1 -1
  56. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  57. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  58. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  59. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  60. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  61. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  62. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  63. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  64. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  65. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  66. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  67. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  68. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  69. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  70. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  72. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  74. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  75. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  76. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  78. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  80. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  82. data/config/admin_routes.rb +12 -0
  83. data/config/locales/en.yml +147 -0
  84. data/config/locales/es.yml +147 -0
  85. data/config/routes.rb +13 -0
  86. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  87. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  88. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  89. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  90. data/lib/shiplog/classes.rb +14 -1
  91. data/lib/shiplog/configuration.rb +132 -1
  92. data/lib/shiplog/engine.rb +2 -0
  93. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  94. data/lib/shiplog/version.rb +1 -1
  95. data/lib/shiplog.rb +32 -0
  96. data/lib/tasks/shiplog.rake +22 -0
  97. metadata +56 -1
@@ -5,6 +5,15 @@ Shiplog::Admin::Engine.routes.draw do
5
5
  resource :approval, only: :create
6
6
  resource :discard, only: %i[create destroy]
7
7
  resource :rewrite, only: :create
8
+ # An announcement's text as a file to paste elsewhere: ?as=markdown, html or text, in the default locale or
9
+ # ?export_locale= (not :locale, which a host's locale-switching around_action would read; see translations).
10
+ get "export", to: "exports#show", as: :export
11
+ resource :announcement_notification, only: :create
12
+ # The newsletter of an announcement: the confirmation step with the recipient counts (show), the send
13
+ # (create), a test to the admin's address, and the retry of failed deliveries.
14
+ resource :newsletter, only: %i[show create]
15
+ resource :newsletter_test, only: :create, path: "newsletter/test"
16
+ resource :newsletter_retry, only: :create, path: "newsletter/retry"
8
17
  # The locale segment is :translation_locale, not :locale: a host's around_action that switches the locale
9
18
  # from params[:locale] must not read it (a configured translation locale may not be an available one).
10
19
  locale = { translation_locale: /[A-Za-z][A-Za-z0-9_-]*/ }
@@ -15,6 +24,9 @@ Shiplog::Admin::Engine.routes.draw do
15
24
  end
16
25
  end
17
26
  end
27
+ resources :announcements, only: :create
28
+ # The newsletter's subscribers: counts, a searchable list (?q=), removal, and the CSV export (.csv).
29
+ resources :newsletter_subscribers, only: %i[index destroy], path: "newsletter"
18
30
  resource :area_detection, only: :create, path: "areas/detection"
19
31
  resource :area_translation, only: :create, path: "areas/translation"
20
32
  resources :areas, only: %i[index update destroy] do
@@ -14,6 +14,7 @@ en:
14
14
  categories:
15
15
  feature: New
16
16
  change: Improved
17
+ announcement: Monthly update
17
18
  empty: Nothing here yet
18
19
  date_format: "%-d %B %Y"
19
20
  roadmap:
@@ -55,17 +56,161 @@ en:
55
56
  nav_label: More changelog entries
56
57
  newer: "← Newer: %{title}"
57
58
  older: "Older: %{title} →"
59
+ announcements:
60
+ read: "Read the %{month} summary"
61
+ back: "← Back to the changelog"
62
+ skeleton:
63
+ features: New
64
+ also_improved: Also improved
58
65
  feed:
59
66
  title_with_product: "%{product} changelog"
60
67
  link_title: Changelog (Atom)
61
68
  subscribe: Subscribe (Atom)
69
+ newsletter:
70
+ title: Newsletter
71
+ product: Product updates
72
+ back: "← Back to the changelog"
73
+ form:
74
+ title: Get the monthly summary by email
75
+ hint: One email a month, no more. Unsubscribe any time.
76
+ email_label: Email address
77
+ email_placeholder: you@example.com
78
+ submit: Subscribe
79
+ trap_label: Leave this field empty
80
+ invalid_email: Enter a valid email address.
81
+ sent:
82
+ heading: Check your inbox
83
+ text: We sent you a link to confirm your subscription. Open it to start receiving the monthly summary. If nothing arrives within a few minutes, look in your spam folder.
84
+ confirm:
85
+ heading: Confirm your subscription
86
+ text: "Click the button to receive the monthly summary at %{email}."
87
+ button: Confirm
88
+ done_heading: You're subscribed
89
+ done_text: "The monthly summary will arrive at %{email}. You can change the language or unsubscribe any time from your preferences."
90
+ preferences:
91
+ heading: Your subscription
92
+ email: Email
93
+ status: Status
94
+ statuses:
95
+ pending: Waiting for confirmation
96
+ confirmed: Subscribed
97
+ unsubscribed: Unsubscribed
98
+ language: Language
99
+ save: Save
100
+ saved: Preferences saved.
101
+ unsubscribe: Unsubscribe
102
+ unsubscribed: You're unsubscribed.
103
+ resubscribe: Resubscribe
104
+ resubscribed: You're subscribed again.
105
+ delete: Delete my data
106
+ delete_help: Deleting your data removes your address from our records entirely, including the record that you unsubscribed.
107
+ deleted_heading: Your data was deleted
108
+ deleted_text: Your email address is no longer stored. You can subscribe again from the changelog at any time.
109
+ unsubscribe:
110
+ heading: Unsubscribe
111
+ text: "Stop receiving the monthly summary at %{email}?"
112
+ button: Unsubscribe
113
+ done_heading: You're unsubscribed
114
+ done_text: "%{email} will not receive the monthly summary anymore."
115
+ invalid:
116
+ heading: This link is not valid
117
+ text: It may have expired or already been used. Subscribe again from the changelog to get a new one.
118
+ rate_limited:
119
+ heading: Too many attempts
120
+ text: Please try again in an hour.
121
+ mail:
122
+ view_in_browser: View in browser
123
+ changelog_button: See everything that shipped
124
+ highlight_button: See how it works
125
+ reason: "You receive this email because you subscribed to the %{product} monthly summary."
126
+ unsubscribe: Unsubscribe
127
+ preferences: Preferences
128
+ confirm_subject: "Confirm your subscription to the %{product} newsletter"
129
+ confirm_heading: One more step
130
+ confirm_intro: "Confirm that you want to receive the %{product} monthly summary at %{email}."
131
+ confirm_button: Confirm my subscription
132
+ confirm_expiry: "The link works for 7 days. If you did not ask for this, ignore this email: nothing is sent without confirmation."
133
+ confirm_link_text: "Or copy this link:"
62
134
  admin:
135
+ newsletter:
136
+ title: Newsletter
137
+ help: The visitors who subscribed through the changelog's form. Your own users (config.newsletter_recipients) are not listed here.
138
+ total: Total
139
+ search: Search
140
+ search_placeholder: Search by email
141
+ clear: Clear
142
+ export: Download CSV
143
+ remove: Remove
144
+ removed: "%{email} removed."
145
+ empty: No subscribers yet.
146
+ no_match: "No subscriber matches “%{query}”."
147
+ statuses:
148
+ pending: Pending
149
+ confirmed: Confirmed
150
+ unsubscribed: Unsubscribed
151
+ columns:
152
+ email: Email
153
+ locale: Language
154
+ status: Status
155
+ source: Signed up on
156
+ subscribed_at: Signed up
157
+ recipients: Recipients
158
+ heading: Newsletter
159
+ not_sent: Not sent yet.
160
+ progress: "%{sent} sent, %{failed} failed, %{pending} pending."
161
+ stalled:
162
+ one: "%{count} has been pending for more than an hour (the worker may have died): Retry sends it again."
163
+ other: "%{count} have been pending for more than an hour (the worker may have died): Retry sends them again."
164
+ retry: Retry failed
165
+ retry_queued: Retrying the failed deliveries.
166
+ send: Send…
167
+ send_blocked: "Sending is not possible yet: %{reasons}."
168
+ blockers:
169
+ not_approved: the announcement is not approved
170
+ translations: "a translation is missing or stale (%{locales})"
171
+ from: config.newsletter_from is not set
172
+ address: config.newsletter_address is not set
173
+ base_url: config.canonical_url is not set (the links in emails must be absolute)
174
+ test: "Send a test to %{email}"
175
+ test_locale: Language
176
+ test_sent: "Test sent to %{email} (%{language})."
177
+ test_failed: "The test could not be sent (%{error}). See the log."
178
+ test_no_email: config.current_admin gives no email address, so no test can be sent.
179
+ confirm_title: "Send the newsletter: %{title}"
180
+ recipients: Recipients
181
+ recipients_none: Nobody to send to yet.
182
+ recipients_error: "The recipients could not be listed (%{error}). See the log for details."
183
+ confirm_help: Each recipient receives the email once, in their language. Sending again later only reaches the recipients who have not received it.
184
+ confirm_resend_help: Recipients who already received it are skipped.
185
+ confirm_button:
186
+ one: "Send to %{count} recipient"
187
+ other: "Send to %{count} recipients"
188
+ queued: The newsletter is being sent. Refresh in a moment.
189
+ back: Back to the announcement
190
+ announcements:
191
+ draft: "Draft announcement for %{month}"
192
+ drafted: Announcement draft ready.
193
+ nothing_to_announce: That month has no approved feature or change to announce yet.
194
+ publishing: Announcement
195
+ view_page: View the public page
196
+ external: Published by your app (config.announcements = :external).
197
+ announced: "Your app was notified (on_announcement_approved) at %{time}."
198
+ waiting_for_translations: Your app will be notified once every translation is current.
199
+ announcing: Notifying your app…
200
+ not_approved: Approve it to publish it and notify your app.
201
+ export: "Export:"
202
+ plain_text: Plain text
203
+ preview: Preview
204
+ notify_now: Notify now
205
+ notify_now_hint: Calls your app's hook now, without waiting for missing translations (English is used for them).
206
+ notify_queued: Your app will be notified in a moment.
63
207
  title: Shiplog admin
64
208
  nav:
65
209
  drafts: Drafts
66
210
  board: Roadmap board
67
211
  sync: Sync
68
212
  areas: Areas
213
+ newsletter: Newsletter
69
214
  areas:
70
215
  title: Product areas
71
216
  help: The AI groups entries into areas (at most %{max}) while it rewrites them. Visitors can filter the changelog by area once two areas have published entries.
@@ -126,6 +271,7 @@ en:
126
271
  area_gone: no longer exists
127
272
  regenerate_warning: Regenerating unpublishes this entry until re-approved.
128
273
  source_changed: GitHub text changed since this draft
274
+ entries_changed: The month's entries changed since this draft
129
275
  rewrite_error: Rewrite failed
130
276
  not_rewritten: Not rewritten yet
131
277
  public_text: Public text
@@ -175,6 +321,7 @@ en:
175
321
  roadmap: Roadmap
176
322
  feature: Feature
177
323
  change: Change
324
+ announcement: Announcement
178
325
  columns:
179
326
  entry: Entry
180
327
  kind: Kind
@@ -18,6 +18,7 @@ es:
18
18
  categories:
19
19
  feature: Nuevo
20
20
  change: Mejorado
21
+ announcement: Resumen mensual
21
22
  empty: Todavía no hay nada
22
23
  date_format: "%-d de %B de %Y"
23
24
  roadmap:
@@ -59,17 +60,161 @@ es:
59
60
  nav_label: Más cambios
60
61
  newer: "← Más reciente: %{title}"
61
62
  older: "Anterior: %{title} →"
63
+ announcements:
64
+ read: "Leer el resumen de %{month}"
65
+ back: "← Volver al registro de cambios"
66
+ skeleton:
67
+ features: Novedades
68
+ also_improved: También mejoramos
62
69
  feed:
63
70
  title_with_product: "Registro de cambios de %{product}"
64
71
  link_title: Registro de cambios (Atom)
65
72
  subscribe: Suscribirse (Atom)
73
+ newsletter:
74
+ title: Boletín
75
+ product: Novedades del producto
76
+ back: "← Volver al registro de cambios"
77
+ form:
78
+ title: Recibe el resumen mensual por correo
79
+ hint: Un correo al mes, nada más. Date de baja cuando quieras.
80
+ email_label: Dirección de correo
81
+ email_placeholder: tu@ejemplo.com
82
+ submit: Suscribirme
83
+ trap_label: Deja este campo vacío
84
+ invalid_email: Escribe una dirección de correo válida.
85
+ sent:
86
+ heading: Revisa tu bandeja de entrada
87
+ text: Te hemos enviado un enlace para confirmar la suscripción. Ábrelo para empezar a recibir el resumen mensual. Si no llega en unos minutos, mira en la carpeta de spam.
88
+ confirm:
89
+ heading: Confirma tu suscripción
90
+ text: "Pulsa el botón para recibir el resumen mensual en %{email}."
91
+ button: Confirmar
92
+ done_heading: Suscripción confirmada
93
+ done_text: "El resumen mensual llegará a %{email}. Puedes cambiar el idioma o darte de baja cuando quieras desde tus preferencias."
94
+ preferences:
95
+ heading: Tu suscripción
96
+ email: Correo
97
+ status: Estado
98
+ statuses:
99
+ pending: Pendiente de confirmación
100
+ confirmed: Suscrito
101
+ unsubscribed: Dado de baja
102
+ language: Idioma
103
+ save: Guardar
104
+ saved: Preferencias guardadas.
105
+ unsubscribe: Darme de baja
106
+ unsubscribed: Te has dado de baja.
107
+ resubscribe: Volver a suscribirme
108
+ resubscribed: Vuelves a estar suscrito.
109
+ delete: Borrar mis datos
110
+ delete_help: Borrar tus datos elimina tu dirección de nuestros registros por completo, incluido el registro de tu baja.
111
+ deleted_heading: Tus datos se han borrado
112
+ deleted_text: Tu dirección de correo ya no está guardada. Puedes volver a suscribirte desde el registro de cambios cuando quieras.
113
+ unsubscribe:
114
+ heading: Darse de baja
115
+ text: "¿Dejar de recibir el resumen mensual en %{email}?"
116
+ button: Darme de baja
117
+ done_heading: Te has dado de baja
118
+ done_text: "%{email} ya no recibirá el resumen mensual."
119
+ invalid:
120
+ heading: Este enlace no es válido
121
+ text: Puede que haya caducado o que ya se haya usado. Vuelve a suscribirte desde el registro de cambios para recibir otro.
122
+ rate_limited:
123
+ heading: Demasiados intentos
124
+ text: Inténtalo de nuevo dentro de una hora.
125
+ mail:
126
+ view_in_browser: Ver en el navegador
127
+ changelog_button: Ver todo lo que hemos lanzado
128
+ highlight_button: Ver cómo funciona
129
+ reason: "Recibes este correo porque te suscribiste al resumen mensual de %{product}."
130
+ unsubscribe: Darme de baja
131
+ preferences: Preferencias
132
+ confirm_subject: "Confirma tu suscripción al boletín de %{product}"
133
+ confirm_heading: Un paso más
134
+ confirm_intro: "Confirma que quieres recibir el resumen mensual de %{product} en %{email}."
135
+ confirm_button: Confirmar mi suscripción
136
+ confirm_expiry: "El enlace funciona durante 7 días. Si no has pedido esto, ignora este correo: no se envía nada sin confirmación."
137
+ confirm_link_text: "O copia este enlace:"
66
138
  admin:
139
+ newsletter:
140
+ title: Boletín
141
+ help: Los visitantes que se suscribieron desde el formulario del registro de cambios. Tus propios usuarios (config.newsletter_recipients) no aparecen aquí.
142
+ total: Total
143
+ search: Buscar
144
+ search_placeholder: Buscar por correo
145
+ clear: Limpiar
146
+ export: Descargar CSV
147
+ remove: Eliminar
148
+ removed: "%{email} eliminado."
149
+ empty: Todavía no hay suscriptores.
150
+ no_match: "Ningún suscriptor coincide con “%{query}”."
151
+ statuses:
152
+ pending: Pendiente
153
+ confirmed: Confirmado
154
+ unsubscribed: Dado de baja
155
+ columns:
156
+ email: Correo
157
+ locale: Idioma
158
+ status: Estado
159
+ source: Página de alta
160
+ subscribed_at: Alta
161
+ recipients: Destinatarios
162
+ heading: Boletín
163
+ not_sent: Todavía no se ha enviado.
164
+ progress: "%{sent} enviados, %{failed} fallidos, %{pending} pendientes."
165
+ stalled:
166
+ one: "%{count} lleva más de una hora pendiente (puede que el worker se haya caído): Reintentar lo envía de nuevo."
167
+ other: "%{count} llevan más de una hora pendientes (puede que el worker se haya caído): Reintentar los envía de nuevo."
168
+ retry: Reintentar los fallidos
169
+ retry_queued: Reintentando los envíos fallidos.
170
+ send: Enviar…
171
+ send_blocked: "Todavía no se puede enviar: %{reasons}."
172
+ blockers:
173
+ not_approved: el anuncio no está aprobado
174
+ translations: "falta una traducción o está desactualizada (%{locales})"
175
+ from: config.newsletter_from no está configurado
176
+ address: config.newsletter_address no está configurado
177
+ base_url: config.canonical_url no está configurado (los enlaces de los correos deben ser absolutos)
178
+ test: "Enviar una prueba a %{email}"
179
+ test_locale: Idioma
180
+ test_sent: "Prueba enviada a %{email} (%{language})."
181
+ test_failed: "No se pudo enviar la prueba (%{error}). Consulta el registro."
182
+ test_no_email: config.current_admin no devuelve una dirección de correo, así que no se puede enviar ninguna prueba.
183
+ confirm_title: "Enviar el boletín: %{title}"
184
+ recipients: Destinatarios
185
+ recipients_none: Todavía no hay nadie a quien enviarlo.
186
+ recipients_error: "No se pudo obtener la lista de destinatarios (%{error}). Consulta el registro para más detalles."
187
+ confirm_help: Cada destinatario recibe el correo una sola vez, en su idioma. Si lo envías de nuevo más tarde, solo llega a quienes no lo han recibido.
188
+ confirm_resend_help: Se omite a quienes ya lo recibieron.
189
+ confirm_button:
190
+ one: "Enviar a %{count} destinatario"
191
+ other: "Enviar a %{count} destinatarios"
192
+ queued: El boletín se está enviando. Actualiza la página en un momento.
193
+ back: Volver al anuncio
194
+ announcements:
195
+ draft: "Redactar el anuncio de %{month}"
196
+ drafted: El borrador del anuncio está listo.
197
+ nothing_to_announce: Ese mes aún no tiene ninguna función o cambio aprobado que anunciar.
198
+ publishing: Anuncio
199
+ view_page: Ver la página pública
200
+ external: Lo publica tu aplicación (config.announcements = :external).
201
+ announced: "Se avisó a tu aplicación (on_announcement_approved) el %{time}."
202
+ waiting_for_translations: Se avisará a tu aplicación cuando todas las traducciones estén al día.
203
+ announcing: Avisando a tu aplicación…
204
+ not_approved: Apruébalo para publicarlo y avisar a tu aplicación.
205
+ export: "Exportar:"
206
+ plain_text: Texto sin formato
207
+ preview: Vista previa
208
+ notify_now: Avisar ahora
209
+ notify_now_hint: Llama ahora al hook de tu aplicación, sin esperar las traducciones que falten (se usa el inglés).
210
+ notify_queued: Se avisará a tu aplicación en un momento.
67
211
  title: Administración de Shiplog
68
212
  nav:
69
213
  drafts: Borradores
70
214
  board: Tablero de la hoja de ruta
71
215
  sync: Sincronización
72
216
  areas: Áreas
217
+ newsletter: Boletín
73
218
  areas:
74
219
  title: Áreas del producto
75
220
  help: La IA agrupa las entradas en áreas (como máximo %{max}) mientras las reescribe. Los visitantes pueden filtrar el registro de cambios por área cuando hay dos áreas con entradas publicadas.
@@ -130,6 +275,7 @@ es:
130
275
  area_gone: ya no existe
131
276
  regenerate_warning: Al regenerar, esta entrada deja de estar publicada hasta que se apruebe de nuevo.
132
277
  source_changed: El texto de GitHub cambió desde este borrador
278
+ entries_changed: Las entradas del mes cambiaron desde este borrador
133
279
  rewrite_error: Falló la reescritura
134
280
  not_rewritten: Todavía sin reescribir
135
281
  public_text: Texto público
@@ -179,6 +325,7 @@ es:
179
325
  roadmap: Hoja de ruta
180
326
  feature: Función
181
327
  change: Cambio
328
+ announcement: Anuncio
182
329
  columns:
183
330
  entry: Entrada
184
331
  kind: Tipo
data/config/routes.rb CHANGED
@@ -7,5 +7,18 @@ Shiplog::Engine.routes.draw do
7
7
  format: false, constraints: { slug: /[a-z0-9-]+/ }
8
8
  get Shiplog.config.changelog_path, to: "changelogs#show", as: :changelog
9
9
  post "shiplog/github/webhook", to: "webhooks#create", as: :github_webhook
10
+ # The newsletter's pages live under shiplog/ (like the webhook), so a host that constrains the engine's mount to
11
+ # the changelog, the roadmap and /shiplog/ lets them through. Links from emails open a page with a button
12
+ # (GET), and the button POSTs: nothing changes state on a GET. A token is URL-safe base64 or base58.
13
+ token = { token: /[A-Za-z0-9_-]+/ }
14
+ get "shiplog/newsletter/subscription", to: "newsletter/subscriptions#show", as: :newsletter_subscription
15
+ post "shiplog/newsletter/subscription", to: "newsletter/subscriptions#create"
16
+ get "shiplog/newsletter/confirm/:token", to: "newsletter/confirmations#show", as: :newsletter_confirmation, constraints: token, format: false
17
+ post "shiplog/newsletter/confirm/:token", to: "newsletter/confirmations#create", constraints: token, format: false
18
+ get "shiplog/newsletter/preferences/:token", to: "newsletter/preferences#show", as: :newsletter_preferences, constraints: token, format: false
19
+ patch "shiplog/newsletter/preferences/:token", to: "newsletter/preferences#update", constraints: token, format: false
20
+ delete "shiplog/newsletter/preferences/:token", to: "newsletter/preferences#destroy", constraints: token, format: false
21
+ get "shiplog/newsletter/unsubscribe/:token", to: "newsletter/unsubscriptions#show", as: :newsletter_unsubscription, constraints: token, format: false
22
+ post "shiplog/newsletter/unsubscribe/:token", to: "newsletter/unsubscriptions#create", constraints: token, format: false
10
23
  get "shiplog/assets/:version/:name", to: "frontends#show", as: :frontend, constraints: { version: /[^\/]+/, name: /[a-z_]+\.(css|js)/ }
11
24
  end
@@ -0,0 +1,19 @@
1
+ # Everything 0.4.0 adds to the schema, one section per feature. Additive and reversible.
2
+ class Shiplog040 < ActiveRecord::Migration[8.1]
3
+ def change
4
+ # --- Monthly announcements ---
5
+ # The subject of an announcement entry: one row per month (starts_on is its first day, ends_on its last), so
6
+ # Shiplog::Entry keeps its polymorphic subject and its unique (subject, kind) index makes drafting idempotent.
7
+ create_table :shiplog_periods do |t|
8
+ t.date :starts_on, null: false
9
+ t.date :ends_on, null: false
10
+ t.timestamps
11
+ end
12
+ add_index :shiplog_periods, :starts_on, unique: true
13
+
14
+ # When config.on_announcement_approved was called for the announcement's current approval: set once it is
15
+ # approved and every configured locale has a current translation, cleared when it returns to draft, so the
16
+ # hook runs once per approval.
17
+ add_column :shiplog_entries, :announced_at, :datetime
18
+ end
19
+ end
@@ -0,0 +1,44 @@
1
+ # The newsletter's tables (0.4.0, second part). Additive and reversible.
2
+ class Shiplog040Newsletter < ActiveRecord::Migration[8.1]
3
+ def change
4
+ # --- Newsletter ---
5
+ # Visitors who subscribed through Shiplog's form, and the addresses that unsubscribed (a suppression record,
6
+ # also for the host's own users, who are otherwise never stored). Double opt-in: confirmation_nonce is the
7
+ # single-use secret of the pending confirmation link, cleared when it is used; confirmed_at is set then;
8
+ # unsubscribed_at keeps an unsubscribed address suppressed; token reaches the preferences page without an
9
+ # account (rotated after each change there); source is the page the visitor signed up on (a consent record).
10
+ # Unique on the lowercased email.
11
+ create_table :shiplog_newsletter_subscribers do |t|
12
+ t.string :email, null: false
13
+ t.string :locale, null: false
14
+ t.string :token, null: false
15
+ t.string :confirmation_nonce
16
+ t.datetime :confirmed_at
17
+ t.datetime :unsubscribed_at
18
+ t.string :source
19
+ t.timestamps
20
+ end
21
+ add_index :shiplog_newsletter_subscribers, "lower(email)", unique: true, name: "index_shiplog_newsletter_subscribers_on_lower_email"
22
+ add_index :shiplog_newsletter_subscribers, :token, unique: true
23
+ add_index :shiplog_newsletter_subscribers, :confirmation_nonce, unique: true
24
+ add_index :shiplog_newsletter_subscribers, [ :confirmed_at, :unsubscribed_at ], name: "index_shiplog_newsletter_subscribers_on_status"
25
+
26
+ # One row per announcement and recipient ("User/42" for the host's users, "Subscriber/7" for Shiplog's
27
+ # subscribers), inserted before the email is sent: a retried job or a second click never sends twice.
28
+ # status is pending (claimed), sent or failed (error says why, without the address, for the admin's "Retry
29
+ # failed"). No email address is kept here; rows older than 180 days are purged.
30
+ create_table :shiplog_newsletter_deliveries do |t|
31
+ t.references :announcement, null: false, foreign_key: { to_table: :shiplog_entries, on_delete: :cascade }, index: false
32
+ t.string :recipient_key, null: false
33
+ t.string :locale, null: false
34
+ t.string :status, null: false, default: "pending"
35
+ t.datetime :sent_at
36
+ t.text :error
37
+ t.timestamps
38
+ end
39
+ add_index :shiplog_newsletter_deliveries, [ :announcement_id, :recipient_key ], unique: true, name: "index_shiplog_newsletter_deliveries_on_announcement_and_key"
40
+ add_index :shiplog_newsletter_deliveries, [ :announcement_id, :status ], name: "index_shiplog_newsletter_deliveries_on_announcement_and_status"
41
+ add_index :shiplog_newsletter_deliveries, :recipient_key
42
+ add_index :shiplog_newsletter_deliveries, :created_at
43
+ end
44
+ end
@@ -68,7 +68,20 @@ module Shiplog
68
68
  area_link: "rounded-full border border-gray-300 px-3 py-0.5 text-sm hover:border-indigo-500 dark:border-gray-700 [&.shiplog-areas__link--current]:border-indigo-600 [&.shiplog-areas__link--current]:bg-indigo-600 [&.shiplog-areas__link--current]:text-white",
69
69
  entry_area: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
70
70
  entry_images: "mt-3 grid w-full gap-3",
71
- entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800"
71
+ entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800",
72
+ announcement: "rounded-xl border border-gray-200 border-l-4 border-l-indigo-600 bg-white p-5 dark:border-gray-800 dark:border-l-indigo-500 dark:bg-gray-900",
73
+ announcement_title: "mt-2 mb-4 text-2xl font-semibold text-gray-900 dark:text-white",
74
+ announcement_body: "leading-relaxed text-gray-700 dark:text-gray-300 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold [&_h4]:mt-4 [&_h4]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-indigo-600 [&_a]:underline dark:[&_a]:text-indigo-400 [&_p]:my-2",
75
+ month_announcement: "-mt-1 mb-3",
76
+ month_announcement_link: "text-sm font-semibold text-indigo-600 hover:underline dark:text-indigo-400",
77
+ newsletter: "my-10 rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-gray-800 dark:bg-gray-900/50",
78
+ newsletter_title: "text-lg font-semibold",
79
+ newsletter_hint: "mt-1 mb-3 text-sm text-gray-500 dark:text-gray-400",
80
+ newsletter_form: "flex flex-wrap items-center gap-2",
81
+ newsletter_label: "text-sm font-medium",
82
+ newsletter_input: "min-w-0 flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-900",
83
+ newsletter_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 [&.shiplog-newsletter__button--secondary]:bg-transparent [&.shiplog-newsletter__button--secondary]:text-gray-700 [&.shiplog-newsletter__button--secondary]:ring-1 [&.shiplog-newsletter__button--secondary]:ring-gray-300 dark:[&.shiplog-newsletter__button--secondary]:text-gray-300",
84
+ newsletter_notice: "mb-3 text-sm [&.shiplog-newsletter__notice--error]:text-red-600"
72
85
  }.freeze
73
86
 
74
87
  def copy_initializer
@@ -174,6 +174,56 @@ Shiplog.configure do |config|
174
174
  # decide which URLs set the locale (e.g. a /es scope). The default locale is dropped from the list.
175
175
  # config.locales = [] # e.g. [:es]
176
176
 
177
+ # Monthly announcements: a short summary of what shipped in a month, drafted by config.rewriter from the month's
178
+ # approved entries and the small changes that got none, and approved in the admin like any entry. Schedule
179
+ # Shiplog::DraftAnnouncementJob early each month (it drafts the previous month), or use the inbox's
180
+ # "Draft announcement" buttons. Where it is published:
181
+ # :page (default) Shiplog renders /changelog/2026-10, links it from the month's heading and adds it to the feed
182
+ # :external your app publishes it (on_announcement_approved below); the month heading links to announcement_url
183
+ # false no announcements
184
+ # config.announcements = :page
185
+ # config.announcement_url = ->(announcement, locale) { main_app.news_path(announcement.slug) }
186
+
187
+ # Called with the announcement once it is approved and translated into every configured locale, once per approval
188
+ # (also sent as the `announcement_approved.shiplog` notification). The announcement offers display_title(locale),
189
+ # to_markdown(locale), to_html(locale), to_text(locale) and public_url, to publish it elsewhere or email it.
190
+ # config.on_announcement_approved = ->(announcement) { NewsletterJob.perform_later(announcement) }
191
+
192
+ # Newsletter: the approved announcement emailed, in each recipient's language, to your users who opted in and to
193
+ # the visitors who subscribed through Shiplog's form (double opt-in, stored in shiplog_newsletter_subscribers).
194
+ # Sending is an explicit admin action on the announcement's page, once per recipient. Needs newsletter_from,
195
+ # newsletter_address and canonical_url. Schedule Shiplog::PurgeNewsletterJob weekly (unconfirmed addresses).
196
+ # config.newsletter = false
197
+
198
+ # Your users who opted in: a callable returning objects (or a relation, read in batches) responding to #email,
199
+ # #locale and #id. Shiplog never stores them. Resolve the locale yourself (the user's, then a default).
200
+ # config.newsletter_recipients = -> { User.where(newsletter: true) }
201
+
202
+ # Your own unsubscribe or settings page for those users, linked in the footer as "Preferences". The one-click
203
+ # unsubscribe of mail providers always comes to Shiplog, which calls on_newsletter_unsubscribe below.
204
+ # config.newsletter_unsubscribe_url = ->(user) { Rails.application.routes.url_helpers.settings_url(host: "example.com") }
205
+
206
+ # Called with the lowercased email on every unsubscribe (one-click, the footer link, the preferences page), so your
207
+ # own setting follows: Shiplog suppresses the address itself either way, and skips it at send time whatever your
208
+ # list says. When a user opts in again in your app, call Shiplog::Newsletter.resubscribe(user.email) to lift the
209
+ # suppression. Errors are logged, never shown to the visitor.
210
+ # config.on_newsletter_unsubscribe = ->(email) { User.where("lower(email) = ?", email).update_all(newsletter: false) }
211
+
212
+ # The sender (also of the confirmation email) and the postal address in the footer (CAN-SPAM requires one).
213
+ # config.newsletter_from = "Acme <updates@example.com>"
214
+ # config.newsletter_address = "Acme Inc., 123 Main Street, Springfield"
215
+
216
+ # Branding: the logo (an http(s) URL, shown 140px wide) and the accent colour of buttons and links.
217
+ # config.newsletter_logo_url = "https://example.com/logo.png"
218
+ # config.newsletter_accent = "#0969da"
219
+
220
+ # Merged into every newsletter message (not the confirmation email): ActionMailer options or headers your
221
+ # delivery gem reads. With Postmark, a broadcast stream keeps complaints away from your transactional email.
222
+ # config.newsletter_mailer_options = { message_stream: "broadcast" }
223
+
224
+ # How many recipients a run claims and emails at a time (Postmark's batch limit by default).
225
+ # config.newsletter_batch_size = 500
226
+
177
227
  # Callables above cannot be part of the public pages' ETag. Bump this when the output of a callable you
178
228
  # configured changes (a new label, another path) so cached pages are refreshed.
179
229
  # config.cache_version = nil
@@ -58,7 +58,20 @@ module Shiplog
58
58
  area_link: "shiplog-areas__link",
59
59
  entry_area: "shiplog-entry__area",
60
60
  entry_images: "shiplog-entry__images",
61
- entry_image: "shiplog-entry__image"
61
+ entry_image: "shiplog-entry__image",
62
+ announcement: "shiplog-announcement",
63
+ announcement_title: "shiplog-announcement__title",
64
+ announcement_body: "shiplog-announcement__body",
65
+ month_announcement: "shiplog-month__announcement",
66
+ month_announcement_link: "shiplog-month__announcement-link",
67
+ newsletter: "shiplog-newsletter",
68
+ newsletter_title: "shiplog-newsletter__title",
69
+ newsletter_hint: "shiplog-newsletter__hint",
70
+ newsletter_form: "shiplog-newsletter__form",
71
+ newsletter_label: "shiplog-newsletter__label",
72
+ newsletter_input: "shiplog-newsletter__input",
73
+ newsletter_button: "shiplog-newsletter__button",
74
+ newsletter_notice: "shiplog-newsletter__notice"
62
75
  }.freeze
63
76
 
64
77
  KEYS = BEM.keys.freeze