shiplog 0.1.1 → 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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +266 -9
  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/admin/syncs_controller.rb +3 -2
  15. data/app/controllers/shiplog/changelog_entries_controller.rb +26 -0
  16. data/app/controllers/shiplog/changelog_feeds_controller.rb +19 -0
  17. data/app/controllers/shiplog/changelogs_controller.rb +30 -4
  18. data/app/controllers/shiplog/public_controller.rb +58 -1
  19. data/app/controllers/shiplog/roadmaps_controller.rb +27 -2
  20. data/app/frontend/shiplog/admin.css +9 -0
  21. data/app/frontend/shiplog/shiplog.css +168 -8
  22. data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
  23. data/app/helpers/shiplog/callable_helper.rb +12 -0
  24. data/app/helpers/shiplog/frontend_helper.rb +1 -0
  25. data/app/helpers/shiplog/permalink_helper.rb +113 -0
  26. data/app/helpers/shiplog/roadmap_helper.rb +24 -0
  27. data/app/helpers/shiplog/switcher_helper.rb +101 -0
  28. data/app/helpers/shiplog/unseen_helper.rb +9 -0
  29. data/app/jobs/shiplog/detect_areas_job.rb +18 -0
  30. data/app/jobs/shiplog/refresh_job.rb +1 -0
  31. data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
  32. data/app/jobs/shiplog/scan_changes_job.rb +19 -5
  33. data/app/jobs/shiplog/sync_job.rb +1 -1
  34. data/app/jobs/shiplog/translate_area_job.rb +25 -0
  35. data/app/jobs/shiplog/translate_entry_job.rb +25 -0
  36. data/app/models/shiplog/area.rb +156 -0
  37. data/app/models/shiplog/area_detector.rb +146 -0
  38. data/app/models/shiplog/area_translator.rb +77 -0
  39. data/app/models/shiplog/change_candidates.rb +39 -4
  40. data/app/models/shiplog/entry/images.rb +127 -0
  41. data/app/models/shiplog/entry.rb +218 -2
  42. data/app/models/shiplog/entry_translation.rb +15 -0
  43. data/app/models/shiplog/image_fetcher/downloader.rb +98 -0
  44. data/app/models/shiplog/image_fetcher/sources.rb +41 -0
  45. data/app/models/shiplog/image_fetcher.rb +72 -0
  46. data/app/models/shiplog/image_type.rb +111 -0
  47. data/app/models/shiplog/pull_request.rb +4 -0
  48. data/app/models/shiplog/pull_request_upgrade.rb +5 -0
  49. data/app/models/shiplog/rewriter/prompt.rb +57 -9
  50. data/app/models/shiplog/rewriter.rb +50 -13
  51. data/app/models/shiplog/setting.rb +19 -0
  52. data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
  53. data/app/models/shiplog/sync/repository_sync.rb +2 -0
  54. data/app/models/shiplog/sync/upgrade_detector.rb +203 -0
  55. data/app/models/shiplog/translator/prompt.rb +40 -0
  56. data/app/models/shiplog/translator.rb +99 -0
  57. data/app/views/layouts/shiplog/application.html.erb +4 -0
  58. data/app/views/shiplog/_empty.html.erb +1 -1
  59. data/app/views/shiplog/_page_header.html.erb +2 -1
  60. data/app/views/shiplog/_pagination.html.erb +3 -3
  61. data/app/views/shiplog/_switcher.html.erb +13 -0
  62. data/app/views/shiplog/admin/_nav.html.erb +2 -1
  63. data/app/views/shiplog/admin/areas/index.html.erb +78 -0
  64. data/app/views/shiplog/admin/entries/_entry_row.html.erb +4 -0
  65. data/app/views/shiplog/admin/entries/_images.html.erb +41 -0
  66. data/app/views/shiplog/admin/entries/_translations.html.erb +42 -0
  67. data/app/views/shiplog/admin/entries/edit.html.erb +12 -0
  68. data/app/views/shiplog/changelog_entries/show.html.erb +25 -0
  69. data/app/views/shiplog/changelog_feeds/show.atom.builder +24 -0
  70. data/app/views/shiplog/changelogs/_areas.html.erb +8 -0
  71. data/app/views/shiplog/changelogs/_entry.html.erb +2 -5
  72. data/app/views/shiplog/changelogs/_entry_content.html.erb +10 -0
  73. data/app/views/shiplog/changelogs/_search.html.erb +18 -0
  74. data/app/views/shiplog/changelogs/show.html.erb +20 -5
  75. data/app/views/shiplog/roadmaps/_card.html.erb +14 -3
  76. data/app/views/shiplog/roadmaps/_column.html.erb +4 -4
  77. data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
  78. data/app/views/shiplog/roadmaps/show.html.erb +11 -2
  79. data/config/admin_routes.rb +16 -0
  80. data/config/locales/en.yml +105 -0
  81. data/config/locales/es.yml +223 -0
  82. data/config/routes.rb +5 -0
  83. data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
  84. data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
  85. data/lib/generators/shiplog/install/install_generator.rb +29 -1
  86. data/lib/generators/shiplog/install/templates/initializer.rb.tt +82 -1
  87. data/lib/shiplog/classes.rb +29 -1
  88. data/lib/shiplog/configuration.rb +245 -3
  89. data/lib/shiplog/engine.rb +3 -0
  90. data/lib/shiplog/github/client.rb +42 -9
  91. data/lib/shiplog/github/errors.rb +3 -0
  92. data/lib/shiplog/github/queries.rb +44 -1
  93. data/lib/shiplog/version.rb +1 -1
  94. data/lib/shiplog.rb +73 -0
  95. data/lib/tasks/shiplog.rake +29 -0
  96. metadata +48 -1
