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,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)) %>
@@ -1,8 +1,10 @@
1
1
  <%# locals: (query: nil, count: nil) %>
2
- <%# the changelog search form (GET, works without JavaScript); with a query, also the result summary and a link back to the full changelog %>
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) %>
3
4
  <% label = t("shiplog.changelog.search.label", default: "Search the changelog") %>
4
5
  <form class="<%= shiplog_class(:search) %>" role="search" action="<%= changelog_path %>" method="get">
5
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 %>
6
8
  <button class="<%= shiplog_class(:search_button) %>" type="submit"><%= t("shiplog.changelog.search.submit", default: "Search") %></button>
7
9
  </form>
8
10
  <% if query %>
@@ -11,6 +13,6 @@
11
13
  <% if count.to_i.positive? %>
12
14
  <%= t("shiplog.changelog.search.summary", count: count, query: query, default: { one: "%{count} result for “%{query}”", other: "%{count} results for “%{query}”" }) %>
13
15
  <% end %>
14
- <%= link_to t("shiplog.changelog.search.clear", default: "Clear search"), changelog_path, class: shiplog_class(:search_clear) %>
16
+ <%= link_to t("shiplog.changelog.search.clear", default: "Clear search"), changelog_path(area: @area&.slug), class: shiplog_class(:search_clear) %>
15
17
  </p>
16
18
  <% end %>
@@ -1,12 +1,16 @@
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
+ <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: t("shiplog.changelog.lede", default: "New features and improvements, as they ship.") %>
4
5
  <% if @query %>
5
6
  <% content_for :shiplog_robots, "noindex" %>
6
7
  <% end %>
7
8
  <% if Shiplog.config.changelog_search %>
8
9
  <%= render "shiplog/changelogs/search", query: @query, count: @result_count %>
9
10
  <% end %>
11
+ <% if @areas.any? %>
12
+ <%= render "shiplog/changelogs/areas", areas: @areas, current: @area, query: @query %>
13
+ <% end %>
10
14
  <% if @months.empty? && @query %>
11
15
  <p class="<%= shiplog_class(:search_empty) %>"><%= t("shiplog.changelog.search.empty", query: @query, default: "No entries match “%{query}”.") %></p>
12
16
  <% elsif @months.empty? %>
@@ -19,6 +23,9 @@
19
23
  </div>
20
24
  <% end %>
21
25
  <%= render "shiplog/pagination",
22
- newer_path: (changelog_path(page: (@page - 1 if @page > 2), q: @query) if @page > 1),
23
- older_path: (changelog_path(page: @page + 1, q: @query) 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>
24
31
  <% end %>
@@ -6,8 +6,8 @@
6
6
  parts = issue.children.size
7
7
  %>
8
8
  <li class="<%= shiplog_class(:card) %>">
9
- <h3 class="<%= shiplog_class(:card_title) %>"><%= entry.title %></h3>
10
- <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
11
  <% if tag_label || parts > 1 %>
12
12
  <p class="<%= shiplog_class(:card_meta) %>">
13
13
  <% if tag_label %><span class="<%= shiplog_class(:card_tag) %>"><%= tag_label %></span><% end %>
@@ -3,12 +3,12 @@
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 %>"><span class="<%= shiplog_class(:column_marker, column) %>" aria-hidden="true"></span><%= 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 %>
@@ -8,7 +8,7 @@
8
8
  <% entries.each do |entry| %>
9
9
  <li class="<%= shiplog_class(:shipped_item) %>">
10
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.title, changelog_path(anchor: "shiplog-month-#{entry.released_on.strftime("%Y-%m")}"), class: shiplog_class(:shipped_link) %>
11
+ <%= link_to entry.display_title, changelog_path(anchor: "shiplog-month-#{entry.released_on.strftime("%Y-%m")}"), class: shiplog_class(:shipped_link) %>
12
12
  </li>
13
13
  <% end %>
14
14
  </ul>
@@ -1,6 +1,6 @@
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) %>
@@ -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]
@@ -46,12 +46,61 @@ en:
46
46
  other: "%{count} results for “%{query}”"
