Skip to content

Commit bf5ffa0

Browse files
committed
Simultaneously Displaying Multilingual Metadata on the Article Landing Page
1 parent 94548fa commit bf5ffa0

File tree

6 files changed

+622
-33
lines changed

6 files changed

+622
-33
lines changed

pages/preprint/PreprintHandler.php

+137
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,23 @@
2222
use APP\facades\Repo;
2323
use APP\handler\Handler;
2424
use APP\observers\events\UsageEvent;
25+
use APP\publication\Publication;
2526
use APP\security\authorization\OpsServerMustPublishPolicy;
2627
use APP\template\TemplateManager;
2728
use Firebase\JWT\JWT;
29+
use Illuminate\Support\Arr;
30+
use PKP\author\Author;
2831
use PKP\citation\CitationDAO;
2932
use PKP\config\Config;
3033
use PKP\core\Core;
3134
use PKP\core\PKPApplication;
3235
use PKP\db\DAORegistry;
36+
use PKP\facades\Locale;
3337
use PKP\orcid\OrcidManager;
3438
use PKP\plugins\Hook;
3539
use PKP\plugins\PluginRegistry;
3640
use PKP\security\authorization\ContextRequiredPolicy;
41+
use PKP\services\PKPSchemaService;
3742
use PKP\submission\Genre;
3843
use PKP\submission\GenreDAO;
3944
use PKP\submission\PKPSubmission;
@@ -302,6 +307,16 @@ public function view($args, $request)
302307
$templateMgr->addHeader('canonical', '<link rel="canonical" href="' . $url . '">');
303308
}
304309

