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