feat: add safe calendar import and export

Add explicit noninteractive CLI transfer modes with full event validation, idempotent duplicate handling, collision refusal, atomic import backups, and no-overwrite export creation.

Cover pure merge semantics and end-to-end rollback paths for unsupported content, invalid identities, conflicts, option combinations, and existing destinations.
This commit is contained in:
2026-07-18 09:32:54 +01:00
parent 8b6cf2e877
commit b524e6e33d
11 changed files with 867 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
#include "nocal/domain/calendar_transfer.hpp"
#include "nocal/domain/date.hpp"
#include "nocal/storage/ics_store.hpp"
#include "nocal/tui/Screen.hpp"
@@ -23,6 +24,8 @@ 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;
bool demo{false};
bool print{false};
bool restore_backup{false};
@@ -50,6 +53,8 @@ void print_help(std::ostream& output)
" --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"
@@ -65,7 +70,15 @@ void print_help(std::ostream& output)
Options parse_options(int argc, char** argv)
{
Options options{.calendar_path = default_calendar_path()};
Options options{
.calendar_path = default_calendar_path(),
.import_path = std::nullopt,
.export_path = std::nullopt,
.demo = false,
.print = false,
.restore_backup = false,
.no_color = false,
};
bool positional_seen = false;
for (int index = 1; index < argc; ++index) {
const std::string_view argument{argv[index]};
@@ -85,6 +98,17 @@ Options parse_options(int argc, char** argv)
options.restore_backup = true;
} else if (argument == "--no-color") {
options.no_color = 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");
@@ -106,6 +130,20 @@ Options parse_options(int argc, char** argv)
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;
}
@@ -172,6 +210,70 @@ int print_frame(std::span<const nocal::Event> events, bool no_color)
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)
@@ -179,9 +281,7 @@ 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';
}
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(
@@ -190,6 +290,13 @@ int main(int argc, char** argv)
<< 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());