310+
$templateMgr->assign('pubLocaleData', $this->getMultilingualMetadataOpts(
311+
$publication,
312+
$templateMgr->getTemplateVars('currentLocale'),
313+
$templateMgr->getTemplateVars('activeTheme')->getOption('showMultilingualMetadata') ?: [],
314+
));
315+
$templateMgr->registerPlugin('modifier', 'wrapData', fn (...$args) => $this->smartyWrapData($templateMgr, ...$args));
316+
$templateMgr->registerPlugin('modifier', 'useFilters', fn (...$args) => $this->smartyUseFilters($templateMgr, ...$args));
317+
$templateMgr->registerPlugin('modifier', 'getAuthorFullNames', $this->smartyGetAuthorFullNames(...));
318+
$templateMgr->registerPlugin('modifier', 'getAffiliationNamesWithRors', $this->smartyGetAffiliationNamesWithRors(...));
319+
305320
if (!Hook::call('PreprintHandler::view', [&$request, &$preprint, $publication])) {
306321
$templateMgr->display('frontend/pages/preprint.tpl');
307322
event(new UsageEvent(Application::ASSOC_TYPE_SUBMISSION, $context, $preprint));
@@ -424,4 +439,126 @@ public function userCanViewGalley($request)
424439
}
425440
return false;
426441
}
442+
443+
/**
444+
* Multilingual publication metadata for template:
445+
* showMultilingualMetadataOpts - Show metadata in other languages: title (+ subtitle), keywords, abstract, etc.
446+
*/
447+
protected function getMultilingualMetadataOpts(Publication $publication, string $currentUILocale, array $showMultilingualMetadataOpts): array
448+
{
449+
// Affiliation languages are not in multiligual props
450+
$authorsLocales = collect($publication->getData('authors'))
451+
->map(fn ($author): array => $this->getAuthorLocales($author))
452+
->flatten()
453+
->unique()
454+
->values()
455+
->toArray();
456+
$langNames = collect($publication->getLanguageNames() + Locale::getSubmissionLocaleDisplayNames($authorsLocales))
457+
->sortKeys();
458+
$langs = $langNames->keys();
459+
460+
return [
461+
'opts' => array_flip($showMultilingualMetadataOpts),
462+
'uiLocale' => $currentUILocale,
463+
'localeNames' => $langNames,
464+
'localeOrder' => collect($publication->getLocalePrecedence())
465+
->intersect($langs) /* remove locales not in publication's languages */
466+
->concat($langs)
467+
->unique()
468+
->values()
469+
->toArray(),
470+
'accessibility' => [
471+
'ariaLabels' => $langNames,
472+
'langAttrs' => $langNames->map(fn ($_, $l) => preg_replace(['/@.+$/', '/_/'], ['', '-'], $l))->toArray() /* remove @ and text after */,
473+
],
474+
];
475+
}
476+
477+
/**
478+
* Publication's multilingual data to array for js and page
479+
*/
480+
protected function smartyWrapData(TemplateManager $templateMgr, array $data, string $switcher, ?array $filters = null, ?string $separator = null): array
481+
{
482+
return [
483+
'switcher' => $switcher,
484+
'data' => collect($data)
485+
->map(
486+
fn ($value): string => collect(Arr::wrap($value))
487+
->when($filters, fn ($value) => $value->map(fn ($v) => $this->smartyUseFilters($templateMgr, $v, $filters)))
488+
->when($separator, fn ($value): string => $value->join($separator), fn ($value): string => $value->first())
489+
)
490+
->toArray(),
491+
'defaultLocale' => collect($templateMgr->getTemplateVars('pubLocaleData')['localeOrder'])
492+
->first(fn (string $locale) => isset($data[$locale])),
493+
];
494+
}
495+
496+
/**
497+
* Smarty template: Apply filters to given value
498+
*/
499+
protected function smartyUseFilters(TemplateManager $templateMgr, string $value, ?array $filters): string
500+
{
501+
if (!$filters) {
502+
return $value;
503+
}
504+
foreach ($filters as $filter) {
505+
$params = Arr::wrap($filter);
506+
$funcName = array_shift($params);
507+
if ($func = $templateMgr->registered_plugins['modifier'][$funcName][0] ?? null) {
508+
$value = $func($value, ...$params);
509+
}
510+
}
511+
return $value;
512+
}
513+
514+
/**
515+
* Smarty template: Get author's full names to multilingual array including all multilingual and affiliation languages as default localized name
516+
*/
517+
protected function smartyGetAuthorFullNames(Author $author): array
518+
{
519+
return collect($this->getAuthorLocales($author))
520+
->mapWithKeys(fn (string $locale) => [$locale => $author->getFullName(preferredLocale: $locale)])
521+
->toArray();
522+
}
523+
524+
/**
525+
* Smarty template: Get authors' affiliations with rors
526+
*/
527+
protected function smartyGetAffiliationNamesWithRors(Author $author): array
528+
{
529+
$affiliations = collect($author->getAffiliations());
530+
531+
return collect($this->getAuthorLocales($author))
532+
->flip()
533+
->map(
534+
fn ($_, string $locale) => $affiliations
535+
->map(fn ($affiliation): array => [
536+
'name' => $affiliation->getAffiliationName($locale),
537+
'ror' => $affiliation->getRor(),
538+
])
539+
->filter(fn (array $nameRor) => $nameRor['name'])
540+
->toArray()
541+
)
542+
->filter()
543+
->toArray();
544+
}
545+
546+
/**
547+
* Aux for smarty template functions: Get author's locales from multilingual props and affiliations
548+
*/
549+
protected function getAuthorLocales(Author $author): array
550+
{
551+
$multilingualLocales = collect(app()->get('schema')->getMultilingualProps(PKPSchemaService::SCHEMA_AUTHOR))
552+
->map(fn (string $prop): array => array_keys($author->getData($prop) ?? []));
553+
$affiliationLocales = collect($author->getAffiliations())
554+
->flatten()
555+
->map(fn ($affiliation): array => array_keys($affiliation->getData('name') ?? []));
556+
557+
return $multilingualLocales
558+
->concat($affiliationLocales)
559+
->flatten()
560+
->unique()
561+
->values()
562+
->toArray();
563+
}
427564
}

plugins/themes/default/DefaultThemePlugin.php

+24
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,30 @@ public function init()
125125
'default' => 'none',
126126
]);
127127

128+
$this->addOption('showMultilingualMetadata', 'FieldOptions', [
129+
'label' => __('plugins.themes.default.option.metadata.label'),
130+
'description' => __('plugins.themes.default.option.metadata.description'),
131+
'options' => [
132+
[
133+
'value' => 'title',
134+
'label' => __('submission.title'),
135+
],
136+
[
137+
'value' => 'keywords',
138+
'label' => __('common.keywords'),
139+
],
140+
[
141+
'value' => 'abstract',
142+
'label' => __('common.abstract'),
143+
],
144+
[
145+
'value' => 'author',
146+
'label' => __('default.groups.name.author'),
147+
],
148+
],
149+
'default' => [],
150+
]);
151+
128152
// Load primary stylesheet
129153
$this->addStyle('stylesheet', 'styles/index.less');
130154

