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
@@ -1,12 +1,12 @@
1
1
  <%# locals: (newer_path: nil, older_path: nil) %>
2
2
  <%# previous/next page links; renders nothing when both are nil %>
3
3
  <% if newer_path || older_path %>
4
- <nav class="<%= shiplog_class(:pagination) %>" aria-label="<%= t("shiplog.changelog.pagination.label") %>">
4
+ <nav class="<%= shiplog_class(:pagination) %>" aria-label="<%= t("shiplog.changelog.pagination.label", default: "Pagination") %>">
5
5
  <% if newer_path %>
6
- <%= link_to t("shiplog.changelog.pagination.newer"), newer_path, class: shiplog_class(:pagination_link, :newer), rel: "prev" %>
6
+ <%= link_to t("shiplog.changelog.pagination.newer", default: "Newer"), newer_path, class: shiplog_class(:pagination_link, :newer), rel: "prev" %>
7
7
  <% end %>
8
8
  <% if older_path %>
9
- <%= link_to t("shiplog.changelog.pagination.older"), older_path, class: shiplog_class(:pagination_link, :older), rel: "next" %>
9
+ <%= link_to t("shiplog.changelog.pagination.older", default: "Older"), older_path, class: shiplog_class(:pagination_link, :older), rel: "next" %>
10
10
  <% end %>
11
11
  </nav>
12
12
  <% end %>
@@ -0,0 +1,13 @@
1
+ <%# locals: () %>
2
+ <%# Roadmap, Changelog and config.extra_pages; the current page gets aria-current. Needs Shiplog::SwitcherHelper, which the public controllers and a host's `helper Shiplog::SwitcherHelper` provide. %>
3
+ <nav class="<%= shiplog_class(:switcher) %>" aria-label="<%= shiplog_switcher_label %>">
4
+ <% shiplog_switcher_pages.each do |page| %>
5
+ <% if page[:path] %>
6
+ <%= link_to page[:label], page[:path],
7
+ class: shiplog_class(:switcher_link, ("current" if page[:current])),
8
+ aria: { current: ("page" if page[:current]) } %>
9
+ <% else %>
10
+ <span class="<%= shiplog_class(:switcher_link) %>"><%= page[:label] %></span>
11
+ <% end %>
12
+ <% end %>
13
+ </nav>
@@ -1,9 +1,10 @@
1
1
  <%# locals: (hosted: false) %>
2
- <%# the Drafts / Roadmap board / Sync links; `hosted` renders the compact variant used inside a host layout %>
2
+ <%# the Drafts / Roadmap board / Areas / Sync links; `hosted` renders the compact variant used inside a host layout %>
3
3
  <nav class="shiplog-admin-nav<%= " shiplog-admin-nav--sub" if hosted %>" aria-label="<%= t("shiplog.admin.title") %>">
4
4
  <ul class="shiplog-admin-nav__list">
5
5
  <li><%= link_to t("shiplog.admin.nav.drafts"), root_path, class: "shiplog-admin-nav__link" %></li>
6
6
  <li><%= link_to t("shiplog.admin.nav.board"), board_path, class: "shiplog-admin-nav__link" %></li>
7
+ <li><%= link_to t("shiplog.admin.nav.areas", default: "Areas"), areas_path, class: "shiplog-admin-nav__link" %></li>
7
8
  <li><%= link_to t("shiplog.admin.nav.sync"), sync_path, class: "shiplog-admin-nav__link" %></li>
8
9
  </ul>
9
10
  </nav>