@@ -0,0 +1,13 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Areas
4
+ # Moves an area one place up or down (params[:direction]); anything else changes nothing.
5
+ class MovesController < ApplicationController
6
+ def create
7
+ Area.find(params[:area_id]).move(params[:direction])
8
+ redirect_to areas_path
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ module Shiplog
2
+ module Admin
3
+ # The Areas page: product areas with their entry counts; rename, edit the translated names, delete. Reordering, merging and detection
4
+ # are their own small controllers.
5
+ class AreasController < ApplicationController
6
+ before_action :set_area, only: %i[update destroy]
7
+
8
+ def index
9
+ @areas = Area.ordered.to_a
10
+ @counts = Area.entry_counts
11
+ @detection = Setting.read(DetectAreasJob::SETTING)
12
+ end
13
+
14
+ def update
15
+ if @area.update(area_attributes)
16
+ redirect_to areas_path, notice: t("shiplog.admin.areas.renamed", default: "Area renamed.")
17
+ else
18
+ redirect_to areas_path, alert: @area.errors.full_messages.to_sentence
19
+ end
20
+ end
21
+
22
+ def destroy
23
+ @area.destroy!
24
+ redirect_to areas_path, notice: t("shiplog.admin.areas.deleted", default: "Area deleted. Its entries have no area now.")
25
+ end
26
+
27
+ private
28
+
29
+ # The name, and the translated names of the configured locales. A rename drops the translations (they were of
30
+ # the old name) and enqueues a new translation, so the translations sent with it are ignored; without a
31
+ # rename they are kept as the admin typed them (an empty field removes one), over any earlier translation.
32
+ def area_attributes
33
+ permitted = params.expect(area: [ :name, { name_translations: {} } ])
34
+ attributes = { name: permitted[:name] }
35
+ submitted = permitted[:name_translations]
36
+ if submitted && permitted[:name].to_s.squish == @area.name
37
+ locales = Shiplog.config.locales.map(&:to_s)
38
+ attributes[:name_translations] = @area.name_translations.merge(submitted.to_h.slice(*locales).select { |_locale, value| value.is_a?(String) })
39
+ end
40
+ attributes
41
+ end
42
+
43
+ def set_area
44
+ @area = Area.find(params[:id])
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,20 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ module Images
5
+ # Moves an image up or down in its entry's order (params[:direction]).
6
+ class MovesController < ApplicationController
7
+ before_action { head :not_found unless Shiplog.images? }
8
+
9
+ def create
10
+ entry = Entry.find(params[:entry_id])
11
+ return head(:not_found) unless entry.find_image(params[:image_id])
12
+
13
+ entry.move_image(params[:image_id], params[:direction] == "up" ? :up : :down)
14
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.entries.images.moved", default: "Image moved.")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # An entry's screenshots on the edit page: upload, alt text, remove. :id is the image's blob id, the key of
5
+ # the entry's order and alt texts. Not found while screenshots are off.
6
+ class ImagesController < ApplicationController
7
+ before_action :require_images
8
+ before_action :set_entry
9
+
10
+ def create
11
+ files = uploads
12
+ return back(alert: t("shiplog.admin.entries.images.none_selected", default: "Choose at least one image.")) if files.empty?
13
+
14
+ checked = files.index_with { |file| inspect_upload(file) }
15
+ rejected = checked.select { |_file, info| info.nil? }.keys
16
+ if rejected.any?
17
+ names = rejected.map { |file| file.original_filename.to_s.scrub.first(100) }.to_sentence
18
+ return back(alert: t("shiplog.admin.entries.images.rejected", names:, default: "Not added, only PNG, JPEG, GIF and WebP images up to 5 MB and 10,000 pixels a side are accepted: %{names}"))
19
+ end
20
+
21
+ @entry.add_images(checked.map { |file, info| attachable(file, info) }) # uploads first, locks only to attach
22
+ back(notice: t("shiplog.admin.entries.images.added", count: files.size, default: { one: "Image added.", other: "%{count} images added." }))
23
+ end
24
+
25
+ def update
26
+ return head(:not_found) unless @entry.find_image(params[:id])
27
+
28
+ @entry.update_image_alt(params[:id], params[:alt])
29
+ back(notice: t("shiplog.admin.entries.images.alt_saved", default: "Alt text saved."))
30
+ end
31
+
32
+ def destroy
33
+ return head(:not_found) unless @entry.find_image(params[:id])
34
+
35
+ @entry.remove_image(params[:id])
36
+ back(notice: t("shiplog.admin.entries.images.removed", default: "Image removed."))
37
+ end
38
+
39
+ private
40
+
41
+ def require_images
42
+ head :not_found unless Shiplog.images?
43
+ end
44
+
45
+ def set_entry
46
+ @entry = Entry.find(params[:entry_id])
47
+ end
48
+
49
+ def back(**flash)
50
+ redirect_to edit_entry_path(@entry, inbox_filter_params), **flash
51
+ end
52
+
53
+ def uploads
54
+ Array(params[:images]).select { |file| file.is_a?(ActionDispatch::Http::UploadedFile) }
55
+ end
56
+
57
+ # Decided by the file's bytes and size, never by the declared name or content type: the type, and the
58
+ # pixel size read from the header, which must be within the limits (see ImageType). Nil when refused.
59
+ def inspect_upload(file)
60
+ return unless file.size.between?(1, ImageType::MAX_BYTES)
61
+
62
+ file.rewind
63
+ ImageType.analyze(file.read(ImageType::MAX_BYTES + 1))
64
+ rescue ImageType::Invalid
65
+ nil
66
+ ensure
67
+ file.rewind
68
+ end
69
+
70
+ def attachable(file, info)
71
+ { io: file.tempfile, filename: file.original_filename.to_s.scrub.presence || "image", content_type: info.content_type, metadata: { width: info.width, height: info.height } }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,23 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ module Translations
5
+ # Translates the entry into the locale again, ignoring the stored digest and an admin's edit.
6
+ class RetranslationsController < ApplicationController
7
+ def create
8
+ entry = Entry.find(params[:entry_id])
9
+ locale = params[:translation_locale].to_s
10
+ return head(:not_found) unless Entry.translated_locale?(locale)
11
+
12
+ back = edit_entry_path(entry, inbox_filter_params)
13
+ return redirect_to(back, alert: t("shiplog.admin.entries.translations.only_approved", default: "Only approved entries are translated.")) unless entry.approved?
14
+ return redirect_to(back, alert: t("shiplog.admin.entries.translations.no_rewriter", default: "Translating needs config.rewriter, which is not set.")) unless Shiplog.config.rewriter
15
+
16
+ TranslateEntryJob.perform_later(entry, locale.to_sym, force: true)
17
+ redirect_to back, notice: t("shiplog.admin.entries.translations.queued", default: "Translation queued. Refresh in a moment.")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -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
@@ -4,8 +4,9 @@ module Shiplog
4
4
  DELIVERIES_SHOWN = 20
