shiplog 0.2.0 → 0.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +140 -5
  4. data/app/controllers/shiplog/admin/area_detections_controller.rb +15 -0
  5. data/app/controllers/shiplog/admin/area_translations_controller.rb +19 -0
  6. data/app/controllers/shiplog/admin/areas/merges_controller.rb +19 -0
  7. data/app/controllers/shiplog/admin/areas/moves_controller.rb +13 -0
  8. data/app/controllers/shiplog/admin/areas_controller.rb +48 -0
  9. data/app/controllers/shiplog/admin/entries/images/moves_controller.rb +20 -0
  10. data/app/controllers/shiplog/admin/entries/images_controller.rb +76 -0
  11. data/app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb +23 -0
  12. data/app/controllers/shiplog/admin/entries/translations_controller.rb +35 -0
  13. data/app/controllers/shiplog/admin/entries_controller.rb +9 -3
  14. data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
  15. data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
  16. data/app/controllers/shiplog/changelogs_controller.rb +21 -5
  17. data/app/controllers/shiplog/public_controller.rb +55 -0
  18. data/app/controllers/shiplog/roadmaps_controller.rb +3 -1
  19. data/app/frontend/shiplog/admin.css +9 -0
  20. data/app/frontend/shiplog/shiplog.css +47 -2
  21. data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
  22. data/app/helpers/shiplog/permalink_helper.rb +113 -0
  23. data/app/helpers/shiplog/roadmap_helper.rb +9 -0
  24. data/app/helpers/shiplog/unseen_helper.rb +9 -0
  25. data/app/jobs/shiplog/detect_areas_job.rb +18 -0
  26. data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
  27. data/app/jobs/shiplog/translate_area_job.rb +25 -0
  28. data/app/jobs/shiplog/translate_entry_job.rb +25 -0
  29. data/app/models/shiplog/area.rb +156 -0
  30. data/app/models/shiplog/area_detector.rb +146 -0
  31. data/app/models/shiplog/area_translator.rb +77 -0
  32. data/app/models/shiplog/entry/images.rb +127 -0
  33. data/app/models/shiplog/entry.rb +204 -5
  34. data/app/models/shiplog/entry_translation.rb +15 -0
  35. data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
  36. data/app/models/shiplog/image_fetcher/sources.rb +41 -0
  37. data/app/models/shiplog/image_fetcher.rb +72 -0
  38. data/app/models/shiplog/image_type.rb +111 -0
  39. data/app/models/shiplog/rewriter/prompt.rb +31 -7
  40. data/app/models/shiplog/rewriter.rb +41 -23
  41. data/app/models/shiplog/setting.rb +19 -0
  42. data/app/models/shiplog/translator/prompt.rb +40 -0
  43. data/app/models/shiplog/translator.rb +99 -0
  44. data/app/views/layouts/shiplog/application.html.erb +3 -0
  45. data/app/views/shiplog/_empty.html.erb +1 -1
  46. data/app/views/shiplog/_pagination.html.erb +3 -3
  47. data/app/views/shiplog/admin/_nav.html.erb +2 -1
  48. data/app/views/shiplog/admin/areas/index.html.erb +78 -0
  49. data/app/views/shiplog/admin/entries/_entry_row.html.erb +3 -0
  50. data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
  51. data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
  52. data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
  53. data/app/views/shiplog/changelog_entries/show.html.erb +25 -0
  54. data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
  55. data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
  56. data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
  57. data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
  58. data/app/views/shiplog/changelogs/_search.html.erb +4 -2
  59. data/app/views/shiplog/changelogs/show.html.erb +11 -4
  60. data/app/views/shiplog/roadmaps/_card.html.erb +2 -2
  61. data/app/views/shiplog/roadmaps/_column.html.erb +3 -3
  62. data/app/views/shiplog/roadmaps/_shipped.html.erb +1 -1
  63. data/app/views/shiplog/roadmaps/show.html.erb +2 -2
  64. data/config/admin_routes.rb +16 -0
  65. data/config/locales/en.yml +85 -0
  66. data/config/locales/es.yml +223 -0
  67. data/config/routes.rb +5 -0
  68. data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
  69. data/lib/generators/shiplog/install/install_generator.rb +10 -1
  70. data/lib/generators/shiplog/install/templates/initializer.rb.tt +45 -1
  71. data/lib/shiplog/classes.rb +10 -1
  72. data/lib/shiplog/configuration.rb +92 -3
  73. data/lib/shiplog/engine.rb +3 -0
  74. data/lib/shiplog/github/queries.rb +13 -1
  75. data/lib/shiplog/version.rb +1 -1
  76. data/lib/shiplog.rb +73 -0
  77. data/lib/tasks/shiplog.rake +29 -0
  78. metadata +39 -1
