#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 #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr std::string_view version = "0.1.0"; struct Options { std::filesystem::path calendar_path; std::optional import_path; std::optional 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 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 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 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 events) mutable { revision = nocal::storage::IcsStore::save( calendar_path, events, revision); }; } else { saver = [](std::span) { 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; } }