1 ////////////////////////////////////////////////////////////////////////////////
2 //
3 // Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez.
4 //
5 // Permission is hereby granted, free of charge, to any person obtaining a copy
6 // of this software and associated documentation files (the "Software"), to deal
7 // in the Software without restriction, including without limitation the rights
8 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 // copies of the Software, and to permit persons to whom the Software is
10 // furnished to do so, subject to the following conditions:
11 //
12 // The above copyright notice and this permission notice shall be included
13 // in all copies or substantial portions of the Software.
14 //
15 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16 // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18 // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 // SOFTWARE.
22 //
23 // https://www.opensource.org/licenses/mit-license.php
24 //
25 ////////////////////////////////////////////////////////////////////////////////
26 
27 #include <cmake.h>
28 #include <CmdCalendar.h>
29 #include <sstream>
30 #include <iomanip>
31 #include <stdlib.h>
32 #include <Context.h>
33 #include <Table.h>
34 #include <Lexer.h>
35 #include <shared.h>
36 #include <format.h>
37 #include <util.h>
38 #include <utf8.h>
39 #include <main.h>
40 
41 ////////////////////////////////////////////////////////////////////////////////
CmdCalendar()42 CmdCalendar::CmdCalendar ()
43 {
44   _keyword               = "calendar";
45   _usage                 = "task          calendar [due|<month> <year>|<year>] [y]";
46   _description           = "Shows a calendar, with due tasks marked";
47   _read_only             = true;
48   _displays_id           = true;
49   _needs_gc              = true;
50   _uses_context          = false;
51   _accepts_filter        = false;
52   _accepts_modifications = false;
53   _accepts_miscellaneous = true;
54   _category              = Command::Category::graphs;
55 }
56 
57 ////////////////////////////////////////////////////////////////////////////////
execute(std::string & output)58 int CmdCalendar::execute (std::string& output)
59 {
60   int rc = 0;
61 
62   auto& config = Context::getContext ().config;
63 
64   // Each month requires 28 text columns width.  See how many will actually
65   // fit.  But if a preference is specified, and it fits, use it.
66   auto width = Context::getContext ().getWidth ();
67   int preferredMonthsPerLine;
68 
69   if (config.has ("calendar.monthsperline"))
70     preferredMonthsPerLine = config.getInteger ("calendar.monthsperline");
71   else
72     // Legacy configuration variable value
73     preferredMonthsPerLine = config.getInteger ("monthsperline");
74 
75   auto monthsThatFit = width / 26;
76 
77   auto monthsPerLine = monthsThatFit;
78   if (preferredMonthsPerLine != 0 && preferredMonthsPerLine < monthsThatFit)
79     monthsPerLine = preferredMonthsPerLine;
80 
81   // Load the pending tasks.
82   handleUntil ();
83   handleRecurrence ();
84   auto tasks = Context::getContext ().tdb2.pending.get_tasks ();
85 
86   Datetime today;
87   auto getPendingDate = false;
88   auto monthsToDisplay = 1;
89   auto mFrom = today.month ();
90   auto yFrom = today.year ();
91   auto mTo = mFrom;
92   auto yTo = yFrom;
93 
94   // Defaults.
95   monthsToDisplay = monthsPerLine;
96   mFrom = today.month ();
97   yFrom = today.year ();
98 
99   // Set up a vector of commands (1), for autoComplete.
100   std::vector <std::string> commandNames {"calendar"};
101 
102   // Set up a vector of keywords, for autoComplete.
103   std::vector <std::string> keywordNames {"due"};
104 
105   // Set up a vector of months, for autoComplete.
106   std::vector <std::string> monthNames;
107   for (int i = 1; i <= 12; ++i)
108     monthNames.push_back (Lexer::lowerCase (Datetime::monthName (i)));
109 
110   // For autoComplete results.
111   std::vector <std::string> matches;
112 
113   // Look at all args, regardless of sequence.
114   auto argMonth = 0;
115   auto argYear = 0;
116   auto argWholeYear = false;
117 
118   for (auto& arg : Context::getContext ().cli2.getWords ())
119   {
120     // Some version of "calendar".
121     if (autoComplete (Lexer::lowerCase (arg), commandNames, matches, config.getInteger ("abbreviation.minimum")) == 1)
122       continue;
123 
124     // "due".
125     else if (autoComplete (Lexer::lowerCase (arg), keywordNames, matches, config.getInteger ("abbreviation.minimum")) == 1)
126       getPendingDate = true;
127 
128     // "y".
129     else if (Lexer::lowerCase (arg) == "y")
130       argWholeYear = true;
131 
132     // YYYY.
133     else if (Lexer::isAllDigits (arg) && arg.length () == 4)
134       argYear = strtol (arg.c_str (), nullptr, 10);
135 
136     // MM.
137     else if (Lexer::isAllDigits (arg) && arg.length () <= 2)
138     {
139       argMonth = strtol (arg.c_str (), nullptr, 10);
140       if (argMonth < 1 || argMonth > 12)
141         throw format ("Argument '{1}' is not a valid month.", arg);
142     }
143 
144     // "January" etc.
145     else if (autoComplete (Lexer::lowerCase (arg), monthNames, matches, config.getInteger ("abbreviation.minimum")) == 1)
146     {
147       argMonth = Datetime::monthOfYear (matches[0]);
148       if (argMonth == -1)
149         throw format ("Argument '{1}' is not a valid month.", arg);
150     }
151 
152     else
153       throw format ("Could not recognize argument '{1}'.", arg);
154   }
155 
156   // Supported combinations:
157   //
158   //   Command line  monthsToDisplay  mFrom  yFrom  getPendingDate
159   //   ------------  ---------------  -----  -----  --------------
160   //   cal             monthsPerLine  today  today           false
161   //   cal y                      12  today  today           false
162   //   cal due         monthsPerLine  today  today            true
163   //   cal YYYY                   12      1    arg           false
164   //   cal due y                  12  today  today            true
165   //   cal MM YYYY     monthsPerLine    arg    arg           false
166   //   cal MM YYYY y              12    arg    arg           false
167 
168   if (argWholeYear || (argYear && !argMonth && !argWholeYear))
169     monthsToDisplay = 12;
170 
171   if (!argMonth && argYear)
172     mFrom = 1;
173   else if (argMonth && argYear)
174     mFrom = argMonth;
175 
176   if (argYear)
177     yFrom = argYear;
178 
179   // Now begin the data subset and rendering.
180   auto countDueDates = 0;
181   if (getPendingDate == true)
182   {
183     // Find the oldest pending due date.
184     Datetime oldest (9999, 12, 31);
185     for (auto& task : tasks)
186     {
187       auto status = task.getStatus ();
188       if (status == Task::pending || status == Task::waiting)
189       {
190         if (task.has ("due") &&
191             !task.hasTag ("nocal"))
192         {
193           ++countDueDates;
194           Datetime d (task.get ("due"));
195           if (d < oldest) oldest = d;
196         }
197       }
198     }
199 
200     // Default to current month if no due date is present
201     if (oldest != Datetime (9999, 12, 31)) {
202       mFrom = oldest.month();
203       yFrom = oldest.year();
204     }
205   }
206 
207   if (config.getBoolean ("calendar.offset"))
208   {
209     auto moffset = config.getInteger ("calendar.offset.value") % 12;
210     auto yoffset = config.getInteger ("calendar.offset.value") / 12;
211     mFrom += moffset;
212     yFrom += yoffset;
213     if (mFrom < 1)
214     {
215       mFrom += 12;
216       yFrom--;
217     }
218     else if (mFrom > 12)
219     {
220       mFrom -= 12;
221       yFrom++;
222     }
223   }
224 
225   mTo = mFrom + monthsToDisplay - 1;
226   yTo = yFrom;
227   if (mTo > 12)
228   {
229     mTo -= 12;
230     yTo++;
231   }
232 
233   auto details_yFrom = yFrom;
234   auto details_mFrom = mFrom;
235 
236   std::stringstream out;
237   out << '\n';
238 
239   while (yFrom < yTo || (yFrom == yTo && mFrom <= mTo))
240   {
241     auto nextM = mFrom;
242     auto nextY = yFrom;
243 
244     // Print month headers (cheating on the width settings, yes)
245     for (int i = 0 ; i < monthsPerLine ; i++)
246     {
247       auto month = Datetime::monthName (nextM);
248 
249       //    12345678901234567890123456 = 26 chars wide
250       //                ^^             = center
251       //    <------->                  = 13 - (month.length / 2) + 1
252       //                      <------> = 26 - above
253       //   +--------------------------+
254       //   |         July 2009        |
255       //   |     Mo Tu We Th Fr Sa Su |
256       //   |  27        1  2  3  4  5 |
257       //   |  28  6  7  8  9 10 11 12 |
258       //   |  29 13 14 15 16 17 18 19 |
259       //   |  30 20 21 22 23 24 25 26 |
260       //   |  31 27 28 29 30 31       |
261       //   +--------------------------+
262 
263       auto totalWidth = 26;
264       auto labelWidth = month.length () + 5;  // 5 = " 2009"
265       auto leftGap = (totalWidth / 2) - (labelWidth / 2);
266       auto rightGap = totalWidth - leftGap - labelWidth;
267 
268       out << std::setw (leftGap) << ' '
269           << month
270           << ' '
271           << nextY
272           << std::setw (rightGap) << ' ';
273 
274       if (++nextM > 12)
275       {
276         nextM = 1;
277         nextY++;
278       }
279     }
280 
281     out << '\n'
282         << optionalBlankLine ()
283         << renderMonths (mFrom, yFrom, today, tasks, monthsPerLine)
284         << '\n';
285 
286     mFrom += monthsPerLine;
287     if (mFrom > 12)
288     {
289       mFrom -= 12;
290       ++yFrom;
291     }
292   }
293 
294   Color color_today      (config.get ("color.calendar.today"));
295   Color color_due        (config.get ("color.calendar.due"));
296   Color color_duetoday   (config.get ("color.calendar.due.today"));
297   Color color_overdue    (config.get ("color.calendar.overdue"));
298   Color color_weekend    (config.get ("color.calendar.weekend"));
299   Color color_holiday    (config.get ("color.calendar.holiday"));
300   Color color_scheduled  (config.get ("color.calendar.scheduled"));
301   Color color_weeknumber (config.get ("color.calendar.weeknumber"));
302 
303   if (Context::getContext ().color () && config.getBoolean ("calendar.legend"))
304   {
305     out << "Legend: "
306         << color_today.colorize ("today")
307         << ", "
308         << color_weekend.colorize ("weekend")
309         << ", ";
310 
311     // If colorizing due dates, print legend
312     if (config.get ("calendar.details") != "none")
313       out << color_due.colorize ("due")
314           << ", "
315           << color_duetoday.colorize ("due-today")
316           << ", "
317           << color_overdue.colorize ("overdue")
318           << ", "
319           << color_scheduled.colorize ("scheduled")
320           << ", ";
321 
322     // If colorizing holidays, print legend
323     if (config.get ("calendar.holidays") != "none")
324       out << color_holiday.colorize ("holiday") << ", ";
325 
326     out << color_weeknumber.colorize ("weeknumber")
327         << '.'
328         << optionalBlankLine ()
329         << '\n';
330   }
331 
332   if (config.get ("calendar.details") == "full" || config.get ("calendar.holidays") == "full")
333   {
334     --details_mFrom;
335     if (details_mFrom == 0)
336     {
337       details_mFrom = 12;
338       --details_yFrom;
339     }
340     int details_dFrom = Datetime::daysInMonth (details_yFrom, details_mFrom);
341 
342     ++mTo;
343     if (mTo == 13)
344     {
345       mTo = 1;
346       ++yTo;
347     }
348 
349     Datetime date_after (details_yFrom, details_mFrom, details_dFrom);
350     auto after = date_after.toString (config.get ("dateformat"));
351 
352     Datetime date_before (yTo, mTo, 1);
353     auto before = date_before.toString (config.get ("dateformat"));
354 
355     // Table with due date information
356     if (config.get ("calendar.details") == "full")
357     {
358       // Assert that 'report' is a valid report.
359       auto report = config.get ("calendar.details.report");
360       if (Context::getContext ().commands.find (report) == Context::getContext ().commands.end ())
361         throw std::string ("The setting 'calendar.details.report' must contain a single report name.");
362 
363       // TODO Fix this:  cal      --> task
364       //                 calendar --> taskendar
365 
366       // If the executable was "cal" or equivalent, replace it with "task".
367       auto executable = Context::getContext ().cli2._original_args[0].attribute ("raw");
368       auto cal = executable.find ("cal");
369       if (cal != std::string::npos)
370         executable = executable.substr (0, cal) + PACKAGE;
371 
372       std::vector <std::string> args;
373       args.push_back ("rc:" + Context::getContext ().rc_file._data);
374       args.push_back ("rc.due:0");
375       args.push_back ("rc.verbose:label,affected,blank");
376       if (Context::getContext ().color ())
377           args.push_back ("rc._forcecolor:on");
378       args.push_back ("due.after:" + after);
379       args.push_back ("due.before:" + before);
380       args.push_back ("-nocal");
381       args.push_back (report);
382 
383       std::string output;
384       ::execute (executable, args, "", output);
385       out << output;
386     }
387 
388     // Table with holiday information
389     if (config.get ("calendar.holidays") == "full")
390     {
391       Table holTable;
392       holTable.width (Context::getContext ().getWidth ());
393       holTable.add ("Date");
394       holTable.add ("Holiday");
395       setHeaderUnderline (holTable);
396 
397       auto dateFormat = config.get ("dateformat.holiday");
398 
399       std::map <time_t, std::vector<std::string>> hm; // we need to store multiple holidays per day
400       for (auto& it : config)
401         if (it.first.substr (0, 8) == "holiday.")
402           if (it.first.substr (it.first.size () - 4) == "name")
403           {
404             auto holName = it.second;
405             auto date = config.get ("holiday." + it.first.substr (8, it.first.size () - 13) + ".date");
406             auto start = config.get ("holiday." + it.first.substr (8, it.first.size () - 13) + ".start");
407             auto end = config.get ("holiday." + it.first.substr (8, it.first.size () - 13) + ".end");
408             if (!date.empty ())
409             {
410               Datetime holDate (date.c_str (), dateFormat);
411 
412               if (date_after < holDate && holDate < date_before)
413                 hm[holDate.toEpoch()].push_back (holName);
414             }
415             if (!start.empty () && !end.empty ())
416             {
417               Datetime holStart (start.c_str (), dateFormat);
418               Datetime holEnd (end.c_str (), dateFormat);
419 
420               if (date_after < holStart && holStart < date_before)
421                 hm[holStart.toEpoch()].push_back ("Start of " + holName);
422               if (date_after < holEnd && holEnd < date_before)
423                 hm[holEnd.toEpoch()].push_back ("End of " + holName);
424             }
425           }
426 
427       auto format = config.get ("report." +
428                                 config.get ("calendar.details.report") +
429                                 ".dateformat");
430       if (format == "")
431         format = config.get ("dateformat.report");
432       if (format == "")
433         format = config.get ("dateformat");
434 
435       for (auto& hm_it : hm)
436       {
437         auto v = hm_it.second;
438         Datetime hDate (hm_it.first);
439         auto d = hDate.toString (format);
440         for (const auto& i : v)
441         {
442           auto row = holTable.addRow ();
443           holTable.set (row, 0, d);
444           holTable.set (row, 1, i);
445         }
446       }
447 
448       out << optionalBlankLine ()
449           << holTable.render ()
450           << '\n';
451     }
452   }
453 
454   output = out.str ();
455   return rc;
456 }
457 
458 ////////////////////////////////////////////////////////////////////////////////
renderMonths(int firstMonth,int firstYear,const Datetime & today,std::vector<Task> & all,int monthsPerLine)459 std::string CmdCalendar::renderMonths (
460   int firstMonth,
461   int firstYear,
462   const Datetime& today,
463   std::vector <Task>& all,
464   int monthsPerLine)
465 {
466 
467   auto& config = Context::getContext ().config;
468 
469   // What day of the week does the user consider the first?
470   auto weekStart = Datetime::dayOfWeek (config.get ("weekstart"));
471   if (weekStart != 0 && weekStart != 1)
472     throw std::string ("The 'weekstart' configuration variable may only contain 'Sunday' or 'Monday'.");
473 
474   // Build table for the number of months to be displayed.
475   Table view;
476   setHeaderUnderline (view);
477   view.width (Context::getContext ().getWidth ());
478   for (int i = 0 ; i < (monthsPerLine * 8); i += 8)
479   {
480     if (weekStart == 1)
481     {
482       view.add ("", false);
483       view.add (utf8_substr (Datetime::dayName (1), 0, 2), false);
484       view.add (utf8_substr (Datetime::dayName (2), 0, 2), false);
485       view.add (utf8_substr (Datetime::dayName (3), 0, 2), false);
486       view.add (utf8_substr (Datetime::dayName (4), 0, 2), false);
487       view.add (utf8_substr (Datetime::dayName (5), 0, 2), false);
488       view.add (utf8_substr (Datetime::dayName (6), 0, 2), false);
489       view.add (utf8_substr (Datetime::dayName (0), 0, 2), false);
490     }
491     else
492     {
493       view.add ("", false);
494       view.add (utf8_substr (Datetime::dayName (0), 0, 2), false);
495       view.add (utf8_substr (Datetime::dayName (1), 0, 2), false);
496       view.add (utf8_substr (Datetime::dayName (2), 0, 2), false);
497       view.add (utf8_substr (Datetime::dayName (3), 0, 2), false);
498       view.add (utf8_substr (Datetime::dayName (4), 0, 2), false);
499       view.add (utf8_substr (Datetime::dayName (5), 0, 2), false);
500       view.add (utf8_substr (Datetime::dayName (6), 0, 2), false);
501     }
502   }
503 
504   // At most, we need 6 rows.
505   view.addRow ();
506   view.addRow ();
507   view.addRow ();
508   view.addRow ();
509   view.addRow ();
510   view.addRow ();
511 
512   // Set number of days per month, months to render, and years to render.
513   std::vector<int> years;
514   std::vector<int> months;
515   std::vector<int> daysInMonth;
516   int thisYear = firstYear;
517   int thisMonth = firstMonth;
518   for (int i = 0 ; i < monthsPerLine ; i++)
519   {
520     if (thisMonth < 13)
521     {
522       years.push_back (thisYear);
523     }
524     else
525     {
526       thisMonth -= 12;
527       years.push_back (++thisYear);
528     }
529     months.push_back (thisMonth);
530     daysInMonth.push_back (Datetime::daysInMonth (thisYear, thisMonth++));
531   }
532 
533   auto row = 0;
534 
535   Color color_today      (config.get ("color.calendar.today"));
536   Color color_due        (config.get ("color.calendar.due"));
537   Color color_duetoday   (config.get ("color.calendar.due.today"));
538   Color color_overdue    (config.get ("color.calendar.overdue"));
539   Color color_weekend    (config.get ("color.calendar.weekend"));
540   Color color_holiday    (config.get ("color.calendar.holiday"));
541   Color color_scheduled  (config.get ("color.calendar.scheduled"));
542   Color color_weeknumber (config.get ("color.calendar.weeknumber"));
543 
544   // Loop through months to be added on this line.
545   for (int mpl = 0; mpl < monthsPerLine ; mpl++)
546   {
547     // Reset row counter for subsequent months
548     if (mpl != 0)
549       row = 0;
550 
551     // Loop through days in month and add to table.
552     for (int d = 1; d <= daysInMonth[mpl]; ++d)
553     {
554       Datetime date (years[mpl], months[mpl], d);
555       auto dow = date.dayOfWeek ();
556       auto woy = date.week ();
557 
558       if (config.getBoolean ("displayweeknumber"))
559         view.set (row,
560                   (8 * mpl),
561                   // Make sure the week number is always 4 columns, space-padded.
562                   format ((woy < 10 ? "   {1}" : "  {1}"), woy),
563                   color_weeknumber);
564 
565       // Calculate column id.
566       auto thisCol = dow +                       // 0 = Sunday
567                      (weekStart == 1 ? 0 : 1) +  // Offset for weekStart
568                      (8 * mpl);                  // Columns in 1 month
569 
570       if (thisCol == (8 * mpl))
571         thisCol += 7;
572 
573       view.set (row, thisCol, d);
574 
575       if (Context::getContext ().color ())
576       {
577         Color cellColor;
578 
579         // colorize weekends
580         if (dow == 0 || dow == 6)
581           cellColor.blend (color_weekend);
582 
583         // colorize holidays
584         if (config.get ("calendar.holidays") != "none")
585         {
586           auto dateFormat = config.get ("dateformat.holiday");
587           for (auto& hol : config)
588           {
589             if (hol.first.substr (0, 8) == "holiday.")
590             {
591               if (hol.first.substr (hol.first.size () - 4) == "date")
592               {
593                 auto value = hol.second;
594                 Datetime holDate (value.c_str (), dateFormat);
595                 if (holDate.sameDay (date))
596                   cellColor.blend (color_holiday);
597               }
598 
599               if (hol.first.substr (hol.first.size () - 5) == "start" &&
600                   config.has ("holiday." + hol.first.substr (8, hol.first.size () - 14) + ".end"))
601               {
602                 auto start = hol.second;
603                 auto end = config.get ("holiday." + hol.first.substr (8, hol.first.size () - 14) + ".end");
604                 Datetime holStart (start.c_str (), dateFormat);
605                 Datetime holEnd   (end.c_str (), dateFormat);
606                 if (holStart <= date && date <= holEnd)
607                   cellColor.blend (color_holiday);
608               }
609             }
610           }
611         }
612 
613         // colorize today
614         if (today.sameDay (date))
615           cellColor.blend (color_today);
616 
617         // colorize due and scheduled tasks
618         if (config.get ("calendar.details") != "none")
619         {
620           config.set ("due", 0);
621           config.set ("scheduled", 0);
622           // if a date has a task that is due on that day, the due color
623           // takes precedence over the scheduled color
624           bool coloredWithDue = false;
625           for (auto& task : all)
626           {
627             auto status = task.getStatus ();
628             if ((status == Task::pending ||
629                  status == Task::waiting ) &&
630                 !task.hasTag ("nocal"))
631             {
632               if(task.has("scheduled") && !coloredWithDue) {
633                 std::string scheduled = task.get ("scheduled");
634                 Datetime scheduleddmy (strtol (scheduled.c_str(), nullptr, 10));
635 
636                 if (scheduleddmy.sameDay (date))
637                 {
638                   cellColor.blend(color_scheduled);
639                 }
640               }
641               if(task.has("due")) {
642                 std::string due = task.get ("due");
643                 Datetime duedmy (strtol (due.c_str(), nullptr, 10));
644 
645                 if (duedmy.sameDay (date))
646                 {
647                   coloredWithDue = true;
648                   switch (task.getDateState ("due"))
649                   {
650                   case Task::dateNotDue:
651                     break;
652 
653                   case Task::dateAfterToday:
654                     cellColor.blend (color_due);
655                     break;
656 
657                   case Task::dateLaterToday:
658                     cellColor.blend (color_duetoday);
659                     break;
660 
661                   case Task::dateEarlierToday:
662                   case Task::dateBeforeToday:
663                     cellColor.blend (color_overdue);
664                     break;
665                   }
666                 }
667               }
668             }
669           }
670         }
671 
672         view.set (row, thisCol, cellColor);
673       }
674 
675       // Check for end of week, and...
676       int eow = 6;
677       if (weekStart == 1)
678         eow = 0;
679       if (dow == eow && d < daysInMonth[mpl])
680         row++;
681     }
682   }
683 
684   return view.render ();
685 }
686 
687 ////////////////////////////////////////////////////////////////////////////////
688