@@ -0,0 +1,35 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # An admin's edit of an entry's translation (title and body) into one of config.locales. It is published
5
+ # at once and kept until the English text changes: the translation is marked edited and made from the
6
+ # entry's current English text, so it shows even if it was stale.
7
+ class TranslationsController < ApplicationController
8
+ before_action :set_entry_and_locale
9
+
10
+ def update
11
+ translation = @entry.translations.find_or_initialize_by(locale: @locale)
12
+ translation.assign_attributes(translation_params)
13
+ translation.assign_attributes(edited_by_admin: true, source_digest: @entry.translation_source_digest)
14
+ if translation.save
15
+ redirect_to edit_entry_path(@entry, inbox_filter_params), notice: t("shiplog.admin.entries.translations.saved", default: "Translation saved.")
16
+ else
17
+ redirect_to edit_entry_path(@entry, inbox_filter_params), alert: translation.errors.full_messages.to_sentence
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def set_entry_and_locale
24
+ @entry = Entry.find(params[:entry_id])
25
+ @locale = params[:translation_locale].to_s
26
+ head :not_found unless Entry.translated_locale?(@locale)
27
+ end
28
+
29
+ def translation_params
30
+ params.expect(translation: %i[title body])
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,7 +12,7 @@ module Shiplog
12
12
  @page = page_number
13
13
  scope = Entry.where(status: @status)
14
14
  scope = scope.where(kind: @kind) if @kind
15
- records = scope.includes(:subject).order(updated_at: :desc, id: :desc)
15
+ records = scope.includes(:subject, :area).order(updated_at: :desc, id: :desc)
16
16
  .offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
17
17
  @more = records.size > PER_PAGE
18
18
  @entries = records.first(PER_PAGE)
@@ -23,11 +23,17 @@ module Shiplog
23
23
  end
24
24
 
25
25
  def update
26
- if @entry.update(entry_params)
26
+ @entry.assign_attributes(entry_params)
27
+ @entry.area_set_by_admin = true if @entry.area_id_changed? # the admin's choice, which detection never overwrites
28
+ if @entry.save
27
29
  redirect_to root_path(inbox_filter_params), notice: t("shiplog.admin.entries.saved")
28
30
  else
29
31
  render :edit, status: :unprocessable_content
30
32
  end
33
+ rescue ActiveRecord::InvalidForeignKey
34
+ # The selected area was deleted between the validation and the save.
35
+ @entry.errors.add(:area_id, t("shiplog.admin.entries.area_gone", default: "no longer exists"))
36
+ render :edit, status: :unprocessable_content
31
37
  rescue ActiveRecord::RecordNotUnique
32
38
  # Two concurrent kind switches can both pass the validation; the unique index has the last word.
33
39
  @entry.errors.add(:changelog_kind, :taken)
@@ -48,7 +54,7 @@ module Shiplog
48
54
  end
49
55
 
50
56
  def entry_params
51
- params.expect(entry: %i[title body released_on changelog_kind])
57
+ params.expect(entry: %i[title body released_on changelog_kind area_id])
52
58
  end
53
59
 
54
60
  def page_number
