Add a Monday-default --week-start option for every weekday and carry it through domain layout, full and compact rendering, event query windows, print mode, and the interactive TUI. Preserve byte-compatible Monday output and cover rotated headers, spill dates, invalid input, sanitizer behavior, and live terminal restoration.
370 lines
15 KiB
C++
370 lines
15 KiB
C++
#include "nocal/domain/calendar_transfer.hpp"
|
|
#include "nocal/domain/date.hpp"
|
|
#include "nocal/storage/ics_store.hpp"
|
|
#include "nocal/tui/Screen.hpp"
|
|
#include "nocal/tui/TuiApp.hpp"
|
|
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <optional>
|
|
#include <span>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include <unistd.h>
|
|
|
|
namespace {
|
|
|
|
constexpr std::string_view version = "0.1.0";
|
|
|
|
struct Options {
|
|
std::filesystem::path calendar_path;
|
|
std::optional<std::filesystem::path> import_path;
|
|
std::optional<std::filesystem::path> export_path;
|
|
std::chrono::weekday week_start{std::chrono::Monday};
|
|
bool demo{false};
|
|
bool print{false};
|
|
bool restore_backup{false};
|
|
bool no_color{false};
|
|
};
|
|
|
|
std::filesystem::path default_calendar_path()
|
|
{
|
|
if (const char* data_home = std::getenv("XDG_DATA_HOME");
|
|
data_home != nullptr && *data_home != '\0') {
|
|
return std::filesystem::path{data_home} / "nocal" / "calendar.ics";
|
|
}
|
|
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
|
|
return std::filesystem::path{home} / ".local" / "share" / "nocal" / "calendar.ics";
|
|
}
|
|
return "calendar.ics";
|
|
}
|
|
|
|
void print_help(std::ostream& output)
|
|
{
|
|
output <<
|
|
"Usage: nocal [OPTIONS] [CALENDAR.ics]\n\n"
|
|
"A theme-native terminal month calendar.\n\n"
|
|
" -c, --calendar PATH read events from PATH\n"
|
|
" --week-start DAY set first weekday: monday through sunday\n"
|
|
" --demo add sample appointments without saving them\n"
|
|
" --print print one plain-text frame and exit\n"
|
|
" --restore-backup restore PATH.bak over PATH and exit\n"
|
|
" --import PATH atomically merge PATH into the calendar and exit\n"
|
|
" --export PATH atomically export to a new PATH and exit\n"
|
|
" --no-color disable ANSI styling\n"
|
|
" -h, --help show this help\n"
|
|
" -V, --version show the version\n\n"
|
|
"Keys: arrows/hjkl move days, PageUp/PageDown or p/n change month,\n"
|
|
" Tab/Shift-Tab focus appointments, Enter reads, Esc returns,\n"
|
|
" a adds, e edits, d deletes; Ctrl-S saves inside the editor,\n"
|
|
" u undoes the last change, Ctrl-R redoes it,\n"
|
|
" g opens the 42-day agenda; arrows/jk/Tab choose, Enter jumps,\n"
|
|
" PageUp/PageDown or p/n shift its window, Esc/g returns,\n"
|
|
" c toggles calendar visibility, / searches appointments,\n"
|
|
" t jumps to today, ? toggles help, q quits.\n";
|
|
}
|
|
|
|
std::optional<std::chrono::weekday> parse_week_start(const std::string_view value)
|
|
{
|
|
if (value == "monday") return std::chrono::Monday;
|
|
if (value == "tuesday") return std::chrono::Tuesday;
|
|
if (value == "wednesday") return std::chrono::Wednesday;
|
|
if (value == "thursday") return std::chrono::Thursday;
|
|
if (value == "friday") return std::chrono::Friday;
|
|
if (value == "saturday") return std::chrono::Saturday;
|
|
if (value == "sunday") return std::chrono::Sunday;
|
|
return std::nullopt;
|
|
}
|
|
|
|
Options parse_options(int argc, char** argv)
|
|
{
|
|
Options options{
|
|
.calendar_path = default_calendar_path(),
|
|
.import_path = std::nullopt,
|
|
.export_path = std::nullopt,
|
|
.week_start = std::chrono::Monday,
|
|
.demo = false,
|
|
.print = false,
|
|
.restore_backup = false,
|
|
.no_color = false,
|
|
};
|
|
bool positional_seen = false;
|
|
bool week_start_seen = false;
|
|
for (int index = 1; index < argc; ++index) {
|
|
const std::string_view argument{argv[index]};
|
|
if (argument == "-h" || argument == "--help") {
|
|
print_help(std::cout);
|
|
std::exit(EXIT_SUCCESS);
|
|
}
|
|
if (argument == "-V" || argument == "--version") {
|
|
std::cout << "nocal " << version << '\n';
|
|
std::exit(EXIT_SUCCESS);
|
|
}
|
|
if (argument == "--demo") {
|
|
options.demo = true;
|
|
} else if (argument == "--print") {
|
|
options.print = true;
|
|
} else if (argument == "--restore-backup") {
|
|
options.restore_backup = true;
|
|
} else if (argument == "--no-color") {
|
|
options.no_color = true;
|
|
} else if (argument == "--week-start") {
|
|
if (week_start_seen) {
|
|
throw std::invalid_argument("--week-start may only be supplied once");
|
|
}
|
|
if (++index >= argc) {
|
|
throw std::invalid_argument("--week-start requires a day");
|
|
}
|
|
const std::string_view value{argv[index]};
|
|
const auto parsed = parse_week_start(value);
|
|
if (!parsed) {
|
|
throw std::invalid_argument(
|
|
"invalid --week-start day: " + std::string{value} +
|
|
" (expected monday through sunday)");
|
|
}
|
|
options.week_start = *parsed;
|
|
week_start_seen = true;
|
|
} else if (argument == "--import" || argument == "--export") {
|
|
if (++index >= argc) {
|
|
throw std::invalid_argument(std::string{argument} + " requires a path");
|
|
}
|
|
auto& destination = argument == "--import"
|
|
? options.import_path : options.export_path;
|
|
if (destination) {
|
|
throw std::invalid_argument(std::string{argument} +
|
|
" may only be supplied once");
|
|
}
|
|
destination = std::filesystem::path{argv[index]};
|
|
} else if (argument == "-c" || argument == "--calendar") {
|
|
if (++index >= argc) {
|
|
throw std::invalid_argument(std::string{argument} + " requires a path");
|
|
}
|
|
options.calendar_path = argv[index];
|
|
positional_seen = true;
|
|
} else if (!argument.empty() && argument.front() == '-') {
|
|
throw std::invalid_argument("unknown option: " + std::string{argument});
|
|
} else if (positional_seen) {
|
|
throw std::invalid_argument("only one calendar path may be supplied");
|
|
} else {
|
|
options.calendar_path = argument;
|
|
positional_seen = true;
|
|
}
|
|
}
|
|
if (options.restore_backup && options.demo) {
|
|
throw std::invalid_argument("--restore-backup cannot be combined with --demo");
|
|
}
|
|
if (options.restore_backup && options.print) {
|
|
throw std::invalid_argument("--restore-backup cannot be combined with --print");
|
|
}
|
|
if (options.import_path && options.export_path) {
|
|
throw std::invalid_argument("--import cannot be combined with --export");
|
|
}
|
|
const bool transfer = options.import_path || options.export_path;
|
|
if (transfer && options.demo) {
|
|
throw std::invalid_argument("--import/--export cannot be combined with --demo");
|
|
}
|
|
if (transfer && options.print) {
|
|
throw std::invalid_argument("--import/--export cannot be combined with --print");
|
|
}
|
|
if (transfer && options.restore_backup) {
|
|
throw std::invalid_argument(
|
|
"--import/--export cannot be combined with --restore-backup");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
nocal::Date shifted(nocal::Date date, int offset)
|
|
{
|
|
return nocal::from_sys_days(nocal::to_sys_days(date) + std::chrono::days{offset});
|
|
}
|
|
|
|
std::vector<nocal::Event> demo_events()
|
|
{
|
|
const auto day = nocal::today();
|
|
nocal::Event planning;
|
|
planning.uid = "demo-planning@nocal";
|
|
planning.title = "Nomarchy planning";
|
|
planning.start = nocal::make_local_time(day, 9, 30);
|
|
planning.end = nocal::make_local_time(day, 10, 15);
|
|
planning.location = "Terminal 1";
|
|
planning.calendar_id = "work";
|
|
|
|
nocal::Event release;
|
|
release.uid = "demo-release@nocal";
|
|
release.title = "Release window";
|
|
release.start = nocal::local_day_start(shifted(day, 2));
|
|
release.end = nocal::local_day_start(shifted(day, 3));
|
|
release.all_day = true;
|
|
release.calendar_id = "work";
|
|
|
|
nocal::Event coffee;
|
|
coffee.uid = "demo-coffee@nocal";
|
|
coffee.title = "Coffee with Luna";
|
|
coffee.start = nocal::make_local_time(shifted(day, -3), 14, 0);
|
|
coffee.end = nocal::make_local_time(shifted(day, -3), 14, 45);
|
|
coffee.location = "Nomarchy café";
|
|
coffee.calendar_id = "personal";
|
|
|
|
return {std::move(planning), std::move(release), std::move(coffee)};
|
|
}
|
|
|
|
int print_frame(std::span<const nocal::Event> events, bool no_color,
|
|
const std::chrono::weekday week_start)
|
|
{
|
|
const auto day = nocal::today();
|
|
const nocal::tui::ScreenState state{
|
|
.visible_month = day.year() / day.month(), .selected_day = day, .today = day,
|
|
.width = 120, .height = 36, .show_help = false,
|
|
.ansi = !no_color && ::isatty(STDOUT_FILENO) != 0,
|
|
.focused_event_id = std::nullopt,
|
|
.show_event_details = false,
|
|
.confirm_delete = false,
|
|
.search_prompt = false,
|
|
.show_search_results = false,
|
|
.search_query = {},
|
|
.search_results = {},
|
|
.search_result_index = 0,
|
|
.notification = {},
|
|
.notification_is_error = false,
|
|
.show_calendar_picker = false,
|
|
.calendar_index = 0,
|
|
.show_agenda = false,
|
|
.agenda_start_day = day,
|
|
.agenda_items = {},
|
|
.agenda_index = 0,
|
|
.week_start = week_start,
|
|
};
|
|
std::cout << nocal::tui::render_month(events, state) << '\n';
|
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
|
}
|
|
|
|
void print_warnings(const nocal::storage::LoadResult& loaded,
|
|
const std::filesystem::path& path)
|
|
{
|
|
for (const auto& warning : loaded.warnings) {
|
|
std::cerr << "nocal: " << path.string() << ": " << warning << '\n';
|
|
}
|
|
}
|
|
|
|
void require_transfer_safe(const nocal::storage::LoadResult& loaded,
|
|
const std::string_view role)
|
|
{
|
|
if (!loaded.safe_to_rewrite) {
|
|
throw std::runtime_error(std::string{role} +
|
|
" contains unsupported iCalendar data");
|
|
}
|
|
}
|
|
|
|
int import_calendar(const std::filesystem::path& source_path,
|
|
const std::filesystem::path& target_path,
|
|
nocal::storage::LoadResult target)
|
|
{
|
|
const auto source = nocal::storage::IcsStore::load(source_path);
|
|
if (!source.revision.existed()) {
|
|
throw std::runtime_error("import source does not exist: " + source_path.string());
|
|
}
|
|
print_warnings(source, source_path);
|
|
require_transfer_safe(target, "target calendar");
|
|
require_transfer_safe(source, "import source");
|
|
auto merged = nocal::merge_events_for_import(target.events, source.events);
|
|
if (merged.imported > 0) {
|
|
(void)nocal::storage::IcsStore::save(target_path, merged.events, target.revision);
|
|
}
|
|
const auto appointment_word = merged.imported == 1 ? " appointment" : " appointments";
|
|
const auto duplicate_suffix = merged.duplicates_skipped == 1 ? "" : "s";
|
|
std::cout << "Imported " << merged.imported << appointment_word << " from "
|
|
<< source_path.string() << " into " << target_path.string() << "; "
|
|
<< merged.duplicates_skipped << " duplicate" << duplicate_suffix
|
|
<< " skipped.\n";
|
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
|
}
|
|
|
|
int export_calendar(const std::filesystem::path& source_path,
|
|
const std::filesystem::path& destination_path,
|
|
const nocal::storage::LoadResult& source)
|
|
{
|
|
if (!source.revision.existed()) {
|
|
throw std::runtime_error("export source does not exist: " + source_path.string());
|
|
}
|
|
require_transfer_safe(source, "export source");
|
|
nocal::validate_events_for_export(source.events);
|
|
const auto destination = nocal::storage::IcsStore::load(destination_path);
|
|
if (destination.revision.existed()) {
|
|
throw std::runtime_error("export destination already exists: " +
|
|
destination_path.string());
|
|
}
|
|
(void)nocal::storage::IcsStore::save(
|
|
destination_path, source.events, destination.revision);
|
|
const auto appointment_word = source.events.size() == 1
|
|
? " appointment" : " appointments";
|
|
std::cout << "Exported " << source.events.size() << appointment_word << " from "
|
|
<< source_path.string() << " to " << destination_path.string() << ".\n";
|
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv)
|
|
{
|
|
try {
|
|
const auto options = parse_options(argc, argv);
|
|
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
|
print_warnings(loaded, options.calendar_path);
|
|
if (options.restore_backup) {
|
|
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
|
|
nocal::storage::IcsStore::restore_backup(
|
|
options.calendar_path, loaded.revision);
|
|
std::cout << "Restored backup " << backup.string() << " to "
|
|
<< options.calendar_path.string() << '\n';
|
|
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
|
}
|
|
if (options.import_path) {
|
|
return import_calendar(*options.import_path, options.calendar_path,
|
|
std::move(loaded));
|
|
}
|
|
if (options.export_path) {
|
|
return export_calendar(options.calendar_path, *options.export_path, loaded);
|
|
}
|
|
if (options.demo) {
|
|
auto samples = demo_events();
|
|
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
|
|
}
|
|
const bool non_interactive = ::isatty(STDIN_FILENO) == 0 ||
|
|
::isatty(STDOUT_FILENO) == 0;
|
|
if (options.print || non_interactive) {
|
|
return print_frame(loaded.events, true, options.week_start);
|
|
}
|
|
if (options.no_color) {
|
|
::setenv("NO_COLOR", "1", 1);
|
|
}
|
|
nocal::tui::TuiApp::SaveCallback saver;
|
|
if (!options.demo) {
|
|
if (loaded.safe_to_rewrite) {
|
|
const auto calendar_path = options.calendar_path;
|
|
saver = [calendar_path, revision = loaded.revision](
|
|
const std::span<const nocal::Event> events) mutable {
|
|
revision = nocal::storage::IcsStore::save(
|
|
calendar_path, events, revision);
|
|
};
|
|
} else {
|
|
saver = [](std::span<const nocal::Event>) {
|
|
throw std::runtime_error(
|
|
"calendar is read-only because it contains unsupported iCalendar data");
|
|
};
|
|
}
|
|
}
|
|
nocal::tui::TuiApp app{
|
|
loaded.events, std::move(saver), options.week_start};
|
|
return app.run() == nocal::tui::ExitReason::io_error ? EXIT_FAILURE : EXIT_SUCCESS;
|
|
} catch (const std::exception& error) {
|
|
std::cerr << "nocal: " << error.what() << '\n';
|
|
return EXIT_FAILURE;
|
|
}
|
|
}
|