001/*
002 *  Copyright 2023 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.pagesubscription;
017
018import java.text.ParseException;
019import java.text.SimpleDateFormat;
020import java.time.DayOfWeek;
021import java.time.ZonedDateTime;
022import java.time.temporal.ChronoUnit;
023import java.time.temporal.TemporalAdjusters;
024import java.util.ArrayList;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028
029import org.apache.avalon.framework.activity.Initializable;
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.logger.LogEnabled;
032import org.apache.avalon.framework.logger.Logger;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.cocoon.xml.AttributesImpl;
037import org.apache.cocoon.xml.XMLUtils;
038import org.apache.commons.lang3.StringUtils;
039import org.quartz.CronScheduleBuilder;
040import org.quartz.CronTrigger;
041import org.xml.sax.ContentHandler;
042import org.xml.sax.SAXException;
043
044import org.ametys.core.util.I18nUtils;
045import org.ametys.plugins.pagesubscription.BroadcastChannelHelper.BroadcastChannel;
046import org.ametys.plugins.pagesubscription.type.SubscriptionType.FrequencyTiming;
047import org.ametys.plugins.repository.query.expression.DateExpression;
048import org.ametys.plugins.repository.query.expression.Expression;
049import org.ametys.plugins.repository.query.expression.Expression.Operator;
050import org.ametys.runtime.config.Config;
051import org.ametys.runtime.i18n.I18nizableText;
052import org.ametys.runtime.i18n.I18nizableTextParameter;
053import org.ametys.runtime.model.ElementDefinition;
054
055/**
056 * Helper for frequency
057 */
058public class FrequencyHelper implements Component, Serviceable, Initializable, LogEnabled
059{
060    /** The i18n utils */
061    protected static I18nUtils _i18nUtils;
062    
063    private static long _frequencyDay;
064    private static String _frequencyTime;
065    
066    private static Logger _logger;
067    
068    /**
069     * The frequency type.
070     */
071    public enum Frequency
072    {
073        /** Now */
074        INSTANT,
075        /** Every day */
076        DAILY,
077        /** Every week */
078        WEEKLY,
079        /** Every month */
080        MONTHLY;
081    }
082    
083    public void enableLogging(Logger logger)
084    {
085        _logger = logger;
086    }
087    
088    public void service(ServiceManager manager) throws ServiceException
089    {
090        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
091    }
092    
093    public void initialize() throws Exception
094    {
095        _frequencyDay = Config.getInstance().getValue("page-subscription.frequency.day", true, 1L);
096        _frequencyTime = Config.getInstance().getValue("page-subscription.frequency.hour", true, "10:00");
097    }
098    
099    /**
100     * Get the default day of frequency (weekly or monthly)
101     * @return the default day of frequency
102     */
103    public static long getDefaultFrequencyDay()
104    {
105        return _frequencyDay;
106    }
107    
108    /**
109     * Get the default time of frequency (daily, weekly or monthly)
110     * @return the default time of frequency
111     */
112    public static String getDefaultFrequencyTime()
113    {
114        return _frequencyTime;
115    }
116    
117    private static I18nizableText _getEntry(long day)
118    {
119        try
120        {
121            ElementDefinition def = (ElementDefinition) Config.getModel().getChild("page-subscription.frequency.day");
122            return def.getEnumerator().getEntry(day);
123        }
124        catch (Exception e)
125        {
126            _logger.error("An error occurred getting day from value '" + day + "'", e);
127        }
128        
129        return null;
130    }
131    
132    /**
133     * SAX frequencies
134     * @param contentHandler the content handler to sax intos
135     * @throws SAXException if an error occured while saxing
136     */
137    public static void saxFrequencies(ContentHandler contentHandler) throws SAXException
138    {
139        XMLUtils.startElement(contentHandler, "frequencies");
140        for (Frequency frequency : Frequency.values())
141        {
142            if (frequency != Frequency.INSTANT)
143            {
144                AttributesImpl attrs = new AttributesImpl();
145                attrs.addCDATAAttribute("name", frequency.name());
146                XMLUtils.startElement(contentHandler, "frequency", attrs);
147                getLabel(frequency).toSAX(contentHandler, "label");
148                getSmartLabel(frequency).toSAX(contentHandler, "smartLabel");
149                XMLUtils.endElement(contentHandler, "frequency");
150            }
151        }
152        XMLUtils.endElement(contentHandler, "frequencies");
153    }
154    /**
155     * Get the label for the given frequency
156     * @param frequency the frequency
157     * @return the label
158     */
159    public static I18nizableText getLabel(Frequency frequency)
160    {
161        return new I18nizableText("plugin.page-subscription", "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_FREQUENCY_LABEL_" + frequency.name());
162    }
163    
164    /**
165     * Get the smart label with day and hour of the given frequency
166     * @param frequency the frequency
167     * @return the smart label
168     */
169    public static I18nizableText getSmartLabel(Frequency frequency)
170    {
171        return getSmartLabel(frequency, null);
172    }
173    
174    /**
175     * Get the smart label with day and hour of the given frequency
176     * @param frequency the frequency
177     * @param timing the frequency timing. Can be null to get the default config param
178     * @return the smart label
179     */
180    public static I18nizableText getSmartLabel(Frequency frequency, FrequencyTiming timing)
181    {
182        Map<String, I18nizableTextParameter> params = new HashMap<>();
183        I18nizableText hourI18n = timing != null && timing.time() != null
184                ? new I18nizableText(_getHour(timing.time()))
185                : new I18nizableText(_getHour(_frequencyTime));
186        switch (frequency)
187        {
188            case DAILY:
189                params.put("hour", hourI18n);
190                break;
191            case WEEKLY:
192            case MONTHLY:
193                I18nizableText dayI18n  = timing != null && timing.day() != null
194                    ? _getEntry(timing.day())
195                    : _getEntry(_frequencyDay);
196                if (dayI18n != null)
197                {
198                    params.put("day", dayI18n);
199                }
200                params.put("hour", hourI18n);
201                break;
202            default: // Do nothing ..
203        }
204        return new I18nizableText("plugin.page-subscription", "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_FREQUENCY_SMART_LABEL_" + frequency.name(), params);
205    }
206    
207    /**
208     * Get full label with frequency and broadcast channel
209     * @param frequency the frequency
210     * @param channels the broadcast channels
211     * @return the frequency and broadcast channel label
212     */
213    public static I18nizableText getFullLabel(Frequency frequency, List<BroadcastChannel> channels)
214    {
215        return getFullLabel(frequency, channels, null);
216    }
217    
218    /**
219     * Get full label with frequency and broadcast channel
220     * @param frequency the frequency
221     * @param channels the broadcast channels
222     * @param timing the frequency timing of the full label. Can be null to get the default config param
223     * @return the frequency and broadcast channel label
224     */
225    public static I18nizableText getFullLabel(Frequency frequency, List<BroadcastChannel> channels, FrequencyTiming timing)
226    {
227        Map<String, I18nizableTextParameter> params = new HashMap<>();
228        I18nizableText dayI18n  = timing != null && timing.day() != null
229                ? _getEntry(timing.day())
230                : _getEntry(_frequencyDay);
231        if (dayI18n != null)
232        {
233            params.put("day", dayI18n);
234        }
235        I18nizableText broadcastChannelKey = new I18nizableText("plugin.page-subscription", _getBroadcastChannelKey(channels));
236        params.put("broadcastChannel", broadcastChannelKey);
237        
238        return new I18nizableText("plugin.page-subscription", "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_FREQUENCY_" + frequency.name() + "_AND_BROADCAST_CHANNEL_LABEL", params);
239    }
240    
241    private static String _getBroadcastChannelKey(List<BroadcastChannel> channels)
242    {
243        if (channels.contains(BroadcastChannel.MAIL) && channels.contains(BroadcastChannel.SITE))
244        {
245            return "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_BROADCAST_CHANNEL_MSG_SITE_AND_MAIL";
246        }
247        else if (channels.contains(BroadcastChannel.SITE))
248        {
249            return "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_BROADCAST_CHANNEL_MSG_SITE";
250        }
251        else if (channels.contains(BroadcastChannel.MAIL))
252        {
253            return "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_BROADCAST_CHANNEL_MSG_MAIL";
254        }
255        
256        return "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_BROADCAST_CHANNEL_MSG_NONE";
257    }
258    
259    private static String _getHour(String time)
260    {
261        String stringFormat = _i18nUtils.translate(new I18nizableText("plugin.page-subscription", "PLUGINS_PAGE_SUBSCRIBE_USER_SUBSCRIPTIONS_FREQUENCY_HOUR_FORMAT"));
262        
263        SimpleDateFormat defaultFormat = new SimpleDateFormat("HH:mm");
264        SimpleDateFormat simpleDateFormat = new SimpleDateFormat(stringFormat);
265        try
266        {
267            return simpleDateFormat.format(defaultFormat.parse(time));
268        }
269        catch (ParseException e)
270        {
271            return time;
272        }
273    }
274    
275    /**
276     * Get the frequency timing of the subscription
277     * @param subscription the subscription
278     * @return the frequency timing
279     */
280    public static FrequencyTiming getTiming(Subscription subscription)
281    {
282        FrequencyTiming forceFrequencyTiming = subscription.getForceFrequencyTiming();
283        long day = forceFrequencyTiming.day() != null ? forceFrequencyTiming.day() : getDefaultFrequencyDay();
284        String time = StringUtils.isNotBlank(forceFrequencyTiming.time()) ? forceFrequencyTiming.time() : getDefaultFrequencyTime();
285        
286        return new FrequencyTiming(day, time);
287    }
288    
289    /**
290     * Get cron from frequency and timing
291     * @param frequency the frequency
292     * @param frequencyTiming the timing
293     * @return the cron expression
294     */
295    public static String getCron(Frequency frequency, FrequencyTiming frequencyTiming)
296    {
297        int minutes = (int) frequencyTiming.minutes();
298        int hour = (int) frequencyTiming.hour();
299        
300        switch (frequency)
301        {
302            case DAILY:
303                CronScheduleBuilder builder = CronScheduleBuilder.dailyAtHourAndMinute(hour, minutes);
304                CronTrigger trigger = (CronTrigger) builder.build();
305                return trigger.getCronExpression();
306            case WEEKLY:
307                CronScheduleBuilder builder2 = CronScheduleBuilder.weeklyOnDayAndHourAndMinute(_getDay(frequencyTiming), hour, minutes);
308                CronTrigger trigger2 = (CronTrigger) builder2.build();
309                return trigger2.getCronExpression();
310            case MONTHLY:
311                return "0 " + minutes + " " + hour + " ? * " + _getDay(frequencyTiming) + "#1";
312            default:
313                throw new IllegalArgumentException("Can create subscription schedulable with the given frequency: " + frequency);
314        }
315    }
316    
317    private static int _getDay(FrequencyTiming frequencyTiming)
318    {
319        // Mapping Ametys day API to Quartz day API
320        // Monday 1 => 2
321        // Tuesday 2 => 3
322        // Wednesday 3 => 4
323        // Thursday 4 => 5
324        // Friday 5 => 6
325        // Saturday 6 => 7
326        // Sunday 7 => 1
327        int day = frequencyTiming.day().intValue();
328        return day == 7
329            ? 1
330            : day + 1;
331    }
332    
333    /**
334     * Get the date expressions for a given frequency to get activities before the notification date
335     * @param frequency the frequency
336     * @param notificationDate the notification date
337     * @return date expressions
338     */
339    public static List<Expression> getDateExpressions(Frequency frequency, ZonedDateTime notificationDate)
340    {
341        List<Expression> dateExprs = new ArrayList<>();
342        switch (frequency)
343        {
344            case INSTANT:
345                // For this frequency, get all news since the last past 6 months
346                // FIXME ca sort d'où ces 6 mois ??
347                ZonedDateTime startDate = notificationDate.minusMonths(6);
348                dateExprs.add(new DateExpression("date", Operator.GT, startDate));
349                break;
350            case DAILY:
351                // Frequency is each day at HH:mm
352                // => get contents updated
353                // - between yesterday-1 at HH:mm and yesterday at HH:mm if current time is before HH:mm
354                // - or between yesterday at HH:mm and today at HH:mm if current time is after HH:mm
355                ZonedDateTime startDate2 = notificationDate.minusDays(1);
356                
357                dateExprs.add(new DateExpression("date", Operator.GT, startDate2));
358                dateExprs.add(new DateExpression("date", Operator.LT, notificationDate));
359                break;
360            case WEEKLY:
361                // Frequency is each D day the week at HH:mm
362                // => get contents updated
363                // - between D-14 at HH:mm and D-7 at HH:mm if current time is before D day at HH:mm
364                // - or between D-7 day at HH:mm and D day at HH:mm if current time is after D day at HH:mm
365                ZonedDateTime startDate3 = notificationDate.minusDays(7);
366
367                dateExprs.add(new DateExpression("date", Operator.GT, startDate3));
368                dateExprs.add(new DateExpression("date", Operator.LT, notificationDate));
369                break;
370            case MONTHLY:
371                // Frequency is each 1st D day the month M at HH:mm
372                // => get contents updated
373                // - between 1st D day of the month M-2 at HH:mm and 1st D day of the month M-1 if current time is before 1st D day of the month at HH:mm
374                // - or between 1st D day of the month M-1 at HH:mm and 1st D day of the month M at HH:mm if current time is after 1st D day of the month at HH:mm
375                ZonedDateTime startDate4 = notificationDate.minusMonths(1);
376            
377                dateExprs.add(new DateExpression("date", Operator.GT, startDate4));
378                dateExprs.add(new DateExpression("date", Operator.LT, notificationDate));
379                break;
380            default:
381        }
382        
383        return dateExprs;
384    }
385    
386    /**
387     * Get the notification date for a given frequency and timing
388     * @param frequency the frequency
389     * @param timing the timing
390     * @return the notification date
391     */
392    public static ZonedDateTime getNotificationDate(Frequency frequency, FrequencyTiming timing)
393    {
394        ZonedDateTime now = ZonedDateTime.now();
395        
396        long day = timing.day();
397        int hour = (int) timing.hour();
398        int minute = (int) timing.minutes();
399        
400        switch (frequency)
401        {
402            case INSTANT:
403                // Return the today date
404                return now.truncatedTo(ChronoUnit.DAYS);
405            case DAILY:
406                // Frequency is each day at HH:mm
407                // => get notification date
408                // - notification date is yesterday at HH:mm if current time is before HH:mm
409                // - or notification date is today at HH:mm if current time is after HH:mm
410                return _isCurrentDayBeforeDailyTime(now, hour, minute)
411                        ? now.minusDays(1).truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute)
412                        : now.truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute);
413            case WEEKLY:
414                // Frequency is each D day the week at HH:mm
415                // => get notification date
416                // - notification date is D-7 at HH:mm if current time is before D day at HH:mm
417                // - or notification date is D day at HH:mm if current time is after D day at HH:mm
418                ZonedDateTime dayOfWeek = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.of((int) day))); // get previous or same day of the week
419                return dayOfWeek.getDayOfMonth() == now.getDayOfMonth() && _isCurrentDayBeforeDailyTime(now, hour, minute)
420                        ? dayOfWeek.minusDays(7).truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute)
421                        : dayOfWeek.truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute);
422            case MONTHLY:
423                // Frequency is each 1st D day the month M at HH:mm
424                // => get notification date
425                // - notification date is the 1st D day of the month M-1 if current time is before 1st D day of the month at HH:mm
426                // - or notification date is the 1st D day of the month M at HH:mm if current time is after 1st D day of the month at HH:mm
427                ZonedDateTime dayOfMonth = now.with(TemporalAdjusters.firstInMonth(DayOfWeek.of((int) day))); // get first day of the month
428                return now.getDayOfMonth() < dayOfMonth.getDayOfMonth() || now.getDayOfMonth() == day && _isCurrentDayBeforeDailyTime(now, hour, minute)
429                        ? dayOfMonth.minusMonths(1).truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute)
430                        : dayOfMonth.truncatedTo(ChronoUnit.DAYS).withHour(hour).withMinute(minute);
431            default:
432        }
433        
434        return null;
435    }
436    
437    private static boolean _isCurrentDayBeforeDailyTime(ZonedDateTime now, int hour, int minute)
438    {
439        return now.getHour() < hour // is before if the current hour is before the given time hour
440            || now.getHour() == hour && now.getMinute() < minute; // or is before if the current hour is equal to the given time hour and the current minute is before the given time minute
441    }
442}