@@ -0,0 +1,26 @@
1
+ module Shiplog
2
+ # The permalink page of a published feature or change: /changelog/:slug.
3
+ class ChangelogEntriesController < PublicController
4
+ def show
5
+ @entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug])
6
+ return pass_to_host_routes unless @entry
7
+
8
+ notify_changelog_seen
9
+ @newer = @entry.newer_changelog_entry
10
+ @older = @entry.older_changelog_entry
11
+ preload_translations(@entry, @newer, @older)
12
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, [ @entry, @newer, @older ].map { |entry| [ entry&.id, entry&.updated_at, entry&.area_id ] },
13
+ shiplog_image_etag_parts([ @entry ]), shiplog_translation_etag_parts([ @entry, @newer, @older ]) ]
14
+ end
15
+
16
+ private
17
+
18
+ # The engine is usually mounted at "/", so /changelog/:slug can shadow a host route below the changelog
19
+ # path (/changelog/archive, declared after the mount). An unknown slug answers 404 with X-Cascade: pass,
20
+ # which makes the router try the routes after the engine's mount; if none matches, the host's own 404 shows.
21
+ def pass_to_host_routes
22
+ response.set_header("X-Cascade", "pass")
23
+ head :not_found
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ module Shiplog
2
+ # The Atom feed of the latest published features and changes: /changelog.atom.
3
+ class ChangelogFeedsController < PublicController
4
+ LIMIT = 50
5
+
6
+ layout false
7
+ before_action :set_area
8
+
9
+ def show
10
+ scope = Entry.published_changelog.newest_first
11
+ scope = scope.where(area_id: @area.id) if @area
12
+ @entries = scope.includes(:area).with_images_loaded.limit(LIMIT).to_a
13
+ @entries.select!(&:permalink?) # no slug (or an invalid one) means no stable id: left out
14
+ preload_translations(@entries)
15
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @entries.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
16
+ shiplog_image_etag_parts(@entries), shiplog_translation_etag_parts(@entries) ]
17
+ end
18
+ end
19
+ end
@@ -2,24 +2,40 @@ module Shiplog
2
2
  class ChangelogsController < PublicController
3
3
  PER_PAGE = 30
4
4
  MAX_PAGE = 100_000
5
+ MIN_FILTER_AREAS = 2
6
+
7
+ before_action :set_area, only: :show
5
8
 
6
9
  def show
7
10
  @page = page_number
8
- scope = Entry.approved.where(kind: %w[feature change]).where.not(released_on: nil)
9
- .order(released_on: :desc, approved_at: :desc, id: :desc)
11
+ scope = Entry.published_changelog.newest_first
10
12
  @query = search_query
11
- scope = scope.search(@query) if @query
12
- records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
13
+ scope = scope.search(@query, locale: I18n.locale) if @query
14
+ scope = scope.where(area_id: @area.id) if @area
15
+ @areas = filter_areas
16
+ records = scope.includes(:area).with_images_loaded.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
13
17
  return head(:not_found) if @page > 1 && records.empty?
14
18
 
19
+ notify_changelog_seen
15
20
  @older = records.size > PER_PAGE
16
21
  @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
17
22
  @result_count = scope.count if @query
18
- fresh_when etag: [ *shiplog_etag_parts, @query, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
23
+ shown = @months.values.flatten
24
+ preload_translations(shown)
25
+ fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @areas.map(&:id), @query, @page, @older,
26
+ shown.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
27
+ shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown) ]
19
28
  end
20
29
 
21
30
  private
22
31
 
32
+ # The areas to offer as filter links: those with published entries, in the admin's order, and only when there
33
+ # are at least two (a single area filters nothing).
34
+ def filter_areas
35
+ areas = Area.ordered.where(id: Entry.published_changelog.where.not(area_id: nil).select(:area_id)).to_a
36
+ areas.size >= MIN_FILTER_AREAS ? areas : []
37
+ end
38
+
23
39
  # The normalised search query (words joined by one space), or nil when search is off or the query is blank.
24
40
  def search_query
25
41
  return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
@@ -8,10 +8,65 @@ module Shiplog
8
8
  helper Shiplog::SwitcherHelper
9
9
  helper Shiplog::RoadmapHelper
10
10
  helper Shiplog::HostRoutesHelper
11
+ helper Shiplog::PermalinkHelper
11
12
  layout -> { Shiplog.config.public_layout }
12
13
 
13
14
  private
14
15
 
