Skip to content

Commit

Permalink
Simultaneously Displaying Multilingual Metadata on the Article Landin…
Browse files Browse the repository at this point in the history
…g Page
  • Loading branch information
jyhein committed Feb 21, 2025
1 parent 2aac374 commit 4a77313
Show file tree
Hide file tree
Showing 6 changed files with 623 additions and 34 deletions.
138 changes: 138 additions & 0 deletions pages/article/ArticleHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,26 @@
use APP\observers\events\UsageEvent;
use APP\payment\ojs\OJSCompletedPaymentDAO;
use APP\payment\ojs\OJSPaymentManager;
use APP\publication\Publication;
use APP\security\authorization\OjsJournalMustPublishPolicy;
use APP\submission\Submission;
use APP\template\TemplateManager;
use Firebase\JWT\Key;
use Illuminate\Support\Arr;
use PKP\author\Author;
use PKP\citation\CitationDAO;
use PKP\config\Config;
use PKP\core\Core;
use PKP\core\PKPApplication;
use PKP\core\PKPJwt as JWT;
use PKP\db\DAORegistry;
use PKP\facades\Locale;
use PKP\orcid\OrcidManager;
use PKP\plugins\Hook;
use PKP\plugins\PluginRegistry;
use PKP\security\authorization\ContextRequiredPolicy;
use PKP\security\Validation;
use PKP\services\PKPSchemaService;
use PKP\submission\Genre;
use PKP\submission\GenreDAO;
use PKP\submission\PKPSubmission;
Expand Down Expand Up @@ -366,6 +371,17 @@ public function view($args, $request)
$templateMgr->assign('purchaseArticleEnabled', true);
}

$templateMgr->assign('pubLocaleData', $this->getMultilingualMetadataOpts(
$publication,
$templateMgr->getTemplateVars('currentLocale'),
$templateMgr->getTemplateVars('activeTheme')->getOption('showMultilingualMetadata') ?: [],
));

$templateMgr->registerPlugin('modifier', 'wrapData', fn (...$args) => $this->smartyWrapData($templateMgr, ...$args));
$templateMgr->registerPlugin('modifier', 'useFilters', fn (...$args) => $this->smartyUseFilters($templateMgr, ...$args));
$templateMgr->registerPlugin('modifier', 'getAuthorFullNames', $this->smartyGetAuthorFullNames(...));
$templateMgr->registerPlugin('modifier', 'getAffiliationNamesWithRors', $this->smartyGetAffiliationNamesWithRors(...));

if (!Hook::call('ArticleHandler::view', [&$request, &$issue, &$article, $publication])) {
$templateMgr->display('frontend/pages/article.tpl');
event(new UsageEvent(Application::ASSOC_TYPE_SUBMISSION, $context, $article, null, null, $this->issue));
Expand Down Expand Up @@ -623,4 +639,126 @@ public function userCanViewGalley($request, $articleId, $galleyId = null)
}
return true;
}

/**
* Multilingual publication metadata for template:
* showMultilingualMetadataOpts - Show metadata in other languages: title (+ subtitle), keywords, abstract, etc.
*/
protected function getMultilingualMetadataOpts(Publication $publication, string $currentUILocale, array $showMultilingualMetadataOpts): array
{
// Affiliation languages are not in multiligual props
$authorsLocales = collect($publication->getData('authors'))
->map(fn ($author): array => $this->getAuthorLocales($author))
->flatten()
->unique()
->values()
->toArray();
$langNames = collect($publication->getLanguageNames() + Locale::getSubmissionLocaleDisplayNames($authorsLocales))
->sortKeys();
$langs = $langNames->keys();

return [
'opts' => array_flip($showMultilingualMetadataOpts),
'uiLocale' => $currentUILocale,
'localeNames' => $langNames,
'localeOrder' => collect($publication->getLocalePrecedence())
->intersect($langs) /* remove locales not in publication's languages */
->concat($langs)
->unique()
->values()
->toArray(),
'accessibility' => [
'ariaLabels' => $langNames,
'langAttrs' => $langNames->map(fn ($_, $l) => preg_replace(['/@.+$/', '/_/'], ['', '-'], $l))->toArray() /* remove @ and text after */,
],
];
}

/**
* Publication's multilingual data to array for js and page
*/
protected function smartyWrapData(TemplateManager $templateMgr, array $data, string $switcher, ?array $filters = null, ?string $separator = null): array
{
return [
'switcher' => $switcher,
'data' => collect($data)
->map(
fn ($value): string => collect(Arr::wrap($value))
->when($filters, fn ($value) => $value->map(fn ($v) => $this->smartyUseFilters($templateMgr, $v, $filters)))
->when($separator, fn ($value): string => $value->join($separator), fn ($value): string => $value->first())
)
->toArray(),
'defaultLocale' => collect($templateMgr->getTemplateVars('pubLocaleData')['localeOrder'])
->first(fn (string $locale) => isset($data[$locale])),
];
}