@@ -0,0 +1,78 @@
1
+ <%= render layout: "shiplog/admin/page" do %>
2
+ <h1 class="shiplog-admin__title"><%= t("shiplog.admin.areas.title", default: "Product areas") %></h1>
3
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.areas.help", max: Shiplog.config.max_areas, default: "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.") %></p>
4
+
5
+ <div class="shiplog-admin-actions">
6
+ <%= form_with url: area_detection_path, class: "shiplog-admin-inline-form" do |form| %>
7
+ <%= form.button t("shiplog.admin.areas.detect", default: "Detect areas"), type: "submit", class: "shiplog-button shiplog-button--primary" %>
8
+ <%= form.check_box :recheck, {}, "1", nil %>
9
+ <%= form.label :recheck, t("shiplog.admin.areas.recheck", default: "Re-check unplaced entries"), title: t("shiplog.admin.areas.recheck_help", default: "Ask the AI again about entries it left without an area, e.g. after renaming, merging or adding areas.") %>
10
+ <% end %>
11
+ <% if Shiplog.config.locales.any? %>
12
+ <%= form_with url: area_translation_path, class: "shiplog-admin-inline-form" do |form| %>
13
+ <%= form.button t("shiplog.admin.areas.translate_names", default: "Translate names"), type: "submit", class: "shiplog-button", title: t("shiplog.admin.areas.translate_names_help", default: "Ask the AI for the names that are missing in some language. Names that are already there, including ones you edited, are kept.") %>
14
+ <% end %>
15
+ <% end %>
16
+ </div>
17
+
18
+ <% if @detection %>
19
+ <% time = (Time.zone.parse(@detection["at"].to_s) || Time.current).utc.strftime("%Y-%m-%d %H:%M UTC") %>
20
+ <% if @detection["error"] %>
21
+ <p class="shiplog-admin__muted shiplog-area-detection shiplog-area-detection--error" role="alert"><%= t("shiplog.admin.areas.last_error", time:, error: @detection["error"], default: "Last detection failed (%{time}): %{error}") %></p>
22
+ <% else %>
23
+ <p class="shiplog-admin__muted shiplog-area-detection"><%= t("shiplog.admin.areas.last_detection", time:, count: @detection["assigned"].to_i, default: "Last detection: %{time}, assigned %{count}") %></p>
24
+ <% end %>
25
+ <% end %>
26
+
27
+ <% if @areas.empty? %>
28
+ <p class="shiplog-admin__empty"><%= t("shiplog.admin.areas.empty", default: "No areas yet. Detect areas to propose some from your entries.") %></p>
29
+ <% else %>
30
+ <table class="shiplog-admin-table">
31
+ <thead>
32
+ <tr>
33
+ <th scope="col"><%= t("shiplog.admin.areas.columns.name", default: "Name") %></th>
34
+ <th scope="col"><%= t("shiplog.admin.areas.columns.entries", default: "Entries") %></th>
35
+ <th scope="col"><%= t("shiplog.admin.areas.columns.order", default: "Order") %></th>
36
+ <th scope="col"><%= t("shiplog.admin.areas.columns.merge", default: "Merge into") %></th>
37
+ <th scope="col"><%= t("shiplog.admin.entries.columns.actions") %></th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% @areas.each_with_index do |area, index| %>
42
+ <tr class="shiplog-area-row" id="shiplog-area-<%= area.id %>">
43
+ <td>
44
+ <%= form_with model: area, url: area_path(area), method: :patch, class: "shiplog-admin-inline-form" do |form| %>
45
+ <%= form.label :name, t("shiplog.admin.areas.name_label", default: "Name"), class: "shiplog-admin__visually-hidden" %>
46
+ <%= form.text_field :name, maxlength: Shiplog::Area::NAME_MAX_LENGTH %>
47
+ <% Shiplog.config.locales.each do |locale| %>
48
+ <%= label_tag "area_#{area.id}_name_#{locale}", locale.to_s, class: "shiplog-admin__muted" %>
49
+ <%= text_field_tag "area[name_translations][#{locale}]", area.name_translations[locale.to_s], id: "area_#{area.id}_name_#{locale}", lang: locale.to_s, maxlength: Shiplog::Area::NAME_MAX_LENGTH, size: 16, placeholder: area.name %>
50
+ <% end %>
51
+ <%= form.submit t("shiplog.admin.areas.rename", default: "Rename"), class: "shiplog-button" %>
52
+ <% end %>
53
+ <span class="shiplog-admin__muted"><%= area.slug %></span>
54
+ </td>
55
+ <td class="shiplog-area-row__count"><%= @counts.fetch(area.id, 0) %></td>
56
+ <td class="shiplog-admin-table__actions">
57
+ <%= button_to t("shiplog.admin.areas.move_up", default: "Move up"), area_move_path(area), params: { direction: "up" }, class: "shiplog-button", disabled: index.zero? %>
58
+ <%= button_to t("shiplog.admin.areas.move_down", default: "Move down"), area_move_path(area), params: { direction: "down" }, class: "shiplog-button", disabled: index == @areas.size - 1 %>
59
+ </td>
60
+ <td>
61
+ <% if @areas.size > 1 %>
62
+ <%= form_with url: area_merge_path(area), class: "shiplog-admin-inline-form" do |form| %>
63
+ <%= form.label :target_id, t("shiplog.admin.areas.merge_into", default: "Merge into"), class: "shiplog-admin__visually-hidden" %>
64
+ <%= form.select :target_id, (@areas - [ area ]).map { |other| [ other.name, other.id ] } %>
65
+ <%= form.submit t("shiplog.admin.areas.merge", default: "Merge"), class: "shiplog-button" %>
66
+ <% end %>
67
+ <% end %>
68
+ </td>
69
+ <td class="shiplog-admin-table__actions">
70
+ <%= button_to t("shiplog.admin.areas.delete", default: "Delete"), area_path(area), method: :delete, class: "shiplog-button" %>
71
+ </td>
72
+ </tr>
73
+ <% end %>
74
+ </tbody>
75
+ </table>
76
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.areas.merge_help", default: "Merging moves the entries to the other area and deletes this one. Deleting an area leaves its entries without one.") %></p>
77
+ <% end %>
78
+ <% end %>
@@ -2,6 +2,9 @@
2
2
  <td>
