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
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ public class Profile {
/** TimeZone key. */
public static final String TIME_ZONE = "timeZone";

/**
* The organization profile attribute holding the timezone the browser of the
* user lives in, for example Europe/Paris. It is the source of truth for
* everything sent to the user on a schedule, kept up to date on every page
* load by the platform timezone synchronization.
*/
public static final String USER_TIME_ZONE = "user.timeZone";

/** TimeZone DayLight savings key. */
public static final String TIME_ZONE_DST_SAVINGS = "timeZoneDSTSavings";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* This file is part of the Meeds project (https://meeds.io/).
*
* Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package io.meeds.social.digest.service;

import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;

import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import org.exoplatform.commons.api.notification.plugin.config.PluginConfig;
import org.exoplatform.commons.api.notification.service.setting.PluginSettingService;
import org.exoplatform.services.resources.ResourceBundleService;
import io.meeds.commons.digest.plugin.DigestCategoryProvider;

/**
* Translates the label of a digest category. The categories of the addons are
* translated in the language of the user here and not in the browser, because
* the settings page only loads the resource bundles of the platform: the labels
* of an addon are read from the bundle its own notification plugins already
* use, so an addon has nothing more to declare than what it declares today.
*/
@Component
public class DigestCategoryLabelResolver {

private final PluginSettingService pluginSettingService;

private final ResourceBundleService resourceBundleService;

public DigestCategoryLabelResolver(PluginSettingService pluginSettingService, ResourceBundleService resourceBundleService) {
this.pluginSettingService = pluginSettingService;
this.resourceBundleService = resourceBundleService;
}

/**
* @param category the category to display
* @param locale the language to translate the label in, the request one:
* these REST services are served by the social webapp, which doesn't
* fill the portal locale of the thread
* @return the label of the category in the language of the user, its
* identifier when no bundle holds it
*/
public String getLabel(DigestCategoryProvider category, Locale locale) {
Locale userLocale = locale == null ? Locale.ENGLISH : locale;
String labelKey = category.getLabelKey();
List<String> pluginIds = category.getPluginIds();
if (StringUtils.isBlank(labelKey) || pluginIds == null) {
return category.getId();
}
for (String pluginId : pluginIds) {
String label = getLabel(labelKey, pluginId, userLocale);
if (label != null) {
return label;
}
}
return category.getId();
}

private String getLabel(String labelKey, String pluginId, Locale userLocale) {
PluginConfig pluginConfig = pluginSettingService.getPluginConfig(pluginId);
if (pluginConfig == null || StringUtils.isBlank(pluginConfig.getBundlePath())) {
return null;
}
ResourceBundle bundle = resourceBundleService.getResourceBundle(pluginConfig.getBundlePath(), userLocale);
return bundle != null && bundle.containsKey(labelKey) ? bundle.getString(labelKey) : null;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* This file is part of the Meeds project (https://meeds.io/).
*
* Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package io.meeds.social.timezone.service;

import java.time.DateTimeException;
import java.time.ZoneId;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import org.exoplatform.services.listener.ListenerService;
import org.exoplatform.services.organization.OrganizationService;
import org.exoplatform.services.organization.UserProfile;
import org.exoplatform.services.organization.UserProfileHandler;
import org.exoplatform.social.core.identity.model.Profile;

/**
* Holds the timezone of each user, the one his browser lives in, under the
* profile attribute {@link Profile#USER_TIME_ZONE}. The browser of the user
* keeps it up to date on every page load, so that everything sent to him on a
* schedule, starting with the digest mail notifications, goes out at the right
* local hour. This synchronization used to live in the agenda addon and is now
* owned by the platform, so that it works whatever the installed addons;
* agenda delegates its own copy here.
*/
@Service
public class UserTimeZoneService {

/**
* Event broadcast when the timezone of a user is saved, with the username as
* source and the timezone as data. A profile listener can't be used for
* that: it never fires when this attribute changes.
*/
public static final String USER_TIME_ZONE_SAVED_EVENT = "social.timeZone.saved";

private static final Logger LOG = LoggerFactory.getLogger(UserTimeZoneService.class);

private final OrganizationService organizationService;

private final ListenerService listenerService;

public UserTimeZoneService(OrganizationService organizationService, ListenerService listenerService) {
this.organizationService = organizationService;
this.listenerService = listenerService;
}

/**
* @param username the user to read the timezone of
* @return the timezone of the user, null when it is unknown: a user who never
* loaded a page has no timezone yet. The caller falls back to the
* server timezone.
*/
public String getUserTimeZone(String username) {
if (StringUtils.isBlank(username)) {
return null;
}
try {
UserProfile userProfile = organizationService.getUserProfileHandler().findUserProfileByName(username);
return userProfile == null ? null : userProfile.getAttribute(Profile.USER_TIME_ZONE);
} catch (Exception e) {
// An unknown timezone only makes what is sent on a schedule go out on
// the server hour, it must never break the caller
LOG.warn("Can't read the timezone of user {}, the server timezone will be used instead", username, e);
return null;
}
}

/**
* Saves the timezone the browser of the user lives in, then broadcasts
* {@link #USER_TIME_ZONE_SAVED_EVENT} so that whatever keeps a copy of the
* timezone, like the digest work list, can refresh it.
*
* @param username the user to save the timezone of
* @param zoneId the timezone identifier, for example Europe/Paris
* @throws IllegalArgumentException when the timezone is not a known one
* @throws IllegalStateException when the profile of the user can't be saved
*/
public void saveUserTimeZone(String username, String zoneId) {
if (StringUtils.isBlank(username)) {
throw new IllegalArgumentException("Username is mandatory");
}
try {
ZoneId.of(zoneId);
} catch (DateTimeException | NullPointerException e) {
throw new IllegalArgumentException("Unknown timezone " + zoneId, e);
}
try {
UserProfileHandler userProfileHandler = organizationService.getUserProfileHandler();
UserProfile userProfile = userProfileHandler.findUserProfileByName(username);
if (userProfile == null) {
userProfile = userProfileHandler.createUserProfileInstance(username);
}
userProfile.setAttribute(Profile.USER_TIME_ZONE, zoneId);
userProfileHandler.saveUserProfile(userProfile, true);
} catch (Exception e) {
throw new IllegalStateException("Can't save the timezone of user " + username, e);
}
broadcast(username, zoneId);
}

private void broadcast(String username, String zoneId) {
try {
listenerService.broadcast(USER_TIME_ZONE_SAVED_EVENT, username, zoneId);
} catch (Exception e) {
// The timezone is saved, a listener failure must not undo that for the
// user: whoever keeps a copy refreshes it at worst on his next save
LOG.warn("Error broadcasting the timezone change of user {}", username, e);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* This file is part of the Meeds project (https://meeds.io/).
*
* Copyright (C) 2020 - 2026 Meeds Association contact@meeds.io
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package io.meeds.social.digest.service;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;

import java.util.List;
import java.util.Locale;
import java.util.ListResourceBundle;
import java.util.ResourceBundle;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import org.exoplatform.commons.api.notification.plugin.config.PluginConfig;
import org.exoplatform.commons.api.notification.service.setting.PluginSettingService;
import org.exoplatform.services.resources.ResourceBundleService;

import io.meeds.commons.digest.plugin.DigestCategoryProvider;

@RunWith(MockitoJUnitRunner.class)
public class DigestCategoryLabelResolverTest {

private static final String BUNDLE_PATH = "locale.notification.template.Notification";

private static final String LABEL_KEY = "digest.category.spaces";

@Mock
private PluginSettingService pluginSettingService;

@Mock
private ResourceBundleService resourceBundleService;

private DigestCategoryLabelResolver labelResolver;

@Before
public void setUp() {
labelResolver = new DigestCategoryLabelResolver(pluginSettingService, resourceBundleService);
PluginConfig pluginConfig = new PluginConfig();
pluginConfig.setBundlePath(BUNDLE_PATH);
lenient().when(pluginSettingService.getPluginConfig("SpaceInvitationPlugin")).thenReturn(pluginConfig);
lenient().when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.ENGLISH)).thenReturn(bundle("Spaces"));
lenient().when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.FRENCH)).thenReturn(bundle("Espaces"));
}

/**
* The digest REST services are served by the social webapp, which doesn't
* fill the portal locale of the thread: reading the language anywhere else
* than in the request would serve English to everybody.
*/
@Test
public void testLabelIsTranslatedInTheLanguageOfTheUser() {
assertEquals("Espaces", labelResolver.getLabel(category(), Locale.FRENCH));
assertEquals("Spaces", labelResolver.getLabel(category(), Locale.ENGLISH));
}

@Test
public void testLabelFallsBackToEnglishWithoutLanguage() {
assertEquals("Spaces", labelResolver.getLabel(category(), null));
}

@Test
public void testLabelFallsBackToTheCategoryIdWhenNoPluginIsRegistered() {
when(pluginSettingService.getPluginConfig("SpaceInvitationPlugin")).thenReturn(null);
assertEquals("spaces", labelResolver.getLabel(category(), Locale.FRENCH));
}

@Test
public void testLabelFallsBackToTheCategoryIdWhenNoBundleHoldsIt() {
when(resourceBundleService.getResourceBundle(BUNDLE_PATH, Locale.FRENCH)).thenReturn(bundle(null));
assertEquals("spaces", labelResolver.getLabel(category(), Locale.FRENCH));
}

private ResourceBundle bundle(String label) {
return new ListResourceBundle() {
@Override
protected Object[][] getContents() {
return label == null ? new Object[0][0] : new Object[][] { { LABEL_KEY, label } };
}
};
}

private DigestCategoryProvider category() {
return new DigestCategoryProvider() {
@Override
public String getId() {
return "spaces";
}

@Override
public String getLabelKey() {
return LABEL_KEY;
}

@Override
public int getOrder() {
return 10;
}

@Override
public List<String> getPluginIds() {
return List.of("SpaceInvitationPlugin");
}
};
}

}
Loading
Loading