feat: initialize Nocal terminal calendar

This commit is contained in:
2026-07-17 21:24:06 +01:00
commit 22c6399056
37 changed files with 6146 additions and 0 deletions

213
src/main.cpp Normal file
View File

@@ -0,0 +1,213 @@
#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;
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"
" --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"
" --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"
" t jumps to today, ? toggles help, q quits.\n";
}
Options parse_options(int argc, char** argv)
{
Options options{.calendar_path = default_calendar_path()};
bool positional_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 == "-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");
}
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 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,
.notification = {},
.notification_is_error = false,
};
std::cout << nocal::tui::render_month(events, state) << '\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);
for (const auto& warning : loaded.warnings) {
std::cerr << "nocal: " << warning << '\n';
}
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.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);
}
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)};
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;
}
}