Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 71 additions & 51 deletions app/controllers/concerns/multilang.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,101 @@
module Multilang
extend ActiveSupport::Concern

# Kept in step with I18n.available_locales by the test below it. This used to
# list ten languages plus Japanese, which had no locale file and was never in
# available_locales at all -- the switcher would have offered a language the
# app could not render.
LOCALES = {
de: 'Deutsch',
en: 'English',
es: 'Español',
fa: 'الفارسية',
fr: 'Français',
it: 'Italiano',
ja: '日本語',
pl: 'Polska',
pt: 'Português',
ru: 'Русский',
zh: '中文'
}
}.freeze

included do
# before_action :set_locale
around_action :set_locale

helper_method :browser_locale
helper_method :locales_for_select
helper_method :locale_switcher
end

# The visitor's most-preferred language that this site can actually render.
#
# This used to be `scan(/[a-z]{2}(?=;)/)`, which only sees a tag followed by a
# `;` -- that is, a tag carrying a q-value. The first entry in an
# Accept-Language header does not carry one, so the visitor's *top* preference
# was the one entry the scan could never match: `ru,en;q=0.9` answered `en`.
# Nothing noticed, because set_locale was switched off.
def browser_locale
locales = request.env['HTTP_ACCEPT_LANGUAGE'] || ''
locales.scan(/[a-z]{2}(?=;)/).find do |locale|
I18n.available_locales.include?(locale.to_sym)
end
accepted_languages.find { |tag| I18n.available_locales.include?(tag.to_sym) }
end

# The header's language tags, most-preferred first, minus the ones the client
# has ruled out. `q=0` means "not acceptable" (RFC 9110 12.4.2), so
# `ru;q=0,de;q=0.9` must not answer `ru` merely because `de` is not served.
def accepted_languages
entries = request.env['HTTP_ACCEPT_LANGUAGE'].to_s.split(',')
ranked = entries.map.with_index { |part, index| rank(part, index) }
ranked.reject { |_, quality, _| quality.zero? }
.sort_by { |entry| entry.drop(1) }
.map(&:first)
end

# One header entry, as [tag, -quality, index].
#
# The tag is cut to the two letters I18n keys on, so `de-DE` ranks as `de`.
# An entry with no q-value is the strongest the header carries, so it defaults
# to 1 rather than being skipped -- skipping it is what the old scan did.
# index breaks ties, because sort_by is not stable and equal q-values have to
# keep the order the browser sent them in.
def rank(part, index)
tag, *parameters = part.split(';')
[tag.to_s.strip.downcase[0, 2].to_s, -quality_of(parameters), index]
end

# Splitting on the literal ';q=' missed every header that spells it another
# way, and the grammar allows several: whitespace around the separator, and
# `Q` as readily as `q`. `en; q=0.1,ru;q=0.9` ranked English at 1 and picked
# it -- the opposite of what the visitor asked for.
def quality_of(parameters)
found = parameters.map(&:strip).find { |parameter| parameter.downcase.start_with?('q=') }
return 1.0 if found.nil?

found[2..].to_f
end

def self.default_url_options
{ locale: I18n.locale }
end

def set_locale
# detect locale from browser languages
# around_action, not before_action, and I18n.with_locale rather than
# `I18n.locale =`. I18n.locale is per-thread and nothing resets it at the end
# of a request, so an assignment leaks into whatever that thread serves next.
# Every request does pass through here, so in practice it would be overwritten
# -- but "in practice" is doing the work in that sentence, and with_locale
# costs nothing.
def set_locale(&)
# A first visit has no choice recorded, so start from what the browser asks
# for. Later visits keep whatever the switcher last set.
session[:locale] ||= browser_locale
session[:locale] = I18n.default_locale unless available?(session[:locale])

# save session locale value to default if not valid
old_locale = session[:locale].to_s.to_sym
unless I18n.available_locales.include?(old_locale)
session[:locale] = I18n.default_locale
end

# save session locale value from params if valid
if params[:locale]
new_locale = params[:locale].to_s.to_sym
if I18n.available_locales.include?(new_locale)
session[:locale] = new_locale
end
end
# The switcher links to ?locale=xx. A locale this site does not serve is
# ignored rather than honoured: it used to be able to leave I18n.locale set
# to something with no translation file behind it.
session[:locale] = params[:locale] if available?(params[:locale])

# set app locale from session
I18n.locale = session[:locale]
I18n.with_locale(session[:locale], &)
end

def locales_for_select
I18n.available_locales.map { |l| [t("locales.#{l}"), l] }
def available?(locale)
locale.present? && I18n.available_locales.include?(locale.to_s.to_sym)
end