plugins/themes/default/js/main.js

+188
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,191 @@
114114
});
115115

116116
})(jQuery);
117+
118+
/**
119+
* Create language buttons to show multilingual metadata
120+
* [data-pkp-switcher-data]: Publication data for the switchers to control
121+
* [data-pkp-switcher]: Switchers' containers
122+
*/
123+
(() => {
124+
function createSwitcher(listbox, data, localeOrder, localeNames, accessibility) {
125+
// Get all locales for the switcher from the data
126+
const locales = Object.keys(Object.assign({}, ...Object.values(data)));
127+
// The initially selected locale
128+
let selectedLocale = null;
129+
// Create and sort to alphabetical order
130+
const buttons = localeOrder
131+
.map((locale) => {
132+
if (locales.indexOf(locale) === -1) {
133+
return null;
134+
}
135+
if (!selectedLocale) {
136+
selectedLocale = locale;
137+
}
138+
139+
const isSelectedLocale = locale === selectedLocale;
140+
const button = document.createElement('button');
141+
142+
button.type = 'button';
143+
button.classList.add('pkpBadge', 'pkpBadge--button');
144+
button.value = locale;
145+
button.tabIndex = isSelectedLocale ? '0' : '-1'; // For safari losing button focus
146+
if (!isSelectedLocale) {
147+
button.classList.add('pkp_screen_reader');
148+
}
149+
150+
// Text content
151+
/// SR
152+
const srText = document.createElement('span');
153+
srText.classList.add('pkp_screen_reader');
154+
srText.textContent = accessibility.ariaLabels[locale];
155+
button.appendChild(srText);
156+
// Visual
157+
const text = document.createElement('span');
158+
text.ariaHidden = 'true';
159+
text.textContent = localeNames[locale];
160+
button.appendChild(text);
161+
162+
return button;
163+
})
164+
.filter((btn) => btn)
165+
.sort((a, b) => a.value.localeCompare(b.value));
166+
167+
// If only one button, set it disabled
168+
if (buttons.length === 1) {
169+
buttons[0].ariaDisabled = 'true';
170+
}
171+
172+
buttons.forEach((btn) => {
173+
const option = document.createElement('li');
174+
option.role = 'option';
175+
option.ariaSelected = `${btn.value === selectedLocale}`;
176+
option.appendChild(btn);
177+
// Listbox: Ul element
178+
listbox.appendChild(option);
179+
});
180+
181+
return buttons;
182+
}
183+
184+
/**
185+
* Sync data in elements to match the selected locale
186+
*/
187+
function syncDataElContents(locale, propsData, accessibility) {
188+
for (prop in propsData.data) {
189+
propsData.dataEls[prop].lang = accessibility.langAttrs[locale];
190+
propsData.dataEls[prop].innerHTML = propsData.data[prop][locale] ?? '';
191+
}
192+
}
193+
194+
/**
195+
* Toggle visibility of the buttons
196+
* pkp_screen_reader: button visibility hidden
197+
*/
198+
function setVisibility(buttons, currentSelected, visible) {
199+
buttons.forEach((btn) => {
200+
if (visible) {
201+
btn.classList.remove('pkp_screen_reader');
202+
} else if (btn !== currentSelected.btn) {
203+
btn.classList.add('pkp_screen_reader');
204+
}
205+
});
206+
}
207+
208+
function setSwitcher(propsData, switcherContainer, localeOrder, localeNames, accessibility) {
209+
// Create buttons and append them to the switcher container
210+
const listbox = switcherContainer.querySelector('[role="listbox"]');
211+
const buttons = createSwitcher(listbox, propsData.data, localeOrder, localeNames, accessibility);
212+
const currentSelected = {btn: switcherContainer.querySelector('[tabindex="0"]')};
213+
const focused = {btn: currentSelected.btn};
214+
215+
// Sync contents in data elements to match the selected locale (currentSelected.btn.value)
216+
syncDataElContents(currentSelected.btn.value, propsData, accessibility);
217+
218+
// Do not add listeners if just one button, it is disabled
219+
if (buttons.length < 2) {
220+
return;
221+
}
222+
223+
const isButtonsHidden = () => buttons.some(b => b.classList.contains('pkp_screen_reader'));
224+
225+
// New button switches language and syncs data contents. Same button hides buttons.
226+
switcherContainer.addEventListener('click', (evt) => {
227+
// Choices are li > button > span
228+
const newSelectedBtn = evt.target.classList.contains('pkpBadge--button')
229+
? evt.target
230+
: (evt.target.querySelector('.pkpBadge--button') ?? evt.target.closest('.pkpBadge--button'));
231+
if (buttons.some(b => b === newSelectedBtn)) {
232+
// Set visibility
233+
setVisibility(buttons, currentSelected, newSelectedBtn !== currentSelected.btn ? true : isButtonsHidden());
234+
if (newSelectedBtn !== currentSelected.btn) {
235+
// Sync contents
236+
syncDataElContents(newSelectedBtn.value, propsData, accessibility);
237+
// Listbox option: Aria
238+
currentSelected.btn.parentElement.ariaSelected = 'false';
239+
newSelectedBtn.parentElement.ariaSelected = 'true';
240+
// Button: Tab index
241+
currentSelected.btn.tabIndex = '-1';
242+
newSelectedBtn.tabIndex = '0';
243+
// Update current and focused button
244+
focused.btn = currentSelected.btn = newSelectedBtn;
245+
focused.btn.focus();
246+
}
247+
}
248+
});
249+
250+
// Hide buttons when focus out
251+
switcherContainer.addEventListener('focusout', (evt) => {
252+
if (evt.target !== evt.currentTarget && evt.relatedTarget?.closest('[data-pkp-switcher]') !== switcherContainer) {
253+
setVisibility(buttons, currentSelected, false);
254+
}
255+
});
256+
257+
// Arrow keys left and right cycles button focus when all buttons are visible. Set focused button.
258+
switcherContainer.addEventListener("keydown", (evt) => {
259+
if (evt.key !== "ArrowRight" && evt.key !== "ArrowLeft") return;
260+
261+
const i = buttons.findIndex(b => b === evt.target);
262+
if (i !== -1 && !isButtonsHidden()) {
263+
focused.btn = (evt.key === "ArrowRight")
264+
? (buttons[i + 1] ?? buttons[0])
265+
: (buttons[i - 1] ?? buttons[buttons.length - 1]);
266+
focused.btn.focus();
267+
}
268+
});
269+
}
270+
271+
/**
272+
* Set all multilingual data and elements for the switchers
273+
*/
274+
function setSwitchersData(dataEls, pubLocaleData) {
275+
const propsData = {};
276+
dataEls.forEach((dataEl) => {
277+
const propName = dataEl.getAttribute('data-pkp-switcher-data');
278+
const switcherName = pubLocaleData[propName].switcher;
279+
if (!propsData[switcherName]) {
280+
propsData[switcherName] = {data: [], dataEls: []};
281+
}
282+
propsData[switcherName].data[propName] = pubLocaleData[propName].data;
283+
propsData[switcherName].dataEls[propName] = dataEl;
284+
});
285+
return propsData;
286+
}
287+
288+
(() => {
289+
const switcherContainers = document.querySelectorAll('[data-pkp-switcher]');
290+
291+
if (!switcherContainers.length) return;
292+
293+
const pubLocaleData = JSON.parse(pubLocaleDataJson);
294+
const switchersDataEls = document.querySelectorAll('[data-pkp-switcher-data]');
295+
const switchersData = setSwitchersData(switchersDataEls, pubLocaleData);
296+
// Create and set switchers, and sync data on the page
297+
switcherContainers.forEach((switcherContainer) => {
298+
const switcherName = switcherContainer.getAttribute('data-pkp-switcher');
299+
if (switchersData[switcherName]) {
300+
setSwitcher(switchersData[switcherName], switcherContainer, pubLocaleData.localeOrder, pubLocaleData.localeNames, pubLocaleData.accessibility);
301+
}
302+
});
303+
})();
304+
})();

0 commit comments

Comments
 (0)