5
5
 
6
6
  def show
7
- repositories = Repository.where(full_name: Shiplog.config.repositories).index_by(&:full_name)
8
- @repositories = Shiplog.config.repositories.map { |full_name| [ full_name, repositories[full_name] ] }
7
+ names = Shiplog.config.repository_names
8
+ repositories = Repository.where(full_name: names).index_by(&:full_name)
9
+ @repositories = names.map { |full_name| [ full_name, repositories[full_name] ] }
9
10
  @deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
10
11
  @webhook_secret_missing = Shiplog.config.webhook_secret.blank?
11
12
  @github_token_missing = Shiplog.config.resolved_github_token.blank?
@@ -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,21 +2,47 @@ 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)
10
- records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
11
+ scope = Entry.published_changelog.newest_first
12
+ @query = search_query
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
11
17
  return head(:not_found) if @page > 1 && records.empty?
12
18
 
19
+ notify_changelog_seen
13
20
  @older = records.size > PER_PAGE
14
21
  @months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
15
- fresh_when etag: [ *shiplog_etag_parts, @page, @older, @months.values.flatten.map { |entry| [ entry.id, entry.updated_at ] } ]
22
+ @result_count = scope.count if @query
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) ]
16
28
  end
17
29
 
18
30
  private
19
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
+
39
+ # The normalised search query (words joined by one space), or nil when search is off or the query is blank.
40
+ def search_query
41
+ return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
42
+
43
+ Entry.search_terms(params[:q]).join(" ").presence
44
+ end
45
+
20
46
  def page_number