16
+ # Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
17
+ # the page renders (the actions call it once they know the page exists, ahead of fresh_when and the
18
+ # implicit render), so a host navbar badge in the same response shows the count the callback just stored,
19
+ # also when the answer ends up a 304 (a revisit is a visit). Not called for 404s or the feed.
20
+ # The response is not sent yet, so the callback can still set cookies or headers. A failing callback is
21
+ # logged and never breaks the page.
22
+ def notify_changelog_seen
23
+ callback = Shiplog.config.on_changelog_seen
24
+ return unless callback && request.format.html?
25
+
26
+ callback.call(self)
27
+ rescue StandardError => error
28
+ Rails.logger.error("[shiplog] on_changelog_seen failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
29
+ end
30
+
31
+ # Sets @area from ?area=<slug>. A blank or non-string value is ignored; a slug no area has is a 404 (a
32
+ # renamed or deleted area has no page to show, rather than the unfiltered list under a filtered URL).
33
+ def set_area
34
+ slug = params[:area]
35
+ return unless slug.is_a?(String) && slug.present?
36
+
37
+ @area = Area.find_by(slug:)
38
+ head(:not_found) unless @area
39
+ end
40
+
41
+ # What the markup shows of the areas: names, translated names, slugs and order of every area (the tags and the filter), which
42
+ # entries' updated_at don't reflect when an area is renamed or entries are moved in bulk.
43
+ def shiplog_area_etag_parts
44
+ [ @area&.id, Area.ordered.pluck(:id, :name, :slug, :position, :name_translations) ]
45
+ end
46
+
47
+ # What the markup shows of the entries' screenshots: their blob ids, order and alt texts. Nothing while
48
+ # screenshots are off. Load the entries with `Entry.with_images_loaded` to avoid a query per entry.
49
+ def shiplog_image_etag_parts(entries)
50
+ Shiplog.images? ? entries.compact.map { |entry| [ entry.id, entry.image_etag_parts ] } : []
51
+ end
52
+
53
+ # Loads the translations the page shows (the request locale's) for these entries in one query; see
54
+ # Entry.preload_translations. Call it before the entries are rendered or their ETag is computed.
55
+ def preload_translations(*entries)
56
+ Entry.preload_translations(entries.flatten)
57
+ end
58
+
59
+ # What the markup shows of the entries' translations: which current translation each entry shows, and when
60
+ # it last changed. Nothing under a locale that is not translated.
61
+ def shiplog_translation_etag_parts(entries)
62
+ return [] unless Entry.translated_locale?(I18n.locale)
63
+
64
+ entries.compact.filter_map do |entry|
65
+ translation = entry.translation
66
+ [ entry.id, translation.id, translation.updated_at ] if translation
67
+ end
68
+ end
69
+
15
70
  # What the rendered markup depends on besides the data: the locale, the gem version and the styling
16
71
  # config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
17
72
  def shiplog_etag_parts
@@ -4,7 +4,9 @@ module Shiplog
4
4
  @columns = Roadmap.new(published_only: true).columns
5
5
  preload_cards(@columns)
6
6
  @shipped = recently_shipped
7
- fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped) ]
7
+ shown = [ *@columns.values.flatten.map(&:entry), *@shipped ]
8
+ preload_translations(shown)
9
+ fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped), shiplog_translation_etag_parts(shown) ]
8
10
  end
9
11
 
10
12
  private
@@ -103,6 +103,7 @@
103
103
  }
