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