def locale_switcher
html = []
html << '<ul class="navbar-nav text-uppercase">'
html << '<li class="nav-item dropdown">'
html << '<a aria-expanded="false" class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" id="dropsownLanguage" role="button">'
#html << format('%<language>s [%<locale>s]', { language: t("str.language"), locale: I18n.locale })
html << '<img src="/assets/translate.svg" alt="Image: language icon" class="icon img-fluid" title="Language selection">'
html << '</a>'
html << '<ul aria-labelledby="dropdownLanguage" class="dropdown-menu dropdown-menu-lg-end">'
I18n.available_locales.sort.each do |l|
html << '<li class="dropdown-item">'
html << format('<a href="?locale=%<locale>s" class="%<active>s">%<name>s</a>',
{ active: I18n.locale.eql?(l) ? ' fw-bold' : nil, locale: l, name: LOCALES[l] })
html << '</li>'
end
html << '</ul></li></ul>'
html.join("\n").html_safe
# LOCALES, not `t("locales.#{l}")`: there are no `locales.*` keys in any file,
# so every entry came back as a translation-missing span. A language name is
# written the same in every language anyway, which is why the switcher
# partial uses LOCALES too.
def locales_for_select
I18n.available_locales.map { |l| [LOCALES[l], l] }
end
end
2 changes: 1 addition & 1 deletion app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def our_team
end

def qr_code_generator
@page_title = t('pages.qr_code.title')
@page_title = t('pages.qr_code_generator.title')
render 'pages/qr_code_generator'
end

Expand Down
34 changes: 34 additions & 0 deletions app/views/layouts/_locale_switcher.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<%#
Was a string-building helper in the Multilang concern. Two things it could not
do from there, both of which only started mattering when the switcher was
turned on:

- the icon was a hardcoded <img src="/assets/translate.svg">, and production
serves fingerprinted assets with compile off, so that is a 404 on every
page. icon_language has rendered it through image_tag all along.
- every link was a bare "?locale=xx", which replaces the whole query string.
Switching language on /snapshots?page=2 lost the page, and on a permanent
link to a camera configuration it lost the configuration.

aria-labelledby also pointed at "dropdownLanguage" while the button was
id="dropsownLanguage", so it labelled nothing.
%>
<ul class="navbar-nav text-uppercase">
<li class="nav-item dropdown">
<a aria-expanded="false" class="nav-link dropdown-toggle" href="#"
data-bs-toggle="dropdown" id="dropdownLanguage" role="button">
<%= icon_language %>
</a>
<ul aria-labelledby="dropdownLanguage" class="dropdown-menu dropdown-menu-lg-end">
<% Multilang::LOCALES.each do |locale, name| %>
<% current = I18n.locale.eql?(locale) %>
<li class="dropdown-item">
<%= link_to name,
"#{request.path}?#{request.query_parameters.merge('locale' => locale).to_query}",
lang: locale, class: (current ? 'fw-bold' : nil),
aria: { current: (current ? 'true' : nil) } %>
</li>
<% end %>
</ul>
</li>
</ul>
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
</ul>
</li>
</ul>
<!-- %= locale_switcher % -->
<%= render 'layouts/locale_switcher' %>
</div>
</div>
</nav>
Expand Down
2 changes: 1 addition & 1 deletion app/views/snapshots/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<% if @snapshot.file.content_type.to_s.start_with?('image/hei') %>
<p data-heif-original="<%= rails_blob_path(@snapshot.file, disposition: 'inline') %>">
<button type="button" class="btn btn-sm btn-outline-secondary" data-heif-view>
<%= t('site.snapshot.view_heif', default: 'View original HEIF in your browser') %>
<%= t('site.snapshot.view_heif') %>
</button>
<span class="small text-muted ms-2" data-heif-status></span>
<canvas class="d-none w-100 mt-2" data-heif-canvas></canvas>
Expand Down
2 changes: 1 addition & 1 deletion config/i18n-tasks.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks

locales: [en, ru, de, es, it, pl, pt, fa, zh]
locales: [en, ru, zh]
base_locale: en
internal_locale: en

Expand Down
17 changes: 15 additions & 2 deletions config/initializers/locale.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Where the I18n library should search for translation files
I18n.load_path += Dir[Rails.root.join('lib', 'locale', '*.{rb,yml}')]

# Permitted locales available for the application
I18n.available_locales = [:en, :de, :es, :fr, :it, :pl, :pt, :ru, :fa, :zh]
# Without this, an `i18n.plural.rule` in lib/locale is read and ignored, and
# every locale gets the built-in one/other split. Russian needs four forms.
I18n::Backend::Simple.include I18n::Backend::Pluralization

# Permitted locales available for the application.
#
# Three, not ten. The site carried ten locale files and served none of them,
# because Multilang's set_locale was switched off deliberately -- the project
# did not have the people to keep ten translations in step with the English
# copy, and a stale translation of the flashing instructions is worse than an
# English one. Translations are cheaper to produce now; keeping them honest
# through every later edit is not, so the list is what the project believes it
# can maintain: English as the source, Russian and Chinese for the two largest
# communities.
I18n.available_locales = %i[en ru zh]

# Set default locale to something other than :en
I18n.default_locale = :en
15 changes: 0 additions & 15 deletions config/locales/activemodel.de.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.es.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.fa.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.fr.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.it.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.pl.yml

This file was deleted.

15 changes: 0 additions & 15 deletions config/locales/activemodel.pt.yml

This file was deleted.

24 changes: 0 additions & 24 deletions config/locales/activerecord.de.yml

This file was deleted.

Loading
Loading