47
47
  clear: Clear search
48
48
  empty: "No entries match “%{query}”."
49
+ areas:
50
+ label: Filter by area
51
+ all: All
52
+ permalink:
53
+ title: "%{title} · %{changelog}"
54
+ back: "← Back to the changelog"
55
+ nav_label: More changelog entries
56
+ newer: "← Newer: %{title}"
57
+ older: "Older: %{title} →"
58
+ feed:
59
+ title_with_product: "%{product} changelog"
60
+ link_title: Changelog (Atom)
61
+ subscribe: Subscribe (Atom)
49
62
  admin:
50
63
  title: Shiplog admin
51
64
  nav:
52
65
  drafts: Drafts
53
66
  board: Roadmap board
54
67
  sync: Sync
68
+ areas: Areas
69
+ areas:
70
+ title: Product areas
71
+ help: The AI groups entries into areas (at most %{max}) while it rewrites them. Visitors can filter the changelog by area once two areas have published entries.
72
+ detect: Detect areas
73
+ recheck: Re-check unplaced entries
74
+ recheck_help: Ask the AI again about entries it left without an area, e.g. after renaming, merging or adding areas.
75
+ no_rewriter: Detection needs config.rewriter, which is not set.
76
+ last_detection: "Last detection: %{time}, assigned %{count}"
77
+ last_error: "Last detection failed (%{time}): %{error}"
78
+ detection_queued: Detection queued. Refresh in a moment.
79
+ translate_names: Translate names
80
+ translate_names_help: Ask the AI for the names that are missing in some language. Names that are already there, including ones you edited, are kept.
81
+ no_locales: Translating names needs config.locales, which is empty.
82
+ no_rewriter_translate: Translating names needs config.rewriter, which is not set.
83
+ translation_queued:
84
+ one: Translation queued for %{count} area. Refresh in a moment.
85
+ other: Translation queued for %{count} areas. Refresh in a moment.
86
+ empty: No areas yet. Detect areas to propose some from your entries.
87
+ columns:
88
+ name: Name
89
+ entries: Entries
90
+ order: Order
91
+ merge: Merge into
92
+ name_label: Name
93
+ rename: Rename
94
+ renamed: Area renamed.
95
+ move_up: Move up
96
+ move_down: Move down
97
+ merge: Merge
98
+ merge_into: Merge into
99
+ merged: "%{from} merged into %{into}."
100
+ merge_target: Choose another area to merge into.
101
+ merge_help: Merging moves the entries to the other area and deletes this one. Deleting an area leaves its entries without one.
102
+ delete: Delete
103
+ deleted: Area deleted. Its entries have no area now.
55
104
  entries:
56
105
  title: Entries
57
106
  filters: Filter entries
@@ -73,6 +122,8 @@ en:
73
122
  restore: Restore
74
123
  save: Save
75
124
  regenerate: Regenerate
125
+ no_area: None
126
+ area_gone: no longer exists
76
127
  regenerate_warning: Regenerating unpublishes this entry until re-approved.
77
128
  source_changed: GitHub text changed since this draft
78
129
  rewrite_error: Rewrite failed
@@ -80,6 +131,39 @@ en:
80
131
  public_text: Public text
81
132
  source: GitHub source
82
133
  back: Back to drafts
134
+ images:
135
+ title: Screenshots
136
+ help: 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.
137
+ empty: No screenshots yet.
138
+ alt: Alt text
139
+ alt_placeholder: Describe the image (defaults to the title)
140
+ save_alt: Save alt text
141
+ remove: Remove
142
+ move_up: Up
143
+ move_down: Down
144
+ upload: Upload images
145
+ upload_label: Images (PNG, JPEG, GIF or WebP, up to 5 MB each)
146
+ none_selected: Choose at least one image.
147
+ rejected: "Not added, only PNG, JPEG, GIF and WebP images up to 5 MB and 10,000 pixels a side are accepted: %{names}"
148
+ added:
149
+ one: Image added.
150
+ other: "%{count} images added."
151
+ alt_saved: Alt text saved.
152
+ removed: Image removed.
153
+ moved: Image moved.
154
+ translations:
155
+ title: Translations
156
+ help: "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."
157
+ not_approved: Translations are written when the entry is approved.
158
+ missing: Missing
159
+ stale: Stale
160
+ current: Up to date
161
+ edited: Edited by an admin
162
+ retranslate: Retranslate
163
+ saved: Translation saved.
164
+ queued: Translation queued. Refresh in a moment.
165
+ only_approved: Only approved entries are translated.
166
+ no_rewriter: Translating needs config.rewriter, which is not set.
83
167
  category_options:
84
168
  feature: New feature
85
169
  change: Improvement
@@ -101,6 +185,7 @@ en:
101
185
  body: Text
102
186
  released_on: Released on
103
187
  category: Category
188
+ area: Area
104
189
  reason: Reason
105
190
  board:
106
191
  title: Roadmap board
@@ -0,0 +1,223 @@
1
+ # Textos de Shiplog en español. Los hosts pueden sobrescribir cualquier clave en sus propios archivos de idioma.
2
+ es:
3
+ activerecord:
4
+ attributes:
5
+ shiplog/entry:
6
+ changelog_kind: Categoría
7
+ # Los nombres de los meses que usa el formato de fecha de Shiplog (rails-i18n los trae también; son los mismos).
8
+ date:
9
+ month_names: [~, enero, febrero, marzo, abril, mayo, junio, julio, agosto, septiembre, octubre, noviembre, diciembre]
10
+ shiplog:
11
+ columns:
12
+ in_progress: En desarrollo
13
+ coming_soon: Próximamente
14
+ later: Más adelante
15
+ in_progress_description: Lo que estamos construyendo ahora
16
+ coming_soon_description: Lo siguiente en la lista
17
+ later_description: En nuestro radar
18
+ categories:
19
+ feature: Nuevo
20
+ change: Mejorado
21
+ empty: Todavía no hay nada
22
+ date_format: "%-d de %B de %Y"
23
+ roadmap:
24
+ title: Hoja de ruta
25
+ lede: En qué estamos trabajando y qué viene después.
26
+ parts:
27
+ one: Incluye %{count} parte
28
+ other: Incluye %{count} partes
29
+ shipped_heading: Lanzado recientemente
30
+ shipped_date_format: "%-d de %B"
31
+ full_changelog: Ver el registro de cambios completo
32
+ footnote: ""
33
+ suggest: Sugerir una función
34
+ switcher:
35
+ label: Actualizaciones del producto
36
+ roadmap: Hoja de ruta
37
+ changelog: Registro de cambios
38
+ changelog:
39
+ title: Registro de cambios
40
+ lede: Nuevas funciones y mejoras, a medida que se lanzan.
41
+ pagination:
42
+ label: Paginación
43
+ older: Anteriores
44
+ newer: Más recientes
45
+ search:
46
+ label: Buscar en el registro de cambios
47
+ submit: Buscar
48
+ summary:
49
+ one: "%{count} resultado para “%{query}”"
50
+ other: "%{count} resultados para “%{query}”"
51
+ clear: Borrar búsqueda
52
+ empty: "Ninguna entrada coincide con “%{query}”."
53
+ areas:
54
+ label: Filtrar por área
55
+ all: Todas
56
+ permalink:
57
+ title: "%{title} · %{changelog}"
58
+ back: "← Volver al registro de cambios"
59
+ nav_label: Más cambios
60
+ newer: "← Más reciente: %{title}"
61
+ older: "Anterior: %{title} →"
62
+ feed:
63
+ title_with_product: "Registro de cambios de %{product}"
64
+ link_title: Registro de cambios (Atom)
65
+ subscribe: Suscribirse (Atom)
66
+ admin:
67
+ title: Administración de Shiplog
68
+ nav:
69
+ drafts: Borradores
70
+ board: Tablero de la hoja de ruta
71
+ sync: Sincronización
72
+ areas: Áreas
73
+ areas:
74
+ title: Áreas del producto
75
+ help: La IA agrupa las entradas en áreas (como máximo %{max}) mientras las reescribe. Los visitantes pueden filtrar el registro de cambios por área cuando hay dos áreas con entradas publicadas.
76
+ detect: Detectar áreas
77
+ recheck: Revisar de nuevo las entradas sin área
78
+ recheck_help: Vuelve a preguntar a la IA por las entradas que dejó sin área, por ejemplo después de renombrar, fusionar o añadir áreas.
79
+ no_rewriter: La detección necesita config.rewriter, que no está configurado.
80
+ last_detection: "Última detección: %{time}, %{count} asignadas"
81
+ last_error: "La última detección falló (%{time}): %{error}"
82
+ detection_queued: Detección en cola. Actualiza en un momento.
83
+ translate_names: Traducir nombres
84
+ translate_names_help: Pide a la IA los nombres que faltan en algún idioma. Los nombres que ya existen, incluidos los que editaste, se conservan.
85
+ no_locales: Traducir los nombres necesita config.locales, que está vacío.
86
+ no_rewriter_translate: Traducir los nombres necesita config.rewriter, que no está configurado.
87
+ translation_queued:
88
+ one: Traducción en cola para %{count} área. Actualiza en un momento.
89
+ other: Traducción en cola para %{count} áreas. Actualiza en un momento.
90
+ empty: Todavía no hay áreas. Detecta áreas para que se propongan a partir de tus entradas.
91
+ columns:
92
+ name: Nombre
93
+ entries: Entradas
94
+ order: Orden
95
+ merge: Fusionar con
96
+ name_label: Nombre
97
+ rename: Renombrar
98
+ renamed: Área renombrada.
99
+ move_up: Subir
100
+ move_down: Bajar
101
+ merge: Fusionar
102
+ merge_into: Fusionar con
103
+ merged: "%{from} se fusionó con %{into}."
104
+ merge_target: Elige otra área con la que fusionar.
105
+ merge_help: Al fusionar, las entradas pasan a la otra área y esta se elimina. Si eliminas un área, sus entradas se quedan sin área.
106
+ delete: Eliminar
107
+ deleted: Área eliminada. Sus entradas ya no tienen área.
108
+ entries:
109
+ title: Entradas
110
+ filters: Filtrar entradas
111
+ all_kinds: Todos los tipos
112
+ empty: Ninguna entrada coincide.
113
+ pagination: Paginación
114
+ older: Anteriores
115
+ newer: Más recientes
116
+ saved: Entrada guardada.
117
+ approved: Entrada aprobada.
118
+ discarded: Entrada descartada.
119
+ restored: Entrada devuelta a borrador.
120
+ invalid_transition: Esa acción no está disponible para esta entrada en su estado actual.
121
+ rewrite_queued: Reescritura en cola. Actualiza en un momento.
122
+ edit: Editar
123
+ edit_title: "Editar %{kind}"
124
+ approve: Aprobar
125
+ discard: Descartar
126
+ restore: Restaurar
127
+ save: Guardar
128
+ regenerate: Regenerar
129
+ no_area: Ninguna
130
+ area_gone: ya no existe
131
+ regenerate_warning: Al regenerar, esta entrada deja de estar publicada hasta que se apruebe de nuevo.
132
+ source_changed: El texto de GitHub cambió desde este borrador
133
+ rewrite_error: Falló la reescritura
134
+ not_rewritten: Todavía sin reescribir
135
+ public_text: Texto público
136
+ source: Origen en GitHub
137
+ back: Volver a los borradores
138
+ images:
139
+ title: Capturas de pantalla
140
+ help: Se muestran bajo el texto en el registro de cambios (la primera en la lista, todas en la página de la entrada). Se toman de la incidencia o del pull request de GitHub al escribir el borrador; también puedes subir las tuyas.
141
+ empty: Todavía no hay capturas.
142
+ alt: Texto alternativo
143
+ alt_placeholder: Describe la imagen (por defecto, el título)
144
+ save_alt: Guardar texto alternativo
145
+ remove: Quitar
146
+ move_up: Subir
147
+ move_down: Bajar
148
+ upload: Subir imágenes
149
+ upload_label: Imágenes (PNG, JPEG, GIF o WebP, hasta 5 MB cada una)
150
+ none_selected: Elige al menos una imagen.
151
+ rejected: "No se añadieron; solo se aceptan imágenes PNG, JPEG, GIF y WebP de hasta 5 MB y 10.000 píxeles por lado: %{names}"
152
+ added:
153
+ one: Imagen añadida.
154
+ other: "%{count} imágenes añadidas."
155
+ alt_saved: Texto alternativo guardado.
156
+ removed: Imagen quitada.
157
+ moved: Imagen movida.
158
+ translations:
159
+ title: Traducciones
160
+ help: "Al aprobar una entrada, esta se traduce automáticamente, y la traducción se publica junto con el texto en inglés. Una traducción que edites se conserva hasta que cambie el texto en inglés; entonces se vuelve a traducir. Una traducción desactualizada o ausente no se muestra: los visitantes ven el texto en inglés."
161
+ not_approved: Las traducciones se escriben cuando se aprueba la entrada.
162
+ missing: Falta
163
+ stale: Desactualizada
164
+ current: Al día
165
+ edited: Editada por un administrador
166
+ retranslate: Traducir de nuevo
167
+ saved: Traducción guardada.
168
+ queued: Traducción en cola. Actualiza en un momento.
169
+ only_approved: Solo se traducen las entradas aprobadas.
170
+ no_rewriter: Para traducir hace falta config.rewriter, que no está configurado.
171
+ category_options:
172
+ feature: Nueva función
173
+ change: Mejora
174
+ statuses:
175
+ draft: Borradores
176
+ approved: Aprobadas
177
+ discarded: Descartadas
178
+ kinds:
179
+ roadmap: Hoja de ruta
180
+ feature: Función
181
+ change: Cambio
182
+ columns:
183
+ entry: Entrada
184
+ kind: Tipo
185
+ source: Origen
186
+ actions: Acciones
187
+ fields:
188
+ title: Título
189
+ body: Texto
190
+ released_on: Fecha de lanzamiento
191
+ category: Categoría
192
+ area: Área
193
+ reason: Motivo
194
+ board:
195
+ title: Tablero de la hoja de ruta
196
+ help: Arrastra las tarjetas entre columnas para fijarlas y ordenarlas. Sin JavaScript, usa el botón Mover de cada tarjeta.
197
+ updated: Tablero actualizado.
198
+ not_approved: Sin aprobar
199
+ pinned: Fijada
200
+ move_to: Mover a
201
+ move: Mover
202
+ unpin: Quitar de fijadas
203
+ sync:
204
+ title: Estado de la sincronización
205
+ queued: Sincronización en cola.
206
+ webhook_secret_missing: El secreto del webhook no está configurado, así que se rechazan los webhooks de GitHub.
207
+ github_token_missing: El token de GitHub no está configurado, así que no se puede sincronizar.
208
+ sync_now: Sincronizar ahora
209
+ sync_and_scan: Sincronizar y buscar cambios
210
+ repositories: Repositorios
211
+ no_repositories: No hay repositorios configurados.
212
+ repository: Repositorio
213
+ synced_at: Última sincronización
214
+ changes_scanned_at: Cambios revisados
215
+ last_error: Último error
216
+ never: Nunca
217
+ deliveries: Últimas entregas de webhook
218
+ no_deliveries: Todavía no hay entregas.
219
+ event: Evento
220
+ action: Acción
221
+ received_at: Recibida
222
+ processed_at: Procesada
223
+ error: Error
data/config/routes.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  Shiplog::Engine.routes.draw do
2
2
  get Shiplog.config.roadmap_path, to: "roadmaps#show", as: :roadmap
