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