3
3
  <%= link_to entry_heading(entry), edit_entry_path(entry, inbox_filter_params) %>
4
4
  <%= render "shiplog/admin/entries/badges", entry: %>
5
+ <% if entry.area %>
6
+ <span class="shiplog-badge shiplog-badge--area"><%= entry.area.name %></span>
7
+ <% end %>
5
8
  <% if entry.discarded? && entry.discard_reason.present? %>
6
9
  <div class="shiplog-admin__muted"><%= entry.discard_reason %></div>
7
10
  <% end %>
@@ -14,6 +17,7 @@
14
17
  <%= link_to t("shiplog.admin.entries.edit"), edit_entry_path(entry, inbox_filter_params), class: "shiplog-button" %>
15
18
  <% if entry.draft? %>
16
19
  <%= button_to t("shiplog.admin.entries.approve"), entry_approval_path(entry, inbox_filter_params), class: "shiplog-button shiplog-button--primary", form: { data: { shiplog_inline: true } } %>
20
+ <%= button_to t("shiplog.admin.entries.discard"), entry_discard_path(entry, inbox_filter_params), class: "shiplog-button", form: { data: { shiplog_inline: true } } %>
17
21
  <% elsif entry.discarded? %>
18
22
  <%= button_to t("shiplog.admin.entries.restore"), entry_discard_path(entry, inbox_filter_params), method: :delete, class: "shiplog-button", form: { data: { shiplog_inline: true } } %>
19
23
  <% end %>