/**
* Smarty template: Apply filters to given value
*/
protected function smartyUseFilters(TemplateManager $templateMgr, string $value, ?array $filters): string
{
if (!$filters) {
return $value;
}
foreach ($filters as $filter) {
$params = Arr::wrap($filter);
$funcName = array_shift($params);
if ($func = $templateMgr->registered_plugins['modifier'][$funcName][0] ?? null) {
$value = $func($value, ...$params);
}
}
return $value;
}

/**
* Smarty template: Get author's full names to multilingual array including all multilingual and affiliation languages as default localized name
*/
protected function smartyGetAuthorFullNames(Author $author): array
{
return collect($this->getAuthorLocales($author))
->mapWithKeys(fn (string $locale) => [$locale => $author->getFullName(preferredLocale: $locale)])
->toArray();
}

/**
* Smarty template: Get authors' affiliations with rors
*/
protected function smartyGetAffiliationNamesWithRors(Author $author): array
{
$affiliations = collect($author->getAffiliations());

return collect($this->getAuthorLocales($author))
->flip()
->map(
fn ($_, string $locale) => $affiliations
->map(fn ($affiliation): array => [
'name' => $affiliation->getAffiliationName($locale),
'ror' => $affiliation->getRor(),
])
->filter(fn (array $nameRor) => $nameRor['name'])
->toArray()
)
->filter()
->toArray();
}

/**
* Aux for smarty template functions: Get author's locales from multilingual props and affiliations
*/
protected function getAuthorLocales(Author $author): array
{
$multilingualLocales = collect(app()->get('schema')->getMultilingualProps(PKPSchemaService::SCHEMA_AUTHOR))
->map(fn (string $prop): array => array_keys($author->getData($prop) ?? []));
$affiliationLocales = collect($author->getAffiliations())
->flatten()
->map(fn ($affiliation): array => array_keys($affiliation->getData('name') ?? []));

return $multilingualLocales
->concat($affiliationLocales)
->flatten()
->unique()
->values()
->toArray();
}
}
24 changes: 24 additions & 0 deletions plugins/themes/default/DefaultThemePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,30 @@ public function init()
'default' => 'none',
]);

$this->addOption('showMultilingualMetadata', 'FieldOptions', [
'label' => __('plugins.themes.default.option.metadata.label'),
'description' => __('plugins.themes.default.option.metadata.description'),
'options' => [
[
'value' => 'title',
'label' => __('submission.title'),
],
[
'value' => 'abstract',
'label' => __('common.abstract'),
],
[
'value' => 'keywords',
'label' => __('common.keywords'),
],
[
'value' => 'author',
'label' => __('default.groups.name.author'),
],
],
'default' => [],
]);


// Load primary stylesheet
$this->addStyle('stylesheet', 'styles/index.less');
Expand Down
188 changes: 188 additions & 0 deletions plugins/themes/default/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,191 @@
});

})(jQuery);