3
+ # The feed and the permalinks come before the plain changelog route, whose optional (.:format) would
4
+ # otherwise take /changelog.atom. A slug has no dot, and the permalink takes no format.
5
+ get "#{Shiplog.config.changelog_path}.atom", to: "changelog_feeds#show", as: :changelog_feed, defaults: { format: "atom" }
6
+ get "#{Shiplog.config.changelog_path}/:slug", to: "changelog_entries#show", as: :changelog_entry,
7
+ format: false, constraints: { slug: /[a-z0-9-]+/ }
3
8
  get Shiplog.config.changelog_path, to: "changelogs#show", as: :changelog
4
9
  post "shiplog/github/webhook", to: "webhooks#create", as: :github_webhook
5
10
  get "shiplog/assets/:version/:name", to: "frontends#show", as: :frontend, constraints: { version: /[^\/]+/, name: /[a-z_]+\.(css|js)/ }
@@ -0,0 +1,105 @@
1
+ # Everything 0.3.0 adds to the schema, one section per feature. Additive and reversible.
2
+ class Shiplog030 < ActiveRecord::Migration[8.1]
3
+ # A migration must not depend on model code that changes later: this is a bare table-backed class.
4
+ class MigrationEntry < ActiveRecord::Base
5
+ self.table_name = "shiplog_entries"
6
+ self.inheritance_column = nil
7
+ end
8
+
9
+ SLUG_MAX_LENGTH = 80
10
+
11
+ def change
12
+ # --- Entry permalinks (/changelog/:slug) ---
13
+ # Set when an entry is first approved, never changed afterwards, so links never break. Nil for roadmap
14
+ # entries and drafts. Entries approved before 0.3.0 get theirs here (oldest first, so the oldest keeps the
15
+ # plain slug on duplicate titles): the public pages never write, so nothing else fills them in.
16
+ add_column :shiplog_entries, :slug, :string
17
+ add_index :shiplog_entries, :slug, unique: true, where: "slug IS NOT NULL"
18
+ reversible do |direction|
19
+ direction.up do
20
+ MigrationEntry.reset_column_information
21
+ backfill_slugs
22
+ end
23
+ end
24
+
25
+ # --- "What's new" unseen count ---
26
+ # `Shiplog.unseen_count` counts approved feature and change entries with `approved_at > since`:
27
+ # an equality column first, then the range column.
28
+ add_index :shiplog_entries, [ :status, :approved_at ]
29
+
30
+ # --- Product areas (detected by AI) ---
31
+ # `entries.area_id` is nullable; deleting an area nullifies it (merging moves the entries first).
32
+ # `area_set_by_admin` marks an area chosen by hand, which detection never overwrites.
33
+ create_table :shiplog_areas do |t|
34
+ t.string :name, null: false
35
+ t.string :slug, null: false
36
+ t.integer :position, null: false, default: 0
37
+ # The name in each of config.locales, keyed by locale string ({ "es" => "Listas de embalaje" }); empty
38
+ # until translated. Slugs stay English, so ?area= URLs are shared across locales.
39
+ t.jsonb :name_translations, null: false, default: {}
40
+ t.timestamps
41
+ end
42
+ add_index :shiplog_areas, :slug, unique: true
43
+ add_reference :shiplog_entries, :area, null: true, index: true, foreign_key: { to_table: :shiplog_areas, on_delete: :nullify }
44
+ add_column :shiplog_entries, :area_set_by_admin, :boolean, null: false, default: false
45
+ # When detection last asked the AI about the entry (placed or not): unplaced entries are not asked again
46
+ # until an admin asks for a re-check.
47
+ add_column :shiplog_entries, :area_detected_at, :datetime
48
+ # A small key-value store for state that belongs to no record, e.g. the result of the last area detection
49
+ # (shown on the admin's Areas page).
50
+ create_table :shiplog_settings do |t|
51
+ t.string :key, null: false
52
+ t.jsonb :value, null: false, default: {}
53
+ t.timestamps
54
+ end
55
+ add_index :shiplog_settings, :key, unique: true
56
+
57
+ # --- Screenshots (needs Active Storage in the host; the columns are harmless without it) ---
58
+ # `image_order` is the ordered list of the entry's image blob ids, `image_alts` the alt text per blob id
59
+ # ({ "12" => "The dark theme" }); a missing alt text falls back to the entry's title.
60
+ add_column :shiplog_entries, :image_order, :jsonb, null: false, default: []
61
+ add_column :shiplog_entries, :image_alts, :jsonb, null: false, default: {}
62
+
63
+ # --- Translations of entries (config.locales) ---
64
+ # One row per entry and locale, published with the English text (no second approval). `source_digest` is the
65
+ # digest of the English title and body it was made from: a row whose digest differs from the entry's current
66
+ # text is stale and never shown. `edited_by_admin` keeps an admin's edit until the English text changes.
67
+ create_table :shiplog_entry_translations do |t|
68
+ t.references :entry, null: false, index: false, foreign_key: { to_table: :shiplog_entries, on_delete: :cascade }
69
+ t.string :locale, null: false
70
+ t.string :title, null: false
71
+ t.text :body, null: false, default: ""
72
+ t.string :source_digest, null: false
73
+ t.boolean :edited_by_admin, null: false, default: false
74
+ t.timestamps
75
+ end
76
+ add_index :shiplog_entry_translations, [ :entry_id, :locale ], unique: true
77
+ end
78
+
79
+ # Gives every approved feature and change with a release date, and no slug, one: the title as [a-z0-9-] only
80
+ # (cut at 80 characters), made unique with -2, -3, ...; entry-<id> when nothing is left. The same rules as
81
+ # Shiplog::Entry#generate_slug at the time of 0.3.0.
82
+ def backfill_slugs
83
+ taken = MigrationEntry.where.not(slug: nil).pluck(:slug).to_set
84
+ MigrationEntry.where(status: "approved", kind: %w[feature change], slug: nil).where.not(released_on: nil)
85
+ .order(:released_on, :approved_at, :id).to_a.each do |entry|
86
+ slug = slug_for(entry, taken)
87
+ taken << slug
88
+ MigrationEntry.where(id: entry.id, slug: nil).update_all(slug:)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def slug_for(entry, taken)
95
+ base = entry.title.to_s.parameterize.tr("_", "-").squeeze("-").first(SLUG_MAX_LENGTH).gsub(/\A-+|-+\z/, "").presence || "entry-#{entry.id}"
96
+ candidate = base
97
+ suffix = 2
98
+ while taken.include?(candidate)
99
+ tail = "-#{suffix}"
100
+ candidate = "#{base.first(SLUG_MAX_LENGTH - tail.length).delete_suffix("-")}#{tail}"
101
+ suffix += 1
102
+ end
103
+ candidate
104
+ end
105
+ end
@@ -59,7 +59,16 @@ module Shiplog
59
59
  search_button: "rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500",
