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:
115
src/main.cpp
115
src/main.cpp
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user