Files
nocal/src/main.cpp
Bernardo Magri 989332ef9a feat: make month week start configurable
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.
2026-07-18 09:58:27 +01:00

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;
}
}