60
60
  search_summary: "mb-4 text-sm text-gray-500 dark:text-gray-400",
61
61
  search_clear: "ml-2 font-medium text-indigo-600 hover:underline dark:text-indigo-400",
62
- search_empty: "text-gray-500 dark:text-gray-400"
62
+ search_empty: "text-gray-500 dark:text-gray-400",
63
+ permalink: "mx-auto max-w-2xl rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-800 dark:bg-gray-900",
64
+ permalink_back: "mt-6 inline-block text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
65
+ permalink_nav: "mt-8 flex flex-wrap justify-between gap-4 border-t border-gray-200 pt-4 dark:border-gray-800",
66
+ permalink_nav_link: "text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400",
67
+ areas: "my-4 flex flex-wrap gap-2",
68
+ area_link: "rounded-full border border-gray-300 px-3 py-0.5 text-sm hover:border-indigo-500 dark:border-gray-700 [&.shiplog-areas__link--current]:border-indigo-600 [&.shiplog-areas__link--current]:bg-indigo-600 [&.shiplog-areas__link--current]:text-white",
69
+ entry_area: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
70
+ entry_images: "mt-3 grid w-full gap-3",
71
+ entry_image: "h-auto max-w-full rounded-lg border border-gray-200 dark:border-gray-800"
63
72
  }.freeze
64
73
 
65
74
  def copy_initializer