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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +266 -9
- 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/admin/syncs_controller.rb +3 -2
- 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 +30 -4
- data/app/controllers/shiplog/public_controller.rb +58 -1
- data/app/controllers/shiplog/roadmaps_controller.rb +27 -2
- data/app/frontend/shiplog/admin.css +9 -0
- data/app/frontend/shiplog/shiplog.css +168 -8
- data/app/helpers/shiplog/admin/entries_helper.rb +15 -0
- data/app/helpers/shiplog/callable_helper.rb +12 -0
- data/app/helpers/shiplog/frontend_helper.rb +1 -0
- data/app/helpers/shiplog/permalink_helper.rb +113 -0
- data/app/helpers/shiplog/roadmap_helper.rb +24 -0
- data/app/helpers/shiplog/switcher_helper.rb +101 -0
- data/app/helpers/shiplog/unseen_helper.rb +9 -0
- data/app/jobs/shiplog/detect_areas_job.rb +18 -0
- data/app/jobs/shiplog/refresh_job.rb +1 -0
- data/app/jobs/shiplog/rewrite_entry_job.rb +4 -1
- data/app/jobs/shiplog/scan_changes_job.rb +19 -5
- data/app/jobs/shiplog/sync_job.rb +1 -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/change_candidates.rb +39 -4
- data/app/models/shiplog/entry/images.rb +127 -0
- data/app/models/shiplog/entry.rb +218 -2
- 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/pull_request.rb +4 -0
- data/app/models/shiplog/pull_request_upgrade.rb +5 -0
- data/app/models/shiplog/rewriter/prompt.rb +57 -9
- data/app/models/shiplog/rewriter.rb +50 -13
- data/app/models/shiplog/setting.rb +19 -0
- data/app/models/shiplog/sync/pull_request_importer.rb +23 -1
- data/app/models/shiplog/sync/repository_sync.rb +2 -0
- data/app/models/shiplog/sync/upgrade_detector.rb +203 -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 +4 -0
- data/app/views/shiplog/_empty.html.erb +1 -1
- data/app/views/shiplog/_page_header.html.erb +2 -1
- data/app/views/shiplog/_pagination.html.erb +3 -3
- data/app/views/shiplog/_switcher.html.erb +13 -0
- 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 +4 -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 +18 -0
- data/app/views/shiplog/changelogs/show.html.erb +20 -5
- data/app/views/shiplog/roadmaps/_card.html.erb +14 -3
- data/app/views/shiplog/roadmaps/_column.html.erb +4 -4
- data/app/views/shiplog/roadmaps/_shipped.html.erb +15 -0
- data/app/views/shiplog/roadmaps/show.html.erb +11 -2
- data/config/admin_routes.rb +16 -0
- data/config/locales/en.yml +105 -0
- data/config/locales/es.yml +223 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb +30 -0
- data/db/migrate/20261027000002_shiplog_0_3_0.rb +105 -0
- data/lib/generators/shiplog/install/install_generator.rb +29 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +82 -1
- data/lib/shiplog/classes.rb +29 -1
- data/lib/shiplog/configuration.rb +245 -3
- data/lib/shiplog/engine.rb +3 -0
- data/lib/shiplog/github/client.rb +42 -9
- data/lib/shiplog/github/errors.rb +3 -0
- data/lib/shiplog/github/queries.rb +44 -1
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +73 -0
- data/lib/tasks/shiplog.rake +29 -0
- metadata +48 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Areas
|
|
4
|
+
# Moves an area one place up or down (params[:direction]); anything else changes nothing.
|
|
5
|
+
class MovesController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
Area.find(params[:area_id]).move(params[:direction])
|
|
8
|
+
redirect_to areas_path
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
# The Areas page: product areas with their entry counts; rename, edit the translated names, delete. Reordering, merging and detection
|
|
4
|
+
# are their own small controllers.
|
|
5
|
+
class AreasController < ApplicationController
|
|
6
|
+
before_action :set_area, only: %i[update destroy]
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@areas = Area.ordered.to_a
|
|
10
|
+
@counts = Area.entry_counts
|
|
11
|
+
@detection = Setting.read(DetectAreasJob::SETTING)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update
|
|
15
|
+
if @area.update(area_attributes)
|
|
16
|
+
redirect_to areas_path, notice: t("shiplog.admin.areas.renamed", default: "Area renamed.")
|
|
17
|
+
else
|
|
18
|
+
redirect_to areas_path, alert: @area.errors.full_messages.to_sentence
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def destroy
|
|
23
|
+
@area.destroy!
|
|
24
|
+
redirect_to areas_path, notice: t("shiplog.admin.areas.deleted", default: "Area deleted. Its entries have no area now.")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The name, and the translated names of the configured locales. A rename drops the translations (they were of
|
|
30
|
+
# the old name) and enqueues a new translation, so the translations sent with it are ignored; without a
|
|
31
|
+
# rename they are kept as the admin typed them (an empty field removes one), over any earlier translation.
|
|
32
|
+
def area_attributes
|
|
33
|
+
permitted = params.expect(area: [ :name, { name_translations: {} } ])
|
|
34
|
+
attributes = { name: permitted[:name] }
|
|
35
|
+
submitted = permitted[:name_translations]
|
|
36
|
+
if submitted && permitted[:name].to_s.squish == @area.name
|
|
37
|
+
locales = Shiplog.config.locales.map(&:to_s)
|
|
38
|
+
attributes[:name_translations] = @area.name_translations.merge(submitted.to_h.slice(*locales).select { |_locale, value| value.is_a?(String) })
|
|
39
|
+
end
|
|
40
|
+
attributes
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set_area
|
|
44
|
+
@area = Area.find(params[:id])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Entries
|
|
4
|
+
module Images
|
|
5
|
+
# Moves an image up or down in its entry's order (params[:direction]).
|
|
6
|
+
class MovesController < ApplicationController
|
|
7
|
+
before_action { head :not_found unless Shiplog.images? }
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
entry = Entry.find(params[:entry_id])
|
|
11
|
+
return head(:not_found) unless entry.find_image(params[:image_id])
|
|
12
|
+
|
|
13
|
+
entry.move_image(params[:image_id], params[:direction] == "up" ? :up : :down)
|
|
14
|
+
redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.entries.images.moved", default: "Image moved.")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Entries
|
|
4
|
+
# An entry's screenshots on the edit page: upload, alt text, remove. :id is the image's blob id, the key of
|
|
5
|
+
# the entry's order and alt texts. Not found while screenshots are off.
|
|
6
|
+
class ImagesController < ApplicationController
|
|
7
|
+
before_action :require_images
|
|
8
|
+
before_action :set_entry
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
files = uploads
|
|
12
|
+
return back(alert: t("shiplog.admin.entries.images.none_selected", default: "Choose at least one image.")) if files.empty?
|
|
13
|
+
|
|
14
|
+
checked = files.index_with { |file| inspect_upload(file) }
|
|
15
|
+
rejected = checked.select { |_file, info| info.nil? }.keys
|
|
16
|
+
if rejected.any?
|
|
17
|
+
names = rejected.map { |file| file.original_filename.to_s.scrub.first(100) }.to_sentence
|
|
18
|
+
return back(alert: t("shiplog.admin.entries.images.rejected", names:, default: "Not added, only PNG, JPEG, GIF and WebP images up to 5 MB and 10,000 pixels a side are accepted: %{names}"))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@entry.add_images(checked.map { |file, info| attachable(file, info) }) # uploads first, locks only to attach
|
|
22
|
+
back(notice: t("shiplog.admin.entries.images.added", count: files.size, default: { one: "Image added.", other: "%{count} images added." }))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update
|
|
26
|
+
return head(:not_found) unless @entry.find_image(params[:id])
|
|
27
|
+
|
|
28
|
+
@entry.update_image_alt(params[:id], params[:alt])
|
|
29
|
+
back(notice: t("shiplog.admin.entries.images.alt_saved", default: "Alt text saved."))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def destroy
|
|
33
|
+
return head(:not_found) unless @entry.find_image(params[:id])
|
|
34
|
+
|
|
35
|
+
@entry.remove_image(params[:id])
|
|
36
|
+
back(notice: t("shiplog.admin.entries.images.removed", default: "Image removed."))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def require_images
|
|
42
|
+
head :not_found unless Shiplog.images?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_entry
|
|
46
|
+
@entry = Entry.find(params[:entry_id])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def back(**flash)
|
|
50
|
+
redirect_to edit_entry_path(@entry, inbox_filter_params), **flash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def uploads
|
|
54
|
+
Array(params[:images]).select { |file| file.is_a?(ActionDispatch::Http::UploadedFile) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Decided by the file's bytes and size, never by the declared name or content type: the type, and the
|
|
58
|
+
# pixel size read from the header, which must be within the limits (see ImageType). Nil when refused.
|
|
59
|
+
def inspect_upload(file)
|
|
60
|
+
return unless file.size.between?(1, ImageType::MAX_BYTES)
|
|
61
|
+
|
|
62
|
+
file.rewind
|
|
63
|
+
ImageType.analyze(file.read(ImageType::MAX_BYTES + 1))
|
|
64
|
+
rescue ImageType::Invalid
|
|
65
|
+
nil
|
|
66
|
+
ensure
|
|
67
|
+
file.rewind
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def attachable(file, info)
|
|
71
|
+
{ io: file.tempfile, filename: file.original_filename.to_s.scrub.presence || "image", content_type: info.content_type, metadata: { width: info.width, height: info.height } }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Entries
|
|
4
|
+
module Translations
|
|
5
|
+
# Translates the entry into the locale again, ignoring the stored digest and an admin's edit.
|
|
6
|
+
class RetranslationsController < ApplicationController
|
|
7
|
+
def create
|
|
8
|
+
entry = Entry.find(params[:entry_id])
|
|
9
|
+
locale = params[:translation_locale].to_s
|
|
10
|
+
return head(:not_found) unless Entry.translated_locale?(locale)
|
|
11
|
+
|
|
12
|
+
back = edit_entry_path(entry, inbox_filter_params)
|
|
13
|
+
return redirect_to(back, alert: t("shiplog.admin.entries.translations.only_approved", default: "Only approved entries are translated.")) unless entry.approved?
|
|
14
|
+
return redirect_to(back, alert: t("shiplog.admin.entries.translations.no_rewriter", default: "Translating needs config.rewriter, which is not set.")) unless Shiplog.config.rewriter
|
|
15
|
+
|
|
16
|
+
TranslateEntryJob.perform_later(entry, locale.to_sym, force: true)
|
|
17
|
+
redirect_to back, notice: t("shiplog.admin.entries.translations.queued", default: "Translation queued. Refresh in a moment.")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Admin
|
|
3
|
+
module Entries
|
|
4
|
+
# An admin's edit of an entry's translation (title and body) into one of config.locales. It is published
|
|
5
|
+
# at once and kept until the English text changes: the translation is marked edited and made from the
|
|
6
|
+
# entry's current English text, so it shows even if it was stale.
|
|
7
|
+
class TranslationsController < ApplicationController
|
|
8
|
+
before_action :set_entry_and_locale
|
|
9
|
+
|
|
10
|
+
def update
|
|
11
|
+
translation = @entry.translations.find_or_initialize_by(locale: @locale)
|
|
12
|
+
translation.assign_attributes(translation_params)
|
|
13
|
+
translation.assign_attributes(edited_by_admin: true, source_digest: @entry.translation_source_digest)
|
|
14
|
+
if translation.save
|
|
15
|
+
redirect_to edit_entry_path(@entry, inbox_filter_params), notice: t("shiplog.admin.entries.translations.saved", default: "Translation saved.")
|
|
16
|
+
else
|
|
17
|
+
redirect_to edit_entry_path(@entry, inbox_filter_params), alert: translation.errors.full_messages.to_sentence
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def set_entry_and_locale
|
|
24
|
+
@entry = Entry.find(params[:entry_id])
|
|
25
|
+
@locale = params[:translation_locale].to_s
|
|
26
|
+
head :not_found unless Entry.translated_locale?(@locale)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def translation_params
|
|
30
|
+
params.expect(translation: %i[title body])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -12,7 +12,7 @@ module Shiplog
|
|
|
12
12
|
@page = page_number
|
|
13
13
|
scope = Entry.where(status: @status)
|
|
14
14
|
scope = scope.where(kind: @kind) if @kind
|
|
15
|
-
records = scope.includes(:subject).order(updated_at: :desc, id: :desc)
|
|
15
|
+
records = scope.includes(:subject, :area).order(updated_at: :desc, id: :desc)
|
|
16
16
|
.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
17
17
|
@more = records.size > PER_PAGE
|
|
18
18
|
@entries = records.first(PER_PAGE)
|
|
@@ -23,11 +23,17 @@ module Shiplog
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def update
|
|
26
|
-
|
|
26
|
+
@entry.assign_attributes(entry_params)
|
|
27
|
+
@entry.area_set_by_admin = true if @entry.area_id_changed? # the admin's choice, which detection never overwrites
|
|
28
|
+
if @entry.save
|
|
27
29
|
redirect_to root_path(inbox_filter_params), notice: t("shiplog.admin.entries.saved")
|
|
28
30
|
else
|
|
29
31
|
render :edit, status: :unprocessable_content
|
|
30
32
|
end
|
|
33
|
+
rescue ActiveRecord::InvalidForeignKey
|
|
34
|
+
# The selected area was deleted between the validation and the save.
|
|
35
|
+
@entry.errors.add(:area_id, t("shiplog.admin.entries.area_gone", default: "no longer exists"))
|
|
36
|
+
render :edit, status: :unprocessable_content
|
|
31
37
|
rescue ActiveRecord::RecordNotUnique
|
|
32
38
|
# Two concurrent kind switches can both pass the validation; the unique index has the last word.
|
|
33
39
|
@entry.errors.add(:changelog_kind, :taken)
|
|
@@ -48,7 +54,7 @@ module Shiplog
|
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
def entry_params
|
|
51
|
-
params.expect(entry: %i[title body released_on changelog_kind])
|
|
57
|
+
params.expect(entry: %i[title body released_on changelog_kind area_id])
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
def page_number
|
|
@@ -4,8 +4,9 @@ module Shiplog
|
|
|
4
4
|
DELIVERIES_SHOWN = 20
|
|
5
5
|
|
|
6
6
|
def show
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
names = Shiplog.config.repository_names
|
|
8
|
+
repositories = Repository.where(full_name: names).index_by(&:full_name)
|
|
9
|
+
@repositories = names.map { |full_name| [ full_name, repositories[full_name] ] }
|
|
9
10
|
@deliveries = WebhookDelivery.order(received_at: :desc, id: :desc).limit(DELIVERIES_SHOWN)
|
|
10
11
|
@webhook_secret_missing = Shiplog.config.webhook_secret.blank?
|
|
11
12
|
@github_token_missing = Shiplog.config.resolved_github_token.blank?
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The permalink page of a published feature or change: /changelog/:slug.
|
|
3
|
+
class ChangelogEntriesController < PublicController
|
|
4
|
+
def show
|
|
5
|
+
@entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug])
|
|
6
|
+
return pass_to_host_routes unless @entry
|
|
7
|
+
|
|
8
|
+
notify_changelog_seen
|
|
9
|
+
@newer = @entry.newer_changelog_entry
|
|
10
|
+
@older = @entry.older_changelog_entry
|
|
11
|
+
preload_translations(@entry, @newer, @older)
|
|
12
|
+
fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, [ @entry, @newer, @older ].map { |entry| [ entry&.id, entry&.updated_at, entry&.area_id ] },
|
|
13
|
+
shiplog_image_etag_parts([ @entry ]), shiplog_translation_etag_parts([ @entry, @newer, @older ]) ]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# The engine is usually mounted at "/", so /changelog/:slug can shadow a host route below the changelog
|
|
19
|
+
# path (/changelog/archive, declared after the mount). An unknown slug answers 404 with X-Cascade: pass,
|
|
20
|
+
# which makes the router try the routes after the engine's mount; if none matches, the host's own 404 shows.
|
|
21
|
+
def pass_to_host_routes
|
|
22
|
+
response.set_header("X-Cascade", "pass")
|
|
23
|
+
head :not_found
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The Atom feed of the latest published features and changes: /changelog.atom.
|
|
3
|
+
class ChangelogFeedsController < PublicController
|
|
4
|
+
LIMIT = 50
|
|
5
|
+
|
|
6
|
+
layout false
|
|
7
|
+
before_action :set_area
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
scope = Entry.published_changelog.newest_first
|
|
11
|
+
scope = scope.where(area_id: @area.id) if @area
|
|
12
|
+
@entries = scope.includes(:area).with_images_loaded.limit(LIMIT).to_a
|
|
13
|
+
@entries.select!(&:permalink?) # no slug (or an invalid one) means no stable id: left out
|
|
14
|
+
preload_translations(@entries)
|
|
15
|
+
fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @entries.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
|
|
16
|
+
shiplog_image_etag_parts(@entries), shiplog_translation_etag_parts(@entries) ]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -2,21 +2,47 @@ module Shiplog
|
|
|
2
2
|
class ChangelogsController < PublicController
|
|
3
3
|
PER_PAGE = 30
|
|
4
4
|
MAX_PAGE = 100_000
|
|
5
|
+
MIN_FILTER_AREAS = 2
|
|
6
|
+
|
|
7
|
+
before_action :set_area, only: :show
|
|
5
8
|
|
|
6
9
|
def show
|
|
7
10
|
@page = page_number
|
|
8
|
-
scope = Entry.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
scope = Entry.published_changelog.newest_first
|
|
12
|
+
@query = search_query
|
|
13
|
+
scope = scope.search(@query, locale: I18n.locale) if @query
|
|
14
|
+
scope = scope.where(area_id: @area.id) if @area
|
|
15
|
+
@areas = filter_areas
|
|
16
|
+
records = scope.includes(:area).with_images_loaded.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
11
17
|
return head(:not_found) if @page > 1 && records.empty?
|
|
12
18
|
|
|
19
|
+
notify_changelog_seen
|
|
13
20
|
@older = records.size > PER_PAGE
|
|
14
21
|
@months = records.first(PER_PAGE).group_by { |entry| entry.released_on.beginning_of_month }
|
|
15
|
-
|
|
22
|
+
@result_count = scope.count if @query
|
|
23
|
+
shown = @months.values.flatten
|
|
24
|
+
preload_translations(shown)
|
|
25
|
+
fresh_when etag: [ *shiplog_etag_parts, *shiplog_area_etag_parts, @areas.map(&:id), @query, @page, @older,
|
|
26
|
+
shown.map { |entry| [ entry.id, entry.updated_at, entry.area_id ] },
|
|
27
|
+
shiplog_image_etag_parts(shown), shiplog_translation_etag_parts(shown) ]
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
private
|
|
19
31
|
|
|
32
|
+
# The areas to offer as filter links: those with published entries, in the admin's order, and only when there
|
|
33
|
+
# are at least two (a single area filters nothing).
|
|
34
|
+
def filter_areas
|
|
35
|
+
areas = Area.ordered.where(id: Entry.published_changelog.where.not(area_id: nil).select(:area_id)).to_a
|
|
36
|
+
areas.size >= MIN_FILTER_AREAS ? areas : []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The normalised search query (words joined by one space), or nil when search is off or the query is blank.
|
|
40
|
+
def search_query
|
|
41
|
+
return unless Shiplog.config.changelog_search && params[:q].is_a?(String)
|
|
42
|
+
|
|
43
|
+
Entry.search_terms(params[:q]).join(" ").presence
|
|
44
|
+
end
|
|
45
|
+
|
|
20
46
|
def page_number
|
|
21
47
|
Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
|
|
22
48
|
end
|
|
@@ -5,15 +5,72 @@ module Shiplog
|
|
|
5
5
|
class PublicController < Shiplog.config.public_parent_controller.constantize
|
|
6
6
|
helper Shiplog::FrontendHelper
|
|
7
7
|
helper Shiplog::StylingHelper
|
|
8
|
+
helper Shiplog::SwitcherHelper
|
|
9
|
+
helper Shiplog::RoadmapHelper
|
|
8
10
|
helper Shiplog::HostRoutesHelper
|
|
11
|
+
helper Shiplog::PermalinkHelper
|
|
9
12
|
layout -> { Shiplog.config.public_layout }
|
|
10
13
|
|
|
11
14
|
private
|
|
12
15
|
|
|
16
|
+
# Calls config.on_changelog_seen for a visit to the changelog index or a permalink page. It runs before
|
|
17
|
+
# the page renders (the actions call it once they know the page exists, ahead of fresh_when and the
|
|
18
|
+
# implicit render), so a host navbar badge in the same response shows the count the callback just stored,
|
|
19
|
+
# also when the answer ends up a 304 (a revisit is a visit). Not called for 404s or the feed.
|
|
20
|
+
# The response is not sent yet, so the callback can still set cookies or headers. A failing callback is
|
|
21
|
+
# logged and never breaks the page.
|
|
22
|
+
def notify_changelog_seen
|
|
23
|
+
callback = Shiplog.config.on_changelog_seen
|
|
24
|
+
return unless callback && request.format.html?
|
|
25
|
+
|
|
26
|
+
callback.call(self)
|
|
27
|
+
rescue StandardError => error
|
|
28
|
+
Rails.logger.error("[shiplog] on_changelog_seen failed: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Sets @area from ?area=<slug>. A blank or non-string value is ignored; a slug no area has is a 404 (a
|
|
32
|
+
# renamed or deleted area has no page to show, rather than the unfiltered list under a filtered URL).
|
|
33
|
+
def set_area
|
|
34
|
+
slug = params[:area]
|
|
35
|
+
return unless slug.is_a?(String) && slug.present?
|
|
36
|
+
|
|
37
|
+
@area = Area.find_by(slug:)
|
|
38
|
+
head(:not_found) unless @area
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What the markup shows of the areas: names, translated names, slugs and order of every area (the tags and the filter), which
|
|
42
|
+
# entries' updated_at don't reflect when an area is renamed or entries are moved in bulk.
|
|
43
|
+
def shiplog_area_etag_parts
|
|
44
|
+
[ @area&.id, Area.ordered.pluck(:id, :name, :slug, :position, :name_translations) ]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# What the markup shows of the entries' screenshots: their blob ids, order and alt texts. Nothing while
|
|
48
|
+
# screenshots are off. Load the entries with `Entry.with_images_loaded` to avoid a query per entry.
|
|
49
|
+
def shiplog_image_etag_parts(entries)
|
|
50
|
+
Shiplog.images? ? entries.compact.map { |entry| [ entry.id, entry.image_etag_parts ] } : []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Loads the translations the page shows (the request locale's) for these entries in one query; see
|
|
54
|
+
# Entry.preload_translations. Call it before the entries are rendered or their ETag is computed.
|
|
55
|
+
def preload_translations(*entries)
|
|
56
|
+
Entry.preload_translations(entries.flatten)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# What the markup shows of the entries' translations: which current translation each entry shows, and when
|
|
60
|
+
# it last changed. Nothing under a locale that is not translated.
|
|
61
|
+
def shiplog_translation_etag_parts(entries)
|
|
62
|
+
return [] unless Entry.translated_locale?(I18n.locale)
|
|
63
|
+
|
|
64
|
+
entries.compact.filter_map do |entry|
|
|
65
|
+
translation = entry.translation
|
|
66
|
+
[ entry.id, translation.id, translation.updated_at ] if translation
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
13
70
|
# What the rendered markup depends on besides the data: the locale, the gem version and the styling
|
|
14
71
|
# config. Host parent controllers rendering per-user layout content should declare `etag { current_user&.id }`.
|
|
15
72
|
def shiplog_etag_parts
|
|
16
|
-
[ I18n.locale, Shiplog::VERSION, Shiplog.config.
|
|
73
|
+
[ I18n.locale, Shiplog::VERSION, Shiplog.config.render_digest ]
|
|
17
74
|
end
|
|
18
75
|
end
|
|
19
76
|
end
|
|
@@ -2,13 +2,38 @@ module Shiplog
|
|
|
2
2
|
class RoadmapsController < PublicController
|
|
3
3
|
def show
|
|
4
4
|
@columns = Roadmap.new(published_only: true).columns
|
|
5
|
-
|
|
5
|
+
preload_cards(@columns)
|
|
6
|
+
@shipped = recently_shipped
|
|
7
|
+
shown = [ *@columns.values.flatten.map(&:entry), *@shipped ]
|
|
8
|
+
preload_translations(shown)
|
|
9
|
+
fresh_when etag: [ *shiplog_etag_parts, etag_for(@columns), shipped_etag(@shipped), shiplog_translation_etag_parts(shown) ]
|
|
6
10
|
end
|
|
7
11
|
|
|
8
12
|
private
|
|
9
13
|
|
|
14
|
+
# The cards read each entry's issue, its repository (label) and its sub-issues (parts).
|
|
15
|
+
def preload_cards(columns)
|
|
16
|
+
entries = columns.values.flatten.map(&:entry)
|
|
17
|
+
ActiveRecord::Associations::Preloader.new(records: entries, associations: { subject: [ :repository, :children ] }).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def recently_shipped
|
|
21
|
+
count = Shiplog.config.roadmap_shipped_count
|
|
22
|
+
return Entry.none if count.zero?
|
|
23
|
+
|
|
24
|
+
Entry.approved.where(kind: Entry::CHANGELOG_KINDS).where.not(released_on: nil)
|
|
25
|
+
.order(released_on: :desc, approved_at: :desc, id: :desc).limit(count).load
|
|
26
|
+
end
|
|
27
|
+
|
|
10
28
|
def etag_for(columns)
|
|
11
|
-
columns.transform_values
|
|
29
|
+
columns.transform_values do |items|
|
|
30
|
+
items.map do |item|
|
|
31
|
+
issue = item.entry.subject
|
|
32
|
+
[ item.entry.id, item.entry.updated_at, issue.updated_at, issue.children.size, issue.repository.full_name ]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
12
35
|
end
|
|
36
|
+
|
|
37
|
+
def shipped_etag(entries) = entries.map { |entry| [ entry.id, entry.updated_at ] }
|
|
13
38
|
end
|
|
14
39
|
end
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
}
|
|
104
104
|
.shiplog-admin .shiplog-badge--warning { border-color: #bf8700; color: #9a6700; }
|
|
105
105
|
.shiplog-admin .shiplog-badge--error { border-color: #cf222e; color: #cf222e; }
|
|
106
|
+
.shiplog-admin .shiplog-badge--area { border-color: var(--sa-accent); color: var(--sa-accent); }
|
|
106
107
|
|
|
107
108
|
.shiplog-admin .shiplog-button {
|
|
108
109
|
background: var(--sa-surface);
|
|
@@ -115,6 +116,7 @@
|
|
|
115
116
|
padding: 0.25rem 0.75rem;
|
|
116
117
|
text-decoration: none;
|
|
117
118
|
}
|
|
119
|
+
.shiplog-admin .shiplog-button:disabled { cursor: default; opacity: 0.5; }
|
|
118
120
|
.shiplog-admin .shiplog-button--primary { background: var(--sa-accent); border-color: var(--sa-accent); color: #fff; }
|
|
119
121
|
|
|
120
122
|
.shiplog-admin .shiplog-admin-split { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
|
|
@@ -125,6 +127,13 @@
|
|
|
125
127
|
.shiplog-admin .shiplog-admin-form textarea { box-sizing: border-box; font: inherit; width: 100%; }
|
|
126
128
|
.shiplog-admin .shiplog-admin-actions { border-top: 1px solid var(--sa-border); display: grid; gap: 0.75rem; margin-top: 1rem; padding-top: 1rem; }
|
|
127
129
|
.shiplog-admin .shiplog-admin-inline-form { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
130
|
+
.shiplog-admin .shiplog-admin-images { margin-top: 1rem; }
|
|
131
|
+
.shiplog-admin .shiplog-admin-images__list { display: grid; gap: 1rem; list-style: none; margin: 0 0 1rem; padding: 0; }
|
|
132
|
+
.shiplog-admin .shiplog-admin-images__item { align-items: flex-start; border-bottom: 1px solid var(--sa-border); display: flex; flex-wrap: wrap; gap: 1rem; padding-bottom: 1rem; }
|
|
133
|
+
.shiplog-admin .shiplog-admin-images__thumb { border: 1px solid var(--sa-border); border-radius: 0.25rem; height: auto; max-height: 8rem; max-width: 12rem; object-fit: contain; }
|
|
134
|
+
.shiplog-admin .shiplog-admin-images__controls { display: grid; flex: 1; gap: 0.5rem; min-width: 16rem; }
|
|
135
|
+
.shiplog-admin .shiplog-admin__visually-hidden { border: 0; clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; }
|
|
136
|
+
.shiplog-admin .shiplog-area-row__count { text-align: right; }
|
|
128
137
|
.shiplog-admin .shiplog-admin-source { background: var(--sa-bg); overflow-x: auto; padding: 0.75rem; white-space: pre-wrap; word-break: break-word; }
|
|
129
138
|
|
|
130
139
|
.shiplog-admin .shiplog-board { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|