@@ -0,0 +1,41 @@
1
+ <%# locals: (entry:) %>
2
+ <section class="shiplog-admin-panel shiplog-admin-images">
3
+ <h2><%= t("shiplog.admin.entries.images.title", default: "Screenshots") %></h2>
4
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.entries.images.help", default: "Shown under the text on the changelog (the first one in the list, all of them on the entry's page). Taken from the GitHub issue or pull request when the draft is written; you can also upload your own.") %></p>
5
+ <% images = entry.ordered_images %>
6
+ <% if images.empty? %>
7
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.entries.images.empty", default: "No screenshots yet.") %></p>
8
+ <% else %>
9
+ <ol class="shiplog-admin-images__list">
10
+ <% images.each_with_index do |image, index| %>
11
+ <li class="shiplog-admin-images__item">
12
+ <% thumbnail = image_thumbnail_path(image) %>
13
+ <%= tag.img(src: thumbnail, alt: entry.image_alt(image), class: "shiplog-admin-images__thumb", loading: "lazy") if thumbnail %>
14
+ <div class="shiplog-admin-images__controls">
15
+ <%= form_with url: entry_image_path(entry, image.blob_id, inbox_filter_params), method: :patch, class: "shiplog-admin-inline-form" do |form| %>
16
+ <%= form.label :alt, t("shiplog.admin.entries.images.alt", default: "Alt text"), for: "alt_#{image.blob_id}" %>
17
+ <%= form.text_field :alt, id: "alt_#{image.blob_id}", value: entry.custom_image_alt(image), maxlength: Shiplog::Entry::Images::ALT_MAX_LENGTH, placeholder: t("shiplog.admin.entries.images.alt_placeholder", default: "Describe the image (defaults to the title)") %>
18
+ <%= form.submit t("shiplog.admin.entries.images.save_alt", default: "Save alt text"), class: "shiplog-button" %>
19
+ <% end %>
20
+ <div class="shiplog-admin-inline-form">
21
+ <% if index.positive? %>
22
+ <%= button_to t("shiplog.admin.entries.images.move_up", default: "Up"), entry_image_move_path(entry, image.blob_id, inbox_filter_params.merge(direction: "up")), class: "shiplog-button" %>
23
+ <% end %>
24
+ <% if index < images.size - 1 %>
25
+ <%= button_to t("shiplog.admin.entries.images.move_down", default: "Down"), entry_image_move_path(entry, image.blob_id, inbox_filter_params.merge(direction: "down")), class: "shiplog-button" %>
26
+ <% end %>
27
+ <%= button_to t("shiplog.admin.entries.images.remove", default: "Remove"), entry_image_path(entry, image.blob_id, inbox_filter_params), method: :delete, class: "shiplog-button" %>
28
+ </div>
29
+ </div>
30
+ </li>
31
+ <% end %>
32
+ </ol>
33
+ <% end %>
34
+ <%= form_with url: entry_images_path(entry, inbox_filter_params), multipart: true, class: "shiplog-admin-form" do |form| %>
35
+ <p>
36
+ <%= form.label :images, t("shiplog.admin.entries.images.upload_label", default: "Images (PNG, JPEG, GIF or WebP, up to 5 MB each)") %>
37
+ <%= form.file_field :images, multiple: true, name: "images[]", accept: Shiplog::ImageType::EXTENSIONS.keys.join(",") %>
38
+ </p>
39
+ <%= form.submit t("shiplog.admin.entries.images.upload", default: "Upload images"), class: "shiplog-button" %>
40
+ <% end %>
41
+ </section>
@@ -0,0 +1,42 @@
1
+ <%# locals: (entry:) %>
2
+ <%# one editable title and body per configured locale (config.locales), with its state: missing, stale (made from older English text), edited by an admin, or up to date. Needs Shiplog::Admin::EntriesHelper. %>
3
+ <% translations = entry.translations.index_by(&:locale) %>
4
+ <section class="shiplog-admin-panel shiplog-translations">
5
+ <h2><%= t("shiplog.admin.entries.translations.title", default: "Translations") %></h2>
6
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.entries.translations.help", default: "Approving an entry translates it automatically, and the translation is published with the English text. A translation you edit is kept until the English text changes; then it is translated again. A stale or missing translation is not shown: visitors see the English text.") %></p>
7
+ <% unless entry.approved? %>
8
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.entries.translations.not_approved", default: "Translations are written when the entry is approved.") %></p>
9
+ <% end %>
10
+ <% Shiplog.config.locales.each do |locale| %>
11
+ <% translation = translations[locale.to_s] %>
12
+ <div class="shiplog-translation" data-locale="<%= locale %>">
13
+ <h3>
14
+ <%= translation_language_label(locale) %>
15
+ <% if translation.nil? %>
16
+ <span class="shiplog-badge"><%= t("shiplog.admin.entries.translations.missing", default: "Missing") %></span>
17
+ <% elsif translation.stale? %>
18
+ <span class="shiplog-badge"><%= t("shiplog.admin.entries.translations.stale", default: "Stale") %></span>
19
+ <% else %>
20
+ <span class="shiplog-badge"><%= t("shiplog.admin.entries.translations.current", default: "Up to date") %></span>
21
+ <% end %>
22
+ <% if translation&.edited_by_admin %>
23
+ <span class="shiplog-badge"><%= t("shiplog.admin.entries.translations.edited", default: "Edited by an admin") %></span>
24
+ <% end %>
25
+ </h3>
26
+ <%= form_with url: entry_translation_path(entry, inbox_filter_params.merge(translation_locale: locale)), scope: :translation, method: :patch, class: "shiplog-admin-form" do |form| %>
27
+ <p>
28
+ <%= form.label :title, t("shiplog.admin.entries.fields.title"), for: "translation_#{locale}_title" %>
29
+ <%= form.text_field :title, value: translation&.title, id: "translation_#{locale}_title", lang: locale %>
30
+ </p>
31
+ <p>
32
+ <%= form.label :body, t("shiplog.admin.entries.fields.body"), for: "translation_#{locale}_body" %>
33
+ <%= form.text_area :body, value: translation&.body, rows: 5, id: "translation_#{locale}_body", lang: locale %>
34
+ </p>
35
+ <%= form.submit t("shiplog.admin.entries.save"), class: "shiplog-button shiplog-button--primary" %>
36
+ <% end %>
37
+ <% if entry.approved? %>
38
+ <%= button_to t("shiplog.admin.entries.translations.retranslate", default: "Retranslate"), entry_translation_retranslation_path(entry, inbox_filter_params.merge(translation_locale: locale)), class: "shiplog-button" %>
39
+ <% end %>
40
+ </div>
41
+ <% end %>
42
+ </section>
@@ -28,6 +28,10 @@
28
28
  <%= form.select :changelog_kind, Shiplog::Entry::CHANGELOG_KINDS.map { |kind| [ t("shiplog.admin.entries.category_options.#{kind}"), kind ] } %>
29
29
  </p>
30
30
  <% end %>
31
+ <p>
32
+ <%= form.label :area_id, t("shiplog.admin.entries.fields.area", default: "Area") %>
33
+ <%= form.collection_select :area_id, Shiplog::Area.ordered, :id, :name, include_blank: t("shiplog.admin.entries.no_area", default: "None") %>
34
+ </p>
31
35
  <p>
32
36
  <%= form.label :released_on, t("shiplog.admin.entries.fields.released_on") %>
33
37
  <%= form.date_field :released_on %>
@@ -63,5 +67,13 @@
63
67
  </aside>
64
68
  </div>
65
69
 
70
+ <% if Shiplog.config.locales.any? %>
71
+ <%= render "shiplog/admin/entries/translations", entry: @entry %>
72
+ <% end %>
73
+
74
+ <% if Shiplog.images? %>
75
+ <%= render "shiplog/admin/entries/images", entry: @entry %>
76
+ <% end %>
77
+
66
78
  <p><%= link_to t("shiplog.admin.entries.back"), entries_filter_path(**inbox_filter_params) %></p>
67
79
  <% end %>
@@ -0,0 +1,25 @@
1
+ <% content_for :title, t("shiplog.permalink.title", title: @entry.display_title, changelog: t("shiplog.changelog.title", default: "Changelog"), default: "%{title} · %{changelog}") %>
2
+ <% content_for :shiplog_description, shiplog_entry_description(@entry) %>
3
+ <% content_for :shiplog_og, shiplog_og_tags(@entry, url: shiplog_entry_url(@entry)) %>
4
+ <% content_for :shiplog_feed, auto_discovery_link_tag(:atom, shiplog_absolute_url(:changelog_feed), title: t("shiplog.feed.link_title", default: "Changelog (Atom)")) %>
5
+ <%= render layout: "shiplog/page", locals: { name: "permalink" } do %>
6
+ <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: nil %>
7
+ <article class="<%= shiplog_class(:permalink, @entry.kind) %>">
8
+ <%= render "shiplog/changelogs/entry_content", entry: @entry, heading: "h2" %>
9
+ </article>
10
+ <%= link_to t("shiplog.permalink.back", default: "← Back to the changelog"), changelog_path(anchor: shiplog_month_anchor(@entry.released_on)), class: shiplog_class(:permalink_back) %>
11
+ <% if @newer || @older %>
12
+ <nav class="<%= shiplog_class(:permalink_nav) %>" aria-label="<%= t("shiplog.permalink.nav_label", default: "More changelog entries") %>">
13
+ <% if @newer %>
14
+ <%= shiplog_entry_link @newer, class: shiplog_class(:permalink_nav_link, :newer), rel: "prev" do %>
15
+ <%= t("shiplog.permalink.newer", title: @newer.display_title, default: "← Newer: %{title}") %>
16
+ <% end %>
17
+ <% end %>
18
+ <% if @older %>
19
+ <%= shiplog_entry_link @older, class: shiplog_class(:permalink_nav_link, :older), rel: "next" do %>
20
+ <%= t("shiplog.permalink.older", title: @older.display_title, default: "Older: %{title} →") %>
21
+ <% end %>
22
+ <% end %>
23
+ </nav>
24
+ <% end %>
25
+ <% end %>
@@ -0,0 +1,24 @@
1
+ # The latest published features and changes. Entry ids are tag URIs built from the permalink path, so they
2
+ # stay the same when a title changes.
3
+ title = Shiplog.config.product_name.present? ? t("shiplog.feed.title_with_product", product: Shiplog.config.product_name, default: "%{product} changelog") : t("shiplog.changelog.title", default: "Changelog")
4
+
5
+ atom_feed(language: I18n.locale.to_s, url: shiplog_absolute_url(:changelog_feed), root_url: shiplog_absolute_url(:changelog),
6
+ id: "tag:#{shiplog_tag_host},2005:#{changelog_path}") do |feed|
7
+ feed.title(title)
8
+ feed.author { |author| author.name(Shiplog.config.product_name.presence || "Changelog") } # RFC 4287 4.1.1: the feed or each entry needs one
9
+ # An entry is updated when its text or the translation shown in this feed changes.
10
+ updated_at = ->(entry) { [ entry.updated_at, entry.translation&.updated_at ].compact.max }
11
+ feed.updated(@entries.map(&updated_at).max || Time.current)
12
+
13
+ # Under a translated locale, an entry without a current translation is in English (the default locale): say so.
14
+ english = ->(entry) { Shiplog::Entry.translated_locale?(I18n.locale) && entry.translation.nil? ? { "xml:lang" => I18n.default_locale.to_s } : {} }
15
+
16
+ @entries.each do |entry|
17
+ feed.entry(entry, id: "tag:#{shiplog_tag_host},2005:#{shiplog_entry_path(entry)}", url: shiplog_entry_url(entry),
18
+ published: entry.released_on.in_time_zone, updated: updated_at.(entry)) do |item|
19
+ item.title(entry.display_title.to_s, english.(entry))
20
+ item.category(term: entry.area.slug, label: entry.area.display_name) if entry.area
21
+ item.content(shiplog_feed_content(entry), { type: "html" }.merge(english.(entry)))
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ <%# locals: (areas:, current: nil, query: nil) %>
2
+ <%# the area filter: an "All" link and one link per area (GET ?area=slug, works without JavaScript); the search query is kept %>
3
+ <nav class="<%= shiplog_class(:areas) %>" aria-label="<%= t("shiplog.changelog.areas.label", default: "Filter by area") %>">
4
+ <%= link_to t("shiplog.changelog.areas.all", default: "All"), changelog_path(q: query), class: shiplog_class(:area_link, ("current" unless current)), "aria-current": ("page" unless current) %>
5
+ <% areas.each do |area| %>
6
+ <%= link_to area.display_name, changelog_path(area: area.slug, q: query), class: shiplog_class(:area_link, ("current" if area == current)), "aria-current": ("page" if area == current) %>
7
+ <% end %>
8
+ </nav>
@@ -1,8 +1,5 @@
1
1
  <%# locals: (entry:) %>
2
- <%# one changelog entry: category pill ("New" for features, "Improved" for changes), date, title and body %>
2
+ <%# one changelog entry in the list; the entry itself is the entry_content partial, shared with the permalink page %>
3
3
  <li class="<%= token_list(shiplog_class(:entry, entry.kind), (shiplog_class(:entry_feature) if entry.feature?)) %>">
4
- <span class="<%= shiplog_class(:entry_category) %>"><%= t("shiplog.categories.#{entry.kind}") %></span>
5
- <time class="<%= shiplog_class(:entry_date) %>" datetime="<%= entry.released_on.iso8601 %>"><%= l(entry.released_on, format: t("shiplog.date_format", default: "%-d %B %Y")) %></time>
6
- <h3 class="<%= shiplog_class(:entry_title) %>"><%= entry.title %></h3>
7
- <div class="<%= shiplog_class(:entry_body) %>"><%= simple_format(h(entry.body)) %></div>
4
+ <%= render "shiplog/changelogs/entry_content", entry:, heading: "h3", linked: true %>
8
5
  </li>
@@ -0,0 +1,10 @@
1
+ <%# locals: (entry:, heading: "h3", linked: false, all_images: !linked) %>
2
+ <%# what an entry shows: category pill ("New" for features, "Improved" for changes), date, title (a permalink link in the list) and body. The area tag (features and changes only) follows the category. The images follow the body: the first one in the list, all of them on the permalink page. %>
3
+ <span class="<%= shiplog_class(:entry_category) %>"><%= t("shiplog.categories.#{entry.kind}", default: (entry.feature? ? "New" : "Improved")) %></span>
4
+ <% if entry.area && Shiplog::Entry::CHANGELOG_KINDS.include?(entry.kind) %>
5
+ <span class="<%= shiplog_class(:entry_area) %>"><%= entry.area.display_name %></span>
6
+ <% end %>
7
+ <time class="<%= shiplog_class(:entry_date) %>" datetime="<%= entry.released_on.iso8601 %>"><%= l(entry.released_on, format: t("shiplog.date_format", default: "%-d %B %Y")) %></time>
8
+ <%= content_tag heading, (linked && shiplog_entry_path(entry) ? link_to(entry.display_title, shiplog_entry_path(entry)) : entry.display_title), class: shiplog_class(:entry_title) %>
9
+ <div class="<%= shiplog_class(:entry_body) %>"><%= simple_format(h(entry.display_body)) %></div>
10
+ <%= shiplog_entry_images(entry, limit: (all_images ? nil : 1)) %>
@@ -0,0 +1,18 @@
1
+ <%# locals: (query: nil, count: nil) %>
2
+ <%# the selected area is read from @area, not a local: a copy of this partial from 0.2.0 declares only query and count %>
3
+ <%# the changelog search form (GET, works without JavaScript); with a query, also the result summary and a link back to the changelog (of the same area, when one is selected) %>
4
+ <% label = t("shiplog.changelog.search.label", default: "Search the changelog") %>
5
+ <form class="<%= shiplog_class(:search) %>" role="search" action="<%= changelog_path %>" method="get">
6
+ <input class="<%= shiplog_class(:search_input) %>" type="search" name="q" value="<%= query %>" aria-label="<%= label %>" placeholder="<%= label %>" maxlength="<%= Shiplog::Entry::SEARCH_MAX_LENGTH %>">
7
+ <% if @area %><input type="hidden" name="area" value="<%= @area.slug %>"><% end %>
8
+ <button class="<%= shiplog_class(:search_button) %>" type="submit"><%= t("shiplog.changelog.search.submit", default: "Search") %></button>
9
+ </form>
10
+ <% if query %>
11
+ <p class="<%= shiplog_class(:search_summary) %>">
12
+ <%# with no results the page says so below; a "0 results" line would repeat it %>
13
+ <% if count.to_i.positive? %>
14
+ <%= t("shiplog.changelog.search.summary", count: count, query: query, default: { one: "%{count} result for “%{query}”", other: "%{count} results for “%{query}”" }) %>
15
+ <% end %>
16
+ <%= link_to t("shiplog.changelog.search.clear", default: "Clear search"), changelog_path(area: @area&.slug), class: shiplog_class(:search_clear) %>
17
+ </p>
18
+ <% end %>
@@ -1,7 +1,19 @@
1
- <% content_for :title, t("shiplog.changelog.title") %>
1
+ <% content_for :title, t("shiplog.changelog.title", default: "Changelog") %>
2
+ <% content_for :shiplog_feed, auto_discovery_link_tag(:atom, shiplog_absolute_url(:changelog_feed), title: t("shiplog.feed.link_title", default: "Changelog (Atom)")) %>
2
3
  <%= render layout: "shiplog/page", locals: { name: "changelog" } do %>
3
- <%= render "shiplog/page_header", title: t("shiplog.changelog.title"), lede: t("shiplog.changelog.lede") %>
4
- <% if @months.empty? %>
4
+ <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: t("shiplog.changelog.lede", default: "New features and improvements, as they ship.") %>
5
+ <% if @query %>
6
+ <% content_for :shiplog_robots, "noindex" %>
7
+ <% end %>
8
+ <% if Shiplog.config.changelog_search %>
9
+ <%= render "shiplog/changelogs/search", query: @query, count: @result_count %>
10
+ <% end %>
11
+ <% if @areas.any? %>
12
+ <%= render "shiplog/changelogs/areas", areas: @areas, current: @area, query: @query %>
13
+ <% end %>
14
+ <% if @months.empty? && @query %>
15
+ <p class="<%= shiplog_class(:search_empty) %>"><%= t("shiplog.changelog.search.empty", query: @query, default: "No entries match “%{query}”.") %></p>
16
+ <% elsif @months.empty? %>
5
17
  <%= render "shiplog/empty" %>
6
18
  <% else %>
7
19
  <div class="<%= shiplog_class(:months) %>">
@@ -11,6 +23,9 @@
11
23
  </div>
12
24
  <% end %>
13
25
  <%= render "shiplog/pagination",
14
- newer_path: (changelog_path(page: (@page - 1 if @page > 2)) if @page > 1),
15
- older_path: (changelog_path(page: @page + 1) if @older) %>
26
+ newer_path: (changelog_path(area: @area&.slug, page: (@page - 1 if @page > 2), q: @query) if @page > 1),
27
+ older_path: (changelog_path(area: @area&.slug, page: @page + 1, q: @query) if @older) %>
28
+ <p class="<%= shiplog_class(:footnote) %>">
29
+ <%= link_to t("shiplog.feed.subscribe", default: "Subscribe (Atom)"), changelog_feed_path, class: shiplog_class(:footnote_link, :feed) %>
30
+ </p>
16
31
  <% end %>
@@ -1,6 +1,17 @@
1
1
  <%# locals: (entry:) %>
2
- <%# one roadmap item: its public title and body %>
2
+ <%# one roadmap item: its public title and body, then what places it: the repository's label (config.repository_labels) and how many parts a grouped item has. No label and fewer than two parts renders no meta. Needs Shiplog::RoadmapHelper, which every public controller includes. %>
3
+ <%
4
+ issue = entry.subject
5
+ tag_label = shiplog_repository_label(issue.repository)
6
+ parts = issue.children.size
7
+ %>
3
8
  <li class="<%= shiplog_class(:card) %>">
4
- <h3 class="<%= shiplog_class(:card_title) %>"><%= entry.title %></h3>
5
- <div class="<%= shiplog_class(:card_body) %>"><%= simple_format(h(entry.body)) %></div>
9
+ <h3 class="<%= shiplog_class(:card_title) %>"><%= entry.display_title %></h3>
10
+ <div class="<%= shiplog_class(:card_body) %>"><%= simple_format(h(entry.display_body)) %></div>
11
+ <% if tag_label || parts > 1 %>
12
+ <p class="<%= shiplog_class(:card_meta) %>">
13
+ <% if tag_label %><span class="<%= shiplog_class(:card_tag) %>"><%= tag_label %></span><% end %>
14
+ <% if parts > 1 %><span><%= t("shiplog.roadmap.parts", count: parts, default: { one: "Includes %{count} part", other: "Includes %{count} parts" }) %></span><% end %>
15
+ </p>
16
+ <% end %>
6
17
  </li>
@@ -1,14 +1,14 @@
1
1
  <%# locals: (column:, entries:) %>
2
- <%# one roadmap column (in_progress, coming_soon or later) and its approved entries %>
2
+ <%# one roadmap column (in_progress, coming_soon or later) and its approved entries; the marker and the rule on top carry its status %>
3
3
  <% heading_id = "shiplog-column-#{column.dasherize}" %>
4
4
  <section class="<%= shiplog_class(:column, column) %>" aria-labelledby="<%= heading_id %>">
5
5
  <header class="<%= shiplog_class(:column_header) %>">
6
- <h2 class="<%= shiplog_class(:column_title) %>" id="<%= heading_id %>"><%= t("shiplog.columns.#{column}") %></h2>
6
+ <h2 class="<%= shiplog_class(:column_title) %>" id="<%= heading_id %>"><span class="<%= shiplog_class(:column_marker, column) %>" aria-hidden="true"></span><%= t("shiplog.columns.#{column}", default: shiplog_column_defaults.fetch(column.to_sym, {})[:title] || column.humanize) %></h2>
7
7
  <span class="<%= shiplog_class(:column_count) %>"><%= entries.size %></span>
8
- <p class="<%= shiplog_class(:column_description) %>"><%= t("shiplog.columns.#{column}_description") %></p>
8
+ <p class="<%= shiplog_class(:column_description) %>"><%= t("shiplog.columns.#{column}_description", default: shiplog_column_defaults.fetch(column.to_sym, {})[:description].to_s) %></p>
9
9
  </header>
10
10
  <% if entries.empty? %>
11
- <p class="<%= shiplog_class(:column_empty) %>"><%= t("shiplog.empty") %></p>
11
+ <p class="<%= shiplog_class(:column_empty) %>"><%= t("shiplog.empty", default: "Nothing here yet") %></p>
12
12
  <% else %>
13
13
  <ol class="<%= shiplog_class(:cards) %>">
14
14
  <%= render partial: "shiplog/roadmaps/card", collection: entries, as: :entry %>
@@ -0,0 +1,15 @@
1
+ <%# locals: (entries:) %>
2
+ <%# "Recently shipped": the latest approved changelog entries, each linking to its month on the changelog %>
3
+ <% heading_id = "shiplog-recently-shipped" %>
4
+ <section class="<%= shiplog_class(:shipped) %>" aria-labelledby="<%= heading_id %>">
5
+ <h2 class="<%= shiplog_class(:shipped_title) %>" id="<%= heading_id %>"><%= t("shiplog.roadmap.shipped_heading", default: "Recently shipped") %></h2>
6
+ <%= link_to t("shiplog.roadmap.full_changelog", default: "See the full changelog"), changelog_path, class: shiplog_class(:shipped_link, :all) %>
7
+ <ul class="<%= shiplog_class(:shipped_list) %>">
8
+ <% entries.each do |entry| %>
9
+ <li class="<%= shiplog_class(:shipped_item) %>">
10
+ <time class="<%= shiplog_class(:shipped_date) %>" datetime="<%= entry.released_on.iso8601 %>"><%= l(entry.released_on, format: t("shiplog.roadmap.shipped_date_format", default: "%-d %B")) %></time>
11
+ <%= link_to entry.display_title, changelog_path(anchor: "shiplog-month-#{entry.released_on.strftime("%Y-%m")}"), class: shiplog_class(:shipped_link) %>
12
+ </li>
13
+ <% end %>
14
+ </ul>
15
+ </section>
@@ -1,9 +1,18 @@
1
- <% content_for :title, t("shiplog.roadmap.title") %>
1
+ <% content_for :title, t("shiplog.roadmap.title", default: "Roadmap") %>
2
2
  <%= render layout: "shiplog/page", locals: { name: "roadmap" } do %>
3
- <%= render "shiplog/page_header", title: t("shiplog.roadmap.title"), lede: t("shiplog.roadmap.lede") %>
3
+ <%= render "shiplog/page_header", title: t("shiplog.roadmap.title", default: "Roadmap"), lede: t("shiplog.roadmap.lede", default: "What we're working on and what comes next.") %>
4
4
  <div class="<%= shiplog_class(:columns) %>">
5
5
  <% @columns.each do |column, items| %>
6
6
  <%= render "shiplog/roadmaps/column", column:, entries: items.map(&:entry) %>
7
7
  <% end %>
8
8
  </div>
9
+ <%= render "shiplog/roadmaps/shipped", entries: @shipped if @shipped.any? %>
10
+ <% footnote = t("shiplog.roadmap.footnote", default: "").presence %>
11
+ <% feedback_path = shiplog_feedback_path %>
12
+ <% if footnote || feedback_path %>
13
+ <p class="<%= shiplog_class(:footnote) %>">
14
+ <%= footnote %>
15
+ <% if feedback_path %><%= link_to t("shiplog.roadmap.suggest", default: "Suggest a feature"), feedback_path, class: shiplog_class(:footnote_link) %><% end %>
16
+ </p>
17
+ <% end %>
9
18
  <% end %>
@@ -5,6 +5,22 @@ 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
+ # The locale segment is :translation_locale, not :locale: a host's around_action that switches the locale
9
+ # from params[:locale] must not read it (a configured translation locale may not be an available one).
10
+ locale = { translation_locale: /[A-Za-z][A-Za-z0-9_-]*/ }
11
+ patch "translations/:translation_locale", to: "translations#update", as: :translation, constraints: locale
12
+ post "translations/:translation_locale/retranslation", to: "translations/retranslations#create", as: :translation_retranslation, constraints: locale
13
+ resources :images, only: %i[create update destroy] do
14
+ resource :move, only: :create, module: :images
15
+ end
16
+ end
17
+ end
18
+ resource :area_detection, only: :create, path: "areas/detection"
19
+ resource :area_translation, only: :create, path: "areas/translation"
20
+ resources :areas, only: %i[index update destroy] do
21
+ scope module: :areas do
22
+ resource :merge, only: :create
23
+ resource :move, only: :create
8
24
  end
9
25
  end
10
26
  resource :board, only: %i[show update]