21
47
  Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
22
48
  end
@@ -5,15 +5,72 @@ module Shiplog
5
5
  class PublicController < Shiplog.config.public_parent_controller.constantize
6
6
  helper Shiplog::FrontendHelper
7
7
  helper Shiplog::StylingHelper
8
+ helper Shiplog::SwitcherHelper
9
+ helper Shiplog::RoadmapHelper
8
10
  helper Shiplog::HostRoutesHelper
11
+ helper Shiplog::PermalinkHelper
9
12
  layout -> { Shiplog.config.public_layout }
10
13
 
11
14
  private
12
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
+
13
70
  # What the rendered markup depends on besides the data: the locale, the gem version and the styling
14
71
  # config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
15
72
  def shiplog_etag_parts
16
- [ I18n.locale, Shiplog::VERSION, Shiplog.config.classes.hash, Shiplog.config.stylesheet ]
73
+ [ I18n.locale, Shiplog::VERSION, Shiplog.config.render_digest ]
17
74
  end
18
75
  end
19
76
  end
@@ -2,13 +2,38 @@ module Shiplog
2
2
  class RoadmapsController < PublicController
3
3
  def show
4
4
  @columns = Roadmap.new(published_only: true).columns
5
- fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns) ]
5
+ preload_cards(@columns)
6
+ @shipped = recently_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) ]
6
10
  end
7
11
 
8
12
  private
9
13
 
14
+ # The cards read each entry's issue, its repository (label) and its sub-issues (parts).
15
+ def preload_cards(columns)
16
+ entries = columns.values.flatten.map(&:entry)
17
+ ActiveRecord::Associations::Preloader.new(records: entries, associations: { subject: [ :repository, :children ] }).call
18
+ end
19
+
20
+ def recently_shipped
21
+ count = Shiplog.config.roadmap_shipped_count
22
+ return Entry.none if count.zero?
23
+
24
+ Entry.approved.where(kind: Entry::CHANGELOG_KINDS).where.not(released_on: nil)
25
+ .order(released_on: :desc, approved_at: :desc, id: :desc).limit(count).load
26
+ end
27
+
10
28
  def etag_for(columns)
11
- columns.transform_values { |items| items.map { |item| [ item.entry.id, item.entry.updated_at ] } }
29
+ columns.transform_values do |items|
30
+ items.map do |item|
31
+ issue = item.entry.subject
32
+ [ item.entry.id, item.entry.updated_at, issue.updated_at, issue.children.size, issue.repository.full_name ]
33
+ end
34
+ end
12
35
  end
36
+
37
+ def shipped_etag(entries) = entries.map { |entry| [ entry.id, entry.updated_at ] }
13
38
  end
14
39
  end
@@ -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)); }