104
104
  .shiplog-admin .shiplog-badge--warning { border-color: #bf8700; color: #9a6700; }
105
105
  .shiplog-admin .shiplog-badge--error { border-color: #cf222e; color: #cf222e; }
106
+ .shiplog-admin .shiplog-badge--area { border-color: var(--sa-accent); color: var(--sa-accent); }
106
107
 
107
108
  .shiplog-admin .shiplog-button {
108
109
  background: var(--sa-surface);
@@ -115,6 +116,7 @@
115
116
  padding: 0.25rem 0.75rem;
116
117
  text-decoration: none;
117
118
  }
119
+ .shiplog-admin .shiplog-button:disabled { cursor: default; opacity: 0.5; }
118
120
  .shiplog-admin .shiplog-button--primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
119
121
 
120
122
  .shiplog-admin .shiplog-admin-split { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
@@ -125,6 +127,13 @@
125
127
  .shiplog-admin .shiplog-admin-form textarea { box-sizing: border-box; font: inherit; width: 100%; }
126
128
  .shiplog-admin .shiplog-admin-actions { border-top: 1px solid var(--sa-border); display: grid; gap: 0.75rem; margin-top: 1rem; padding-top: 1rem; }
127
129
  .shiplog-admin .shiplog-admin-inline-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
130
+ .shiplog-admin .shiplog-admin-images { margin-top: 1rem; }
131
+ .shiplog-admin .shiplog-admin-images__list { display: grid; gap: 1rem; list-style: none; margin: 0 0 1rem; padding: 0; }
132
+ .shiplog-admin .shiplog-admin-images__item { align-items: flex-start; border-bottom: 1px solid var(--sa-border); display: flex; flex-wrap: wrap; gap: 1rem; padding-bottom: 1rem; }
133
+ .shiplog-admin .shiplog-admin-images__thumb { border: 1px solid var(--sa-border); border-radius: 0.25rem; height: auto; max-height: 8rem; max-width: 12rem; object-fit: contain; }
134
+ .shiplog-admin .shiplog-admin-images__controls { display: grid; flex: 1; gap: 0.5rem; min-width: 16rem; }
135
+ .shiplog-admin .shiplog-admin__visually-hidden { border: 0; clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; }
136
+ .shiplog-admin .shiplog-area-row__count { text-align: right; }
128
137
  .shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
129
138
 
130
139
  .shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
@@ -152,7 +152,8 @@
152
152
  .shiplog-entries { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
153
153
 
154
154
  .shiplog-card,
155
- .shiplog-entry {
155
+ .shiplog-entry,
156
+ .shiplog-permalink {
156
157
  background: var(--sl-card);
157
158
  border: 1px solid var(--sl-border);
158
159
  border-radius: var(--sl-radius);
@@ -241,10 +242,14 @@
241
242
  width: 0.75rem;
242
243
  }
243
244
 
244
- .shiplog-entry { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
245
+ .shiplog-entry,
246
+ .shiplog-permalink { align-items: center; display: flex; flex-wrap: wrap; gap: 0.25rem 0.625rem; }
245
247
  .shiplog-entry__title,
246
248
  .shiplog-entry__body { flex-basis: 100%; }
247
249
  .shiplog-entry__title { font-size: 1rem; margin: 0.25rem 0 0; }
250
+ .shiplog-entry__title a { color: inherit; text-decoration: none; }
251
+ .shiplog-entry__title a:hover { color: var(--sl-accent); text-decoration: underline; }
252
+ .shiplog-entry__title a:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
248
253
  .shiplog-entry__body { color: var(--sl-muted); }
249
254
  .shiplog-entry__date { color: var(--sl-muted); font-size: 0.8125rem; }
250
255
 
@@ -256,6 +261,9 @@
256
261
  font-weight: 600;
257
262
  padding: 0 0.5rem;
258
263
  }
264
+ .shiplog-permalink .shiplog-entry__title { font-size: 1.5rem; }
265
+ .shiplog-permalink--feature { border-left: 4px solid var(--sl-accent); }
266
+ .shiplog-permalink--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
259
267
  .shiplog-entry--feature { border-left: 4px solid var(--sl-accent); padding: var(--sl-space) calc(var(--sl-space) * 1.25); }
260
268
  .shiplog-entry--feature .shiplog-entry__title { font-size: 1.25rem; }
261
269
  .shiplog-entry--feature .shiplog-entry__category { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
@@ -264,6 +272,43 @@
264
272
  .shiplog-pagination__link { color: var(--sl-accent); }
265
273
  .shiplog-pagination__link--older { margin-left: auto; }
266
274
 
275
+ .shiplog-permalink__back { color: var(--sl-accent); display: inline-block; margin-top: var(--sl-space); }
276
+ .shiplog-permalink__nav {
277
+ display: flex;
278
+ flex-wrap: wrap;
279
+ gap: var(--sl-space);
280
+ justify-content: space-between;
281
+ margin-top: calc(var(--sl-space) * 2);
282
+ }
283
+ .shiplog-permalink__nav-link { color: var(--sl-accent); }
284
+ .shiplog-permalink__nav-link--older { margin-left: auto; text-align: right; }
285
+ .shiplog-permalink__back:focus-visible,
286
+ .shiplog-permalink__nav-link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
287
+
288
+ .shiplog-entry__area {
289
+ color: var(--sl-muted);
290
+ font-size: 0.75rem;
291
+ font-weight: 600;
292
+ letter-spacing: 0.02em;
293
+ text-transform: uppercase;
294
+ }
295
+
296
+ .shiplog-entry__images { display: grid; flex-basis: 100%; gap: 0.75rem; margin-top: 0.5rem; }
297
+ .shiplog-entry__image { border: 1px solid var(--sl-border); border-radius: 0.5rem; display: block; height: auto; max-width: 100%; }
298
+
299
+ .shiplog-areas { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
300
+ .shiplog-areas__link {
301
+ border: 1px solid var(--sl-border);
302
+ border-radius: 1rem;
303
+ color: var(--sl-fg);
304
+ font-size: 0.875rem;
305
+ padding: 0.125rem 0.75rem;
306
+ text-decoration: none;
307
+ }
308
+ .shiplog-areas__link:hover { border-color: var(--sl-accent); }
309
+ .shiplog-areas__link:focus-visible { outline: 2px solid var(--sl-accent); outline-offset: 2px; }
310
+ .shiplog-areas__link--current { background: var(--sl-accent); border-color: var(--sl-accent); color: #fff; }
311
+
267
312
  .shiplog-search { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: var(--sl-space) 0; }
268
313
  .shiplog-search__input {
269
314
  flex: 1 1 14rem;
@@ -19,6 +19,21 @@ module Shiplog
19
19
  entry.title.presence || source_title(entry.subject)
20
20
  end
21
21
 
22
+ # The URL of an image's thumbnail: a 200x200 variant when the host can make them (image_processing and a
23
+ # processor, see Shiplog.image_variants?), else the original (the stylesheet sizes it).
24
+ def image_thumbnail_path(image)
25
+ source = Shiplog.image_variants? && image.variable? ? image.variant(resize_to_limit: [ 200, 200 ]) : image
26
+ polymorphic_path(source)
27
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
28
+ nil
29
+ end
30
+
31
+ # "Spanish (es)" for a locale of config.locales; the code alone when Shiplog has no name for the language.
32
+ def translation_language_label(locale)
33
+ name = Shiplog::Translator.language_name(locale)
34
+ name == locale.to_s ? name : "#{name} (#{locale})"
35
+ end
36
+
22
37
  def entries_filter_path(status: nil, kind: nil, page: nil)
23
38
  root_path({ status:, kind:, page: }.compact_blank)
24
39
  end
@@ -0,0 +1,113 @@
1
+ module Shiplog
2
+ # Markup helpers of the permalink pages, the Atom feed and the entries' screenshots.
3
+ module PermalinkHelper
4
+ DESCRIPTION_LENGTH = 160
5
+
6
+ # The path of an entry's permalink page, or nil when the entry has no slug the route accepts (a link is
7
+ # an extra, never a reason to fail the page).
8
+ def shiplog_entry_path(entry)
9
+ changelog_entry_path(entry.slug) if entry.permalink?
10
+ rescue ActionController::UrlGenerationError
11
+ nil
12
+ end
13
+
14
+ # The absolute URL of a named engine route: config.canonical_url plus the route's path (which carries the
15
+ # engine's mount prefix) when it is set, the request's own origin otherwise.
16
+ # shiplog_absolute_url(:changelog_feed) # => "https://trackberry.com/changelog.atom"
17
+ def shiplog_absolute_url(route, *args)
18
+ canonical = Shiplog.config.canonical_url
19
+ canonical ? "#{canonical}#{public_send("#{route}_path", *args)}" : public_send("#{route}_url", *args)
20
+ end
21
+
22
+ # The absolute URL of an entry's permalink page, or nil when it has no valid slug.
23
+ def shiplog_entry_url(entry)
24
+ shiplog_absolute_url(:changelog_entry, entry.slug) if shiplog_entry_path(entry)
25
+ end
26
+
27
+ # The authority of the feed's tag URIs: the canonical host, else the request's.
28
+ def shiplog_tag_host
29
+ canonical = Shiplog.config.canonical_url
30
+ canonical ? URI.parse(canonical).host : request.host
31
+ end
32
+
33
+ # A link to an entry's permalink page, or the plain content in a span when it has none.
34
+ def shiplog_entry_link(entry, name = nil, html_options = {}, &block)
35
+ name, html_options = capture(&block), name || {} if block
36
+ path = shiplog_entry_path(entry)
37
+ path ? link_to(name, path, html_options) : content_tag(:span, name, html_options.slice(:class))
38
+ end
39
+
40
+ # The id of a month's section on the changelog list.
41
+ def shiplog_month_anchor(date) = "shiplog-month-#{date.strftime("%Y-%m")}"
42
+
43
+ # The body, on one line and cut for a meta description. Plain text: tag helpers escape it.
44
+ def shiplog_entry_description(entry)
45
+ truncate(entry.display_body.to_s.squish, length: DESCRIPTION_LENGTH, separator: " ")
46
+ end
47
+
48
+ # The Open Graph meta tags of a permalink page, for `content_for :shiplog_og`.
49
+ def shiplog_og_tags(entry, url:)
50
+ tags = {
51
+ "og:title" => entry.display_title, "og:description" => shiplog_entry_description(entry),
52
+ "og:type" => "article", "og:url" => url, "og:image" => shiplog_entry_og_image(entry)
53
+ }
54
+ safe_join(tags.compact_blank.map { |property, content| tag.meta(property:, content:) }, "\n")
55
+ end
56
+
57
+ # The absolute URL of the image to show when the page is shared (the first image), or nil.
58
+ def shiplog_entry_og_image(entry)
59
+ image = shiplog_images(entry).first
60
+ shiplog_image_url(image) if image
61
+ end
62
+
63
+ # The HTML of an entry in the Atom feed: its text, then its images with absolute URLs.
64
+ def shiplog_feed_content(entry)
65
+ images = shiplog_images(entry).filter_map do |image|
66
+ url = shiplog_image_url(image)
67
+ tag.p(tag.img(src: url, alt: entry.image_alt(image), **shiplog_image_size(image))) if url
68
+ end
69
+ safe_join([ simple_format(h(entry.display_body)), *images ], "\n")
70
+ end
71
+
72
+ # An entry's images in order, or none while screenshots are off.
73
+ def shiplog_images(entry)
74
+ Shiplog.images? ? entry.ordered_images : []
75
+ end
76
+
77
+ # The images of an entry under its body: `limit` of them (all by default). Nil when it has none to show.
78
+ def shiplog_entry_images(entry, limit: nil)
79
+ images = shiplog_images(entry)
80
+ images = images.first(limit) if limit
81
+ tags = images.filter_map do |image|
82
+ path = shiplog_image_path(image)
83
+ tag.img(src: path, alt: entry.image_alt(image), loading: "lazy", class: shiplog_class(:entry_image), **shiplog_image_size(image)) if path
84
+ end
85
+ tag.div(safe_join(tags), class: shiplog_class(:entry_images)) if tags.any?
86
+ end
87
+
88
+ # Active Storage's routes belong to the host: url_for reaches them through main_app (see HostRoutesHelper).
89
+ # Nil when the host can't build the route.
90
+ def shiplog_image_path(image)
91
+ polymorphic_path(image)
92
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
93
+ nil
94
+ end
95
+
96
+ # The image's absolute URL: config.canonical_url plus its path when set, the request's origin otherwise.
97
+ def shiplog_image_url(image)
98
+ canonical = Shiplog.config.canonical_url
99
+ return polymorphic_url(image) unless canonical
100
+
101
+ path = shiplog_image_path(image)
102
+ "#{canonical}#{path}" if path
103
+ rescue ActionController::UrlGenerationError, NoMethodError, ArgumentError
104
+ nil
105
+ end
106
+
107
+ # width and height from the blob's analysis, when it has run and found them.
108
+ def shiplog_image_size(image)
109
+ metadata = image.blob.metadata
110
+ { width: metadata[:width], height: metadata[:height] }.select { |_, value| value.to_i.positive? }
111
+ end
112
+ end
113
+ end
@@ -2,6 +2,15 @@ module Shiplog
2
2
  module RoadmapHelper
3
3
  include Shiplog::SwitcherHelper
4
4
 
5
+ # English texts of the roadmap columns, used when the host's locale has no shiplog.columns translations.
6
+ COLUMN_DEFAULTS = {
7
+ in_progress: { title: "In progress", description: "What we're building now" },
8
+ coming_soon: { title: "Coming soon", description: "Planned next" },
9
+ later: { title: "Later", description: "On our radar" }
10
+ }.freeze
11
+
12
+ def shiplog_column_defaults = COLUMN_DEFAULTS
13
+
5
14
  # The configured label of a repository (config.repository_labels, matched case-insensitively), or nil.
6
15
  def shiplog_repository_label(repository)
7
16
  labels = Shiplog.config.repository_labels
@@ -0,0 +1,9 @@
1
+ module Shiplog
2
+ # The "what's new" count for host views: `helper Shiplog::UnseenHelper`, then
3
+ # `shiplog_unseen_count(current_user.changelog_seen_at)`. Shiplog doesn't know about users, so the host
4
+ # supplies the "last seen" time (and stamps it from config.on_changelog_seen).
5
+ module UnseenHelper
6
+ # The number of approved features and changes approved after `since` (all of them while it is nil), at most 99.
7
+ def shiplog_unseen_count(since) = Shiplog.unseen_count(since:)
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ module Shiplog
2
+ # Backfills product areas for entries that have none (see AreaDetector). Enqueued by the admin's Areas page.
3
+ # A failure is logged and recorded for the Areas page (Setting "area_detection"), never raised: the job is not
4
+ # retried, the admin clicks again once the cause (usually the rewriter) is fixed.
5
+ class DetectAreasJob < ApplicationJob
6
+ SETTING = "area_detection".freeze
7
+ MAX_ERROR_CHARS = 500
8
+
9
+ # recheck: true first makes the entries the AI left unplaced eligible again.
10
+ def perform(recheck: false)
11
+ assigned = AreaDetector.new.call(recheck:)
12
+ Setting.write(SETTING, at: Time.current.iso8601, assigned:) if assigned # nil: another run was in progress
13
+ rescue StandardError => error
14
+ Rails.logger.error("[shiplog] Detect areas failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
15
+ Setting.write(SETTING, at: Time.current.iso8601, error: "#{error.class}: #{error.message}".truncate(MAX_ERROR_CHARS))
16
+ end
17
+ end
18
+ end
@@ -2,8 +2,11 @@ module Shiplog
2
2
  class RewriteEntryJob < ApplicationJob
3
3
  discard_on ActiveRecord::RecordNotFound
4
4
 
5
+ # After writing the draft, screenshots from the GitHub body are attached to a draft that has none (the
6
+ # fetcher checks, never raises, and leaves approved entries alone).
5
7
  def perform(entry_id, force: false)
6
- Rewriter.new(Entry.find(entry_id)).call(force:)
8
+ entry = Rewriter.new(Entry.find(entry_id)).call(force:)
9
+ ImageFetcher.call(entry)
7
10
  end
8
11
  end
9
12
  end
@@ -0,0 +1,25 @@
1
+ module Shiplog
2
+ # Translates an area's name into every locale of config.locales with one LLM call (see AreaTranslator).
3
+ # Enqueued when an area is created or renamed, by "Translate names" (with `force: true`) and by
4
+ # `shiplog:translate_approved`. Without force a locale that has a name is left alone, an admin's edit included;
5
+ # a repeat when every locale has one makes no LLM call.
6
+ #
7
+ # A failure (the rewriter raised, or its reply was unusable) is logged and retried twice, with a growing wait,
8
+ # then logged again and dropped: never an endless retry. The English name stays shown meanwhile.
9
+ class TranslateAreaJob < ApplicationJob
10
+ ATTEMPTS = 3
11
+
12
+ retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
13
+ Rails.logger.error("[shiplog] Giving up translating area #{job.arguments.first.try(:id)} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
14
+ end
15
+ # Declared after retry_on, which makes it win: an area deleted since the job was enqueued is not an error.
16
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
17
+
18
+ def perform(area, force: false)
19
+ AreaTranslator.new(area).call(force:)
20
+ rescue StandardError => error
21
+ Rails.logger.error("[shiplog] Translating area #{area.id} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
22
+ raise
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ module Shiplog
2
+ # Translates an approved entry into one locale of config.locales (see Translator). Enqueued when an entry is
3
+ # approved and when the English text of an approved entry changes: a repeat for the same English text makes no
4
+ # LLM call. `force: true` (the admin's Retranslate) ignores the stored digest and an admin's edit.
5
+ #
6
+ # A failure (the rewriter raised, or its reply was unusable) is logged and retried twice, with a growing wait,
7
+ # then logged again and dropped: never an endless retry. The English text stays shown meanwhile.
8
+ class TranslateEntryJob < ApplicationJob
9
+ ATTEMPTS = 3
10
+
11
+ retry_on StandardError, attempts: ATTEMPTS, wait: :polynomially_longer do |job, error|
12
+ entry, locale = job.arguments
13
+ Rails.logger.error("[shiplog] Giving up translating entry #{entry.try(:id)} into #{locale} after #{ATTEMPTS} attempts: #{error.class}: #{error.message}")
14
+ end
15
+ # Declared after retry_on, which makes it win: an entry deleted since the job was enqueued is not an error.
16
+ discard_on ActiveJob::DeserializationError, ActiveRecord::RecordNotFound
17
+
18
+ def perform(entry, locale, force: false)
19
+ Translator.new(entry, locale).call(force:)
20
+ rescue StandardError => error
21
+ Rails.logger.error("[shiplog] Translating entry #{entry.id} into #{locale} failed (attempt #{executions} of #{ATTEMPTS}): #{error.class}: #{error.message}")
22
+ raise
23
+ end
24
+ end
25
+ end