/**
* Create language buttons to show multilingual metadata
* [data-pkp-switcher-data]: Publication data for the switchers to control
* [data-pkp-switcher]: Switchers' containers
*/
(() => {
function createSwitcher(listbox, data, localeOrder, localeNames, accessibility) {
// Get all locales for the switcher from the data
const locales = Object.keys(Object.assign({}, ...Object.values(data)));
// The initially selected locale
let selectedLocale = null;
// Create and sort to alphabetical order
const buttons = localeOrder
.map((locale) => {
if (locales.indexOf(locale) === -1) {
return null;
}
if (!selectedLocale) {
selectedLocale = locale;
}

const isSelectedLocale = locale === selectedLocale;
const button = document.createElement('button');

button.type = 'button';
button.classList.add('pkpBadge', 'pkpBadge--button');
button.value = locale;
button.tabIndex = isSelectedLocale ? '0' : '-1'; // For safari losing button focus
if (!isSelectedLocale) {
button.classList.add('pkp_screen_reader');
}

// Text content
/// SR
const srText = document.createElement('span');
srText.classList.add('pkp_screen_reader');
srText.textContent = accessibility.ariaLabels[locale];
button.appendChild(srText);
// Visual
const text = document.createElement('span');
text.ariaHidden = 'true';
text.textContent = localeNames[locale];
button.appendChild(text);

return button;
})
.filter((btn) => btn)
.sort((a, b) => a.value.localeCompare(b.value));

// If only one button, set it disabled
if (buttons.length === 1) {
buttons[0].ariaDisabled = 'true';
}

buttons.forEach((btn) => {
const option = document.createElement('li');
option.role = 'option';
option.ariaSelected = `${btn.value === selectedLocale}`;
option.appendChild(btn);
// Listbox: Ul element
listbox.appendChild(option);
});

return buttons;
}

/**
* Sync data in elements to match the selected locale
*/
function syncDataElContents(locale, propsData, accessibility) {
for (prop in propsData.data) {
propsData.dataEls[prop].lang = accessibility.langAttrs[locale];
propsData.dataEls[prop].innerHTML = propsData.data[prop][locale] ?? '';
}
}

/**
* Toggle visibility of the buttons
* pkp_screen_reader: button visibility hidden
*/
function setVisibility(buttons, currentSelected, visible) {
buttons.forEach((btn) => {
if (visible) {
btn.classList.remove('pkp_screen_reader');
} else if (btn !== currentSelected.btn) {
btn.classList.add('pkp_screen_reader');
}
});
}

function setSwitcher(propsData, switcherContainer, localeOrder, localeNames, accessibility) {
// Create buttons and append them to the switcher container
const listbox = switcherContainer.querySelector('[role="listbox"]');
const buttons = createSwitcher(listbox, propsData.data, localeOrder, localeNames, accessibility);
const currentSelected = {btn: switcherContainer.querySelector('[tabindex="0"]')};
const focused = {btn: currentSelected.btn};

// Sync contents in data elements to match the selected locale (currentSelected.btn.value)
syncDataElContents(currentSelected.btn.value, propsData, accessibility);

// Do not add listeners if just one button, it is disabled
if (buttons.length < 2) {
return;
}

const isButtonsHidden = () => buttons.some(b => b.classList.contains('pkp_screen_reader'));

// New button switches language and syncs data contents. Same button hides buttons.
switcherContainer.addEventListener('click', (evt) => {
// Choices are li > button > span
const newSelectedBtn = evt.target.classList.contains('pkpBadge--button')
? evt.target
: (evt.target.querySelector('.pkpBadge--button') ?? evt.target.closest('.pkpBadge--button'));
if (buttons.some(b => b === newSelectedBtn)) {
// Set visibility
setVisibility(buttons, currentSelected, newSelectedBtn !== currentSelected.btn ? true : isButtonsHidden());
if (newSelectedBtn !== currentSelected.btn) {
// Sync contents
syncDataElContents(newSelectedBtn.value, propsData, accessibility);
// Listbox option: Aria
currentSelected.btn.parentElement.ariaSelected = 'false';
newSelectedBtn.parentElement.ariaSelected = 'true';
// Button: Tab index
currentSelected.btn.tabIndex = '-1';
newSelectedBtn.tabIndex = '0';
// Update current and focused button
focused.btn = currentSelected.btn = newSelectedBtn;
focused.btn.focus();
}
}
});

// Hide buttons when focus out
switcherContainer.addEventListener('focusout', (evt) => {
if (evt.target !== evt.currentTarget && evt.relatedTarget?.closest('[data-pkp-switcher]') !== switcherContainer) {
setVisibility(buttons, currentSelected, false);
}
});

// Arrow keys left and right cycles button focus when all buttons are visible. Set focused button.
switcherContainer.addEventListener("keydown", (evt) => {
if (evt.key !== "ArrowRight" && evt.key !== "ArrowLeft") return;

const i = buttons.findIndex(b => b === evt.target);
if (i !== -1 && !isButtonsHidden()) {
focused.btn = (evt.key === "ArrowRight")
? (buttons[i + 1] ?? buttons[0])
: (buttons[i - 1] ?? buttons[buttons.length - 1]);
focused.btn.focus();
}
});
}

/**
* Set all multilingual data and elements for the switchers
*/
function setSwitchersData(dataEls, pubLocaleData) {
const propsData = {};
dataEls.forEach((dataEl) => {
const propName = dataEl.getAttribute('data-pkp-switcher-data');
const switcherName = pubLocaleData[propName].switcher;
if (!propsData[switcherName]) {
propsData[switcherName] = {data: [], dataEls: []};
}
propsData[switcherName].data[propName] = pubLocaleData[propName].data;
propsData[switcherName].dataEls[propName] = dataEl;
});
return propsData;
}

(() => {
const switcherContainers = document.querySelectorAll('[data-pkp-switcher]');

if (!switcherContainers.length) return;

const pubLocaleData = JSON.parse(pubLocaleDataJson);
const switchersDataEls = document.querySelectorAll('[data-pkp-switcher-data]');
const switchersData = setSwitchersData(switchersDataEls, pubLocaleData);
// Create and set switchers, and sync data on the page
switcherContainers.forEach((switcherContainer) => {
const switcherName = switcherContainer.getAttribute('data-pkp-switcher');
if (switchersData[switcherName]) {
setSwitcher(switchersData[switcherName], switcherContainer, pubLocaleData.localeOrder, pubLocaleData.localeNames, pubLocaleData.accessibility);
}
});
})();
})();
Loading

0 comments on commit 4a77313

Please sign in to comment.