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:
27
README.md
27
README.md
@@ -6,8 +6,9 @@ inherits its colors from your terminal theme.
|
|||||||
|
|
||||||
This repository currently contains the first local-calendar vertical slice:
|
This repository currently contains the first local-calendar vertical slice:
|
||||||
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
date/domain logic, guarded atomic iCalendar persistence, add/edit/delete forms,
|
||||||
a responsive ANSI terminal UI with month and agenda views, tests, and Linux
|
a responsive ANSI terminal UI with month and agenda views, explicit safe
|
||||||
launch integration. See [the product specification](docs/PRODUCT.md),
|
calendar import/export, tests, and Linux launch integration. See
|
||||||
|
[the product specification](docs/PRODUCT.md),
|
||||||
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
[architecture](docs/ARCHITECTURE.md), and [roadmap](docs/ROADMAP.md).
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
@@ -46,6 +47,28 @@ Run the tests with `meson test -C build --print-errorlogs`. Nocal reads
|
|||||||
positional argument. `--demo` adds a few unsaved sample appointments and
|
positional argument. `--demo` adds a few unsaved sample appointments and
|
||||||
`--print` emits a non-interactive frame.
|
`--print` emits a non-interactive frame.
|
||||||
|
|
||||||
|
Calendar interchange is explicit, non-interactive, and network-free:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nocal --import SOURCE.ics --calendar TARGET.ics
|
||||||
|
nocal --export DESTINATION.ics --calendar SOURCE.ics
|
||||||
|
```
|
||||||
|
|
||||||
|
Import atomically merges source events into the target. An event whose UID and
|
||||||
|
all fields exactly match an existing event is skipped. The complete operation
|
||||||
|
is rejected before the target is written if either file is unsafe to
|
||||||
|
round-trip or contains empty or duplicate UIDs or invalid intervals, or if an
|
||||||
|
imported UID collides with a different target event. A successful merge that
|
||||||
|
adds events retains the target's exact previous bytes as `TARGET.ics.bak`; an
|
||||||
|
import containing only exact duplicates does not rewrite the target or its
|
||||||
|
backup.
|
||||||
|
|
||||||
|
Export validates the source and atomically creates a canonical calendar at the
|
||||||
|
destination. It refuses to overwrite any existing destination and rejects a
|
||||||
|
source that fails event validation or that Nocal cannot safely round-trip.
|
||||||
|
`--import` and `--export` are mutually exclusive and cannot be combined with
|
||||||
|
`--demo`, `--print`, or `--restore-backup`.
|
||||||
|
|
||||||
Nocal expands and safely round-trips a focused iCalendar recurrence subset:
|
Nocal expands and safely round-trips a focused iCalendar recurrence subset:
|
||||||
daily, weekly, monthly, and yearly rules with interval, count or until, common
|
daily, weekly, monthly, and yearly rules with interval, count or until, common
|
||||||
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA
|
weekly/monthly/yearly selectors, `EXDATE`, floating times, UTC, and system IANA
|
||||||
|
|||||||
@@ -82,6 +82,25 @@ does not consume the backup. The TUI mutates a copyable in-memory model and
|
|||||||
rolls it back if persistence fails. Its bounded undo/redo history contains only
|
rolls it back if persistence fails. Its bounded undo/redo history contains only
|
||||||
mutations that crossed the persistence boundary successfully.
|
mutations that crossed the persistence boundary successfully.
|
||||||
|
|
||||||
|
Explicit CLI import and export reuse the storage parser, safety classification,
|
||||||
|
validation, and atomic persistence boundary; these workflows do not depend on
|
||||||
|
the TUI and perform no network access. Import loads and validates both source
|
||||||
|
and target before staging a target replacement. UIDs in both files must be
|
||||||
|
non-empty and unique, every interval must be valid, and every component in both
|
||||||
|
files must be safe to round-trip. Matching UID plus identical fields is an
|
||||||
|
idempotent duplicate; matching UID with different fields is a collision that
|
||||||
|
aborts the whole operation. A committed merge writes the exact old target to
|
||||||
|
`.bak` before atomic replacement. If every source event is already present
|
||||||
|
exactly, import performs no write and does not alter the backup.
|
||||||
|
|
||||||
|
Export accepts only a valid, safely round-trippable source, serializes a
|
||||||
|
canonical calendar through private staging, and atomically creates a destination
|
||||||
|
that must not already exist. Destination refusal is part of the
|
||||||
|
commit boundary, so export never replaces an existing path. Import, export,
|
||||||
|
demo, non-interactive frame printing, and backup restoration are distinct
|
||||||
|
top-level application modes; import and export cannot be selected together or
|
||||||
|
combined with any of the other modes.
|
||||||
|
|
||||||
The current writer deliberately refuses to mutate an existing file when the
|
The current writer deliberately refuses to mutate an existing file when the
|
||||||
loader encounters information it cannot round-trip, including recurrence
|
loader encounters information it cannot round-trip, including recurrence
|
||||||
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown
|
overrides, `RDATE`, custom `VTIMEZONE` definitions, alarms, attendees, unknown
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ The full terminal is treated as a responsive canvas:
|
|||||||
|
|
||||||
The first usable foundation supports day and appointment navigation, a
|
The first usable foundation supports day and appointment navigation, a
|
||||||
full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms,
|
full-frame reader, a read-only 42-day agenda, validated add/edit/delete forms,
|
||||||
and atomic `.ics` writes.
|
atomic `.ics` writes, and explicit non-interactive calendar import/export.
|
||||||
Dense days keep every appointment keyboard-reachable even when all lines do
|
Dense days keep every appointment keyboard-reachable even when all lines do
|
||||||
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
not fit in the cell. Unknown or unsupported iCalendar content makes a source
|
||||||
read-only instead of being discarded. Saves reject external changes, retain a
|
read-only instead of being discarded. Saves reject external changes, retain a
|
||||||
@@ -101,6 +101,27 @@ agenda. Agenda navigation never persists state, and add/edit/delete remain in
|
|||||||
the month workflow. The next local-data work adds advanced recurrence overrides
|
the month workflow. The next local-data work adds advanced recurrence overrides
|
||||||
and rule editing.
|
and rule editing.
|
||||||
|
|
||||||
|
## Local file interchange
|
||||||
|
|
||||||
|
Import and export are explicit command-line workflows. They never contact the
|
||||||
|
network and do not enter the interactive TUI.
|
||||||
|
|
||||||
|
- `nocal --import SOURCE --calendar TARGET` atomically merges events into the
|
||||||
|
target. Exact same-UID, same-field events are duplicates and are skipped. A
|
||||||
|
differing same-UID event, an empty or duplicate UID or invalid event interval
|
||||||
|
in either file, or content in either file that cannot be safely round-tripped
|
||||||
|
rejects the complete import before the target is written. A successful
|
||||||
|
merge keeps the target's exact previous bytes as its adjacent `.bak`; a
|
||||||
|
duplicate-only import leaves both target and backup untouched.
|
||||||
|
- `nocal --export DESTINATION --calendar SOURCE` validates the source and
|
||||||
|
atomically creates a canonical calendar. It never overwrites an existing
|
||||||
|
destination and refuses any source whose events are invalid or that cannot
|
||||||
|
be safely round-tripped.
|
||||||
|
|
||||||
|
The two operations are mutually exclusive and cannot be combined with demo,
|
||||||
|
frame printing, or backup restoration. These restrictions keep destructive
|
||||||
|
ambiguity out of automation and make every file-writing mode deliberate.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
- Information is never encoded by color alone.
|
- Information is never encoded by color alone.
|
||||||
|
|||||||
@@ -12,13 +12,14 @@
|
|||||||
- Occurrence-aware appointment search with exact result-to-grid navigation
|
- Occurrence-aware appointment search with exact result-to-grid navigation
|
||||||
- Session calendar toggles shared by month rendering, focus, and search
|
- Session calendar toggles shared by month rendering, focus, and search
|
||||||
- Read-only 42-day agenda with bounded recurrence and exact return navigation
|
- Read-only 42-day agenda with bounded recurrence and exact return navigation
|
||||||
|
- Explicit atomic import/export with validation, collision refusal, and backups
|
||||||
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
|
- Meson build, Nix development shell, desktop entry, and Hyprland launcher
|
||||||
- Seeded sample data when explicitly requested, never silent data mutation
|
- Seeded sample data when explicitly requested, never silent data mutation
|
||||||
|
|
||||||
## 0.1 — complete local calendar
|
## 0.1 — complete local calendar
|
||||||
|
|
||||||
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support
|
- Advanced recurrence editing, RDATE/overrides, and embedded VTIMEZONE support
|
||||||
- Import/export and configurable week start
|
- Configurable week start
|
||||||
- Screen-reader-friendly linear view and full Unicode display-width handling
|
- Screen-reader-friendly linear view and full Unicode display-width handling
|
||||||
|
|
||||||
## 0.2 — durable cache and provider boundary
|
## 0.2 — durable cache and provider boundary
|
||||||
|
|||||||
28
include/nocal/domain/calendar_transfer.hpp
Normal file
28
include/nocal/domain/calendar_transfer.hpp
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/event.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
|
||||||
|
struct ImportMergeResult {
|
||||||
|
std::vector<Event> events;
|
||||||
|
std::size_t imported{};
|
||||||
|
std::size_t duplicates_skipped{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transfer inputs must contain valid intervals and one non-empty, unique UID
|
||||||
|
// per event. Invalid input throws std::invalid_argument before any persistence
|
||||||
|
// boundary is crossed.
|
||||||
|
void validate_events_for_export(std::span<const Event> events);
|
||||||
|
|
||||||
|
// Preserves target order and appends new source events in source order. An
|
||||||
|
// exactly equivalent source/target UID is skipped; a differing UID collision
|
||||||
|
// throws std::invalid_argument and returns no partial result.
|
||||||
|
[[nodiscard]] ImportMergeResult merge_events_for_import(
|
||||||
|
std::span<const Event> target, std::span<const Event> source);
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "nocal/domain/calendar_transfer.hpp"
|
||||||
#include "nocal/domain/date.hpp"
|
#include "nocal/domain/date.hpp"
|
||||||
#include "nocal/domain/event.hpp"
|
#include "nocal/domain/event.hpp"
|
||||||
#include "nocal/domain/event_query.hpp"
|
#include "nocal/domain/event_query.hpp"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ project(
|
|||||||
|
|
||||||
inc = include_directories('include')
|
inc = include_directories('include')
|
||||||
nocal_sources = files(
|
nocal_sources = files(
|
||||||
|
'src/domain/calendar_transfer.cpp',
|
||||||
'src/domain/date.cpp',
|
'src/domain/date.cpp',
|
||||||
'src/domain/event_query.cpp',
|
'src/domain/event_query.cpp',
|
||||||
'src/domain/month_layout.cpp',
|
'src/domain/month_layout.cpp',
|
||||||
@@ -30,6 +31,10 @@ nocal_executable = executable('nocal', 'src/main.cpp', include_directories: inc,
|
|||||||
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
domain_tests = executable('domain-tests', 'tests/domain_tests.cpp',
|
||||||
include_directories: inc, link_with: nocal_lib)
|
include_directories: inc, link_with: nocal_lib)
|
||||||
test('domain', domain_tests)
|
test('domain', domain_tests)
|
||||||
|
calendar_transfer_tests = executable('calendar-transfer-tests',
|
||||||
|
'tests/calendar_transfer_tests.cpp', include_directories: inc,
|
||||||
|
link_with: nocal_lib)
|
||||||
|
test('calendar-transfer', calendar_transfer_tests)
|
||||||
ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
|
ics_tests = executable('ics-tests', 'tests/ics_tests.cpp',
|
||||||
include_directories: inc, link_with: nocal_lib)
|
include_directories: inc, link_with: nocal_lib)
|
||||||
test('ics-storage', ics_tests)
|
test('ics-storage', ics_tests)
|
||||||
@@ -46,6 +51,8 @@ test('editor', editor_tests)
|
|||||||
shell = find_program('sh')
|
shell = find_program('sh')
|
||||||
test('cli-recovery', shell,
|
test('cli-recovery', shell,
|
||||||
args: [files('tests/cli_recovery_test.sh'), nocal_executable])
|
args: [files('tests/cli_recovery_test.sh'), nocal_executable])
|
||||||
|
test('cli-transfer', shell,
|
||||||
|
args: [files('tests/cli_transfer_test.sh'), nocal_executable])
|
||||||
|
|
||||||
install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
|
install_data('scripts/nocal-hyprland', install_dir: get_option('bindir'),
|
||||||
install_mode: 'rwxr-xr-x')
|
install_mode: 'rwxr-xr-x')
|
||||||
|
|||||||
93
src/domain/calendar_transfer.cpp
Normal file
93
src/domain/calendar_transfer.cpp
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#include "nocal/domain/calendar_transfer.hpp"
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
namespace nocal {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
[[nodiscard]] bool events_are_semantically_equal(const Event& left,
|
||||||
|
const Event& right)
|
||||||
|
{
|
||||||
|
return left.uid == right.uid && left.title == right.title &&
|
||||||
|
left.start == right.start && left.end == right.end &&
|
||||||
|
left.all_day == right.all_day && left.location == right.location &&
|
||||||
|
left.description == right.description &&
|
||||||
|
left.calendar_id == right.calendar_id && left.color == right.color &&
|
||||||
|
left.time_basis == right.time_basis &&
|
||||||
|
left.time_zone == right.time_zone &&
|
||||||
|
left.recurrence == right.recurrence &&
|
||||||
|
left.recurrence_exceptions == right.recurrence_exceptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void validate_events(const std::span<const Event> events,
|
||||||
|
const std::string_view collection_name)
|
||||||
|
{
|
||||||
|
std::unordered_set<std::string_view> uids;
|
||||||
|
uids.reserve(events.size());
|
||||||
|
|
||||||
|
for (const auto& event : events) {
|
||||||
|
if (event.uid.empty()) {
|
||||||
|
throw std::invalid_argument{std::string{collection_name} +
|
||||||
|
" event has an empty UID"};
|
||||||
|
}
|
||||||
|
if (!has_valid_interval(event)) {
|
||||||
|
throw std::invalid_argument{std::string{collection_name} +
|
||||||
|
" event '" + event.uid +
|
||||||
|
"' ends before it starts"};
|
||||||
|
}
|
||||||
|
if (!uids.emplace(event.uid).second) {
|
||||||
|
throw std::invalid_argument{std::string{collection_name} +
|
||||||
|
" contains duplicate UID '" + event.uid +
|
||||||
|
"'"};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void validate_events_for_export(const std::span<const Event> events)
|
||||||
|
{
|
||||||
|
validate_events(events, "export");
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportMergeResult merge_events_for_import(const std::span<const Event> target,
|
||||||
|
const std::span<const Event> source)
|
||||||
|
{
|
||||||
|
validate_events(target, "target");
|
||||||
|
validate_events(source, "source");
|
||||||
|
|
||||||
|
std::unordered_map<std::string_view, const Event*> target_by_uid;
|
||||||
|
target_by_uid.reserve(target.size());
|
||||||
|
for (const auto& event : target) target_by_uid.emplace(event.uid, &event);
|
||||||
|
|
||||||
|
std::size_t imported = 0;
|
||||||
|
std::size_t duplicates_skipped = 0;
|
||||||
|
for (const auto& event : source) {
|
||||||
|
const auto match = target_by_uid.find(event.uid);
|
||||||
|
if (match == target_by_uid.end()) {
|
||||||
|
++imported;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!events_are_semantically_equal(*match->second, event)) {
|
||||||
|
throw std::invalid_argument{"import UID collision for '" + event.uid +
|
||||||
|
"' differs from the target event"};
|
||||||
|
}
|
||||||
|
++duplicates_skipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportMergeResult result;
|
||||||
|
result.events.reserve(target.size() + imported);
|
||||||
|
result.events.insert(result.events.end(), target.begin(), target.end());
|
||||||
|
for (const auto& event : source) {
|
||||||
|
if (!target_by_uid.contains(event.uid)) result.events.push_back(event);
|
||||||
|
}
|
||||||
|
result.imported = imported;
|
||||||
|
result.duplicates_skipped = duplicates_skipped;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nocal
|
||||||
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/domain/date.hpp"
|
||||||
#include "nocal/storage/ics_store.hpp"
|
#include "nocal/storage/ics_store.hpp"
|
||||||
#include "nocal/tui/Screen.hpp"
|
#include "nocal/tui/Screen.hpp"
|
||||||
@@ -23,6 +24,8 @@ constexpr std::string_view version = "0.1.0";
|
|||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
std::filesystem::path calendar_path;
|
std::filesystem::path calendar_path;
|
||||||
|
std::optional<std::filesystem::path> import_path;
|
||||||
|
std::optional<std::filesystem::path> export_path;
|
||||||
bool demo{false};
|
bool demo{false};
|
||||||
bool print{false};
|
bool print{false};
|
||||||
bool restore_backup{false};
|
bool restore_backup{false};
|
||||||
@@ -50,6 +53,8 @@ void print_help(std::ostream& output)
|
|||||||
" --demo add sample appointments without saving them\n"
|
" --demo add sample appointments without saving them\n"
|
||||||
" --print print one plain-text frame and exit\n"
|
" --print print one plain-text frame and exit\n"
|
||||||
" --restore-backup restore PATH.bak over PATH 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"
|
" --no-color disable ANSI styling\n"
|
||||||
" -h, --help show this help\n"
|
" -h, --help show this help\n"
|
||||||
" -V, --version show the version\n\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 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;
|
bool positional_seen = false;
|
||||||
for (int index = 1; index < argc; ++index) {
|
for (int index = 1; index < argc; ++index) {
|
||||||
const std::string_view argument{argv[index]};
|
const std::string_view argument{argv[index]};
|
||||||
@@ -85,6 +98,17 @@ Options parse_options(int argc, char** argv)
|
|||||||
options.restore_backup = true;
|
options.restore_backup = true;
|
||||||
} else if (argument == "--no-color") {
|
} else if (argument == "--no-color") {
|
||||||
options.no_color = true;
|
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") {
|
} else if (argument == "-c" || argument == "--calendar") {
|
||||||
if (++index >= argc) {
|
if (++index >= argc) {
|
||||||
throw std::invalid_argument(std::string{argument} + " requires a path");
|
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) {
|
if (options.restore_backup && options.print) {
|
||||||
throw std::invalid_argument("--restore-backup cannot be combined with --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;
|
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;
|
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
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv)
|
int main(int argc, char** argv)
|
||||||
@@ -179,9 +281,7 @@ int main(int argc, char** argv)
|
|||||||
try {
|
try {
|
||||||
const auto options = parse_options(argc, argv);
|
const auto options = parse_options(argc, argv);
|
||||||
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
auto loaded = nocal::storage::IcsStore::load(options.calendar_path);
|
||||||
for (const auto& warning : loaded.warnings) {
|
print_warnings(loaded, options.calendar_path);
|
||||||
std::cerr << "nocal: " << warning << '\n';
|
|
||||||
}
|
|
||||||
if (options.restore_backup) {
|
if (options.restore_backup) {
|
||||||
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
|
const auto backup = nocal::storage::IcsStore::backup_path(options.calendar_path);
|
||||||
nocal::storage::IcsStore::restore_backup(
|
nocal::storage::IcsStore::restore_backup(
|
||||||
@@ -190,6 +290,13 @@ int main(int argc, char** argv)
|
|||||||
<< options.calendar_path.string() << '\n';
|
<< options.calendar_path.string() << '\n';
|
||||||
return std::cout ? EXIT_SUCCESS : EXIT_FAILURE;
|
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) {
|
if (options.demo) {
|
||||||
auto samples = demo_events();
|
auto samples = demo_events();
|
||||||
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
|
loaded.events.insert(loaded.events.end(), samples.begin(), samples.end());
|
||||||
|
|||||||
279
tests/calendar_transfer_tests.cpp
Normal file
279
tests/calendar_transfer_tests.cpp
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#include "nocal/domain/calendar_transfer.hpp"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <functional>
|
||||||
|
#include <iostream>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void check(const bool condition, const std::string_view message)
|
||||||
|
{
|
||||||
|
if (!condition) {
|
||||||
|
++failures;
|
||||||
|
std::cerr << "FAIL: " << message << '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <class Function>
|
||||||
|
void check_invalid_argument(Function&& function, const std::string_view message)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
std::forward<Function>(function)();
|
||||||
|
check(false, message);
|
||||||
|
} catch (const std::invalid_argument&) {
|
||||||
|
} catch (...) {
|
||||||
|
check(false, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] nocal::TimePoint at(const int hour, const int minute = 0)
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
return nocal::TimePoint{hours{24 * 20'000 + hour} + minutes{minute}};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] nocal::Event complete_event(std::string uid)
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
nocal::Event event;
|
||||||
|
event.uid = std::move(uid);
|
||||||
|
event.title = "Planning";
|
||||||
|
event.start = at(9);
|
||||||
|
event.end = at(10, 30);
|
||||||
|
event.all_day = false;
|
||||||
|
event.location = "Room 3";
|
||||||
|
event.description = "Discuss launch";
|
||||||
|
event.calendar_id = "work";
|
||||||
|
event.color = "blue";
|
||||||
|
event.time_basis = nocal::TimeBasis::zoned;
|
||||||
|
event.time_zone = "Europe/London";
|
||||||
|
nocal::RecurrenceRule recurrence;
|
||||||
|
recurrence.frequency = nocal::RecurrenceFrequency::weekly;
|
||||||
|
recurrence.interval = 2;
|
||||||
|
recurrence.count = 6;
|
||||||
|
recurrence.until = at(24);
|
||||||
|
recurrence.by_weekday = {Monday, Wednesday};
|
||||||
|
recurrence.by_month_day = {1, -1};
|
||||||
|
recurrence.by_month = {1, 7};
|
||||||
|
event.recurrence = std::move(recurrence);
|
||||||
|
event.recurrence_exceptions = {at(16), at(18)};
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool equal(const nocal::Event& left, const nocal::Event& right)
|
||||||
|
{
|
||||||
|
return left.uid == right.uid && left.title == right.title &&
|
||||||
|
left.start == right.start && left.end == right.end &&
|
||||||
|
left.all_day == right.all_day && left.location == right.location &&
|
||||||
|
left.description == right.description &&
|
||||||
|
left.calendar_id == right.calendar_id && left.color == right.color &&
|
||||||
|
left.time_basis == right.time_basis &&
|
||||||
|
left.time_zone == right.time_zone &&
|
||||||
|
left.recurrence == right.recurrence &&
|
||||||
|
left.recurrence_exceptions == right.recurrence_exceptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool equal(const std::vector<nocal::Event>& left,
|
||||||
|
const std::vector<nocal::Event>& right)
|
||||||
|
{
|
||||||
|
if (left.size() != right.size()) return false;
|
||||||
|
for (std::size_t index = 0; index < left.size(); ++index) {
|
||||||
|
if (!equal(left[index], right[index])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_export_validation()
|
||||||
|
{
|
||||||
|
nocal::validate_events_for_export({});
|
||||||
|
const std::vector valid{complete_event("one"), complete_event("two")};
|
||||||
|
nocal::validate_events_for_export(valid);
|
||||||
|
|
||||||
|
auto missing_uid = valid;
|
||||||
|
missing_uid[0].uid.clear();
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { nocal::validate_events_for_export(missing_uid); },
|
||||||
|
"export rejects an empty UID");
|
||||||
|
|
||||||
|
auto duplicate_uid = valid;
|
||||||
|
duplicate_uid[1].uid = duplicate_uid[0].uid;
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { nocal::validate_events_for_export(duplicate_uid); },
|
||||||
|
"export rejects duplicate UIDs even when event fields differ");
|
||||||
|
|
||||||
|
auto backwards = valid;
|
||||||
|
backwards[0].end = backwards[0].start - std::chrono::minutes{1};
|
||||||
|
check_invalid_argument([&] { nocal::validate_events_for_export(backwards); },
|
||||||
|
"export rejects a backwards interval");
|
||||||
|
|
||||||
|
auto zero_duration = complete_event("instant");
|
||||||
|
zero_duration.end = zero_duration.start;
|
||||||
|
nocal::validate_events_for_export(std::vector{zero_duration});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_merge_success_order_and_counts()
|
||||||
|
{
|
||||||
|
auto first = complete_event("target-one");
|
||||||
|
auto second = complete_event("target-two");
|
||||||
|
second.title = "Second target";
|
||||||
|
auto duplicate = second;
|
||||||
|
auto third = complete_event("source-one");
|
||||||
|
third.title = "First import";
|
||||||
|
auto fourth = complete_event("source-two");
|
||||||
|
fourth.title = "Second import";
|
||||||
|
|
||||||
|
const std::vector target{first, second};
|
||||||
|
const std::vector source{third, duplicate, fourth};
|
||||||
|
const auto result = nocal::merge_events_for_import(target, source);
|
||||||
|
|
||||||
|
check(result.events.size() == 4, "merge returns target and new source events");
|
||||||
|
check(result.imported == 2, "merge reports the number imported");
|
||||||
|
check(result.duplicates_skipped == 1,
|
||||||
|
"merge reports exact duplicates skipped");
|
||||||
|
check(result.events.size() == 4 && result.events[0].uid == "target-one" &&
|
||||||
|
result.events[1].uid == "target-two" &&
|
||||||
|
result.events[2].uid == "source-one" &&
|
||||||
|
result.events[3].uid == "source-two",
|
||||||
|
"merge preserves target order and appends source order");
|
||||||
|
check(equal(result.events[1], duplicate),
|
||||||
|
"an all-field exact duplicate is skipped without replacing target");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_every_event_field_participates_in_collision_equality()
|
||||||
|
{
|
||||||
|
using Mutation = std::pair<std::string_view, std::function<void(nocal::Event&)>>;
|
||||||
|
const std::vector<Mutation> mutations{
|
||||||
|
{"title", [](auto& event) { event.title += " changed"; }},
|
||||||
|
{"start", [](auto& event) { event.start += std::chrono::minutes{1}; }},
|
||||||
|
{"end", [](auto& event) { event.end += std::chrono::minutes{1}; }},
|
||||||
|
{"all-day", [](auto& event) { event.all_day = !event.all_day; }},
|
||||||
|
{"location", [](auto& event) { event.location += " changed"; }},
|
||||||
|
{"description", [](auto& event) { event.description += " changed"; }},
|
||||||
|
{"calendar ID", [](auto& event) { event.calendar_id += "-other"; }},
|
||||||
|
{"color", [](auto& event) { event.color += "-other"; }},
|
||||||
|
{"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }},
|
||||||
|
{"time zone", [](auto& event) { event.time_zone = "America/New_York"; }},
|
||||||
|
{"recurrence", [](auto& event) { event.recurrence->interval += 1; }},
|
||||||
|
{"recurrence exclusions",
|
||||||
|
[](auto& event) { event.recurrence_exceptions.push_back(at(19)); }},
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::vector target{complete_event("same-uid")};
|
||||||
|
for (const auto& [field, mutate] : mutations) {
|
||||||
|
auto collision = target[0];
|
||||||
|
mutate(collision);
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] {
|
||||||
|
(void)nocal::merge_events_for_import(
|
||||||
|
target, std::vector<nocal::Event>{collision});
|
||||||
|
},
|
||||||
|
std::string{"UID collision rejects a differing "} +
|
||||||
|
std::string{field});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto removed_recurrence = target[0];
|
||||||
|
removed_recurrence.recurrence.reset();
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] {
|
||||||
|
(void)nocal::merge_events_for_import(
|
||||||
|
target, std::vector<nocal::Event>{removed_recurrence});
|
||||||
|
},
|
||||||
|
"UID collision rejects recurrence presence mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_merge_validates_both_collections_before_result()
|
||||||
|
{
|
||||||
|
const auto target_event = complete_event("target");
|
||||||
|
const auto source_event = complete_event("source");
|
||||||
|
|
||||||
|
auto empty_target_uid = std::vector{target_event};
|
||||||
|
empty_target_uid[0].uid.clear();
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import(empty_target_uid, {}); },
|
||||||
|
"merge rejects an empty target UID");
|
||||||
|
|
||||||
|
const std::vector duplicate_target{target_event, target_event};
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import(duplicate_target, {}); },
|
||||||
|
"merge rejects duplicate target UIDs");
|
||||||
|
|
||||||
|
auto empty_source_uid = std::vector{source_event};
|
||||||
|
empty_source_uid[0].uid.clear();
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import({}, empty_source_uid); },
|
||||||
|
"merge rejects an empty source UID");
|
||||||
|
|
||||||
|
const std::vector duplicate_source{source_event, source_event};
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import({}, duplicate_source); },
|
||||||
|
"merge rejects duplicate source UIDs");
|
||||||
|
|
||||||
|
auto invalid_target = std::vector{target_event};
|
||||||
|
invalid_target[0].end = invalid_target[0].start - std::chrono::seconds{1};
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import(invalid_target, {}); },
|
||||||
|
"merge rejects a backwards target interval");
|
||||||
|
|
||||||
|
auto invalid_source = std::vector{source_event};
|
||||||
|
invalid_source[0].end = invalid_source[0].start - std::chrono::seconds{1};
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import({}, invalid_source); },
|
||||||
|
"merge rejects a backwards source interval");
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_inputs_are_not_mutated_on_failure()
|
||||||
|
{
|
||||||
|
auto target_event = complete_event("collision");
|
||||||
|
auto source_event = target_event;
|
||||||
|
source_event.description = "Different data";
|
||||||
|
source_event.recurrence_exceptions.push_back(at(21));
|
||||||
|
|
||||||
|
std::vector target{target_event};
|
||||||
|
std::vector source{source_event};
|
||||||
|
const auto target_before = target;
|
||||||
|
const auto source_before = source;
|
||||||
|
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import(target, source); },
|
||||||
|
"a differing collision fails the whole merge");
|
||||||
|
check(equal(target, target_before), "failed merge does not mutate target input");
|
||||||
|
check(equal(source, source_before), "failed merge does not mutate source input");
|
||||||
|
|
||||||
|
source.push_back(complete_event("new-before-invalid"));
|
||||||
|
auto invalid = complete_event("invalid-after-new");
|
||||||
|
invalid.end = invalid.start - std::chrono::hours{1};
|
||||||
|
source.push_back(invalid);
|
||||||
|
const auto source_with_invalid_before = source;
|
||||||
|
check_invalid_argument(
|
||||||
|
[&] { (void)nocal::merge_events_for_import(target, source); },
|
||||||
|
"source validation fails before exposing an earlier import");
|
||||||
|
check(equal(source, source_with_invalid_before),
|
||||||
|
"validation failure after a valid source item leaves source unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
test_export_validation();
|
||||||
|
test_merge_success_order_and_counts();
|
||||||
|
test_every_event_field_participates_in_collision_equality();
|
||||||
|
test_merge_validates_both_collections_before_result();
|
||||||
|
test_inputs_are_not_mutated_on_failure();
|
||||||
|
|
||||||
|
if (failures != 0) {
|
||||||
|
std::cerr << failures << " calendar transfer test(s) failed\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::cout << "calendar transfer tests passed\n";
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
280
tests/cli_transfer_test.sh
Normal file
280
tests/cli_transfer_test.sh
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "usage: cli_transfer_test.sh /path/to/nocal" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
nocal=$1
|
||||||
|
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-transfer.XXXXXX")
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
fail()
|
||||||
|
{
|
||||||
|
echo "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_failure()
|
||||||
|
{
|
||||||
|
description=$1
|
||||||
|
shift
|
||||||
|
if "$@" >"$tmpdir/out" 2>"$tmpdir/err"; then
|
||||||
|
fail "$description unexpectedly succeeded"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_target()
|
||||||
|
{
|
||||||
|
path=$1
|
||||||
|
cat >"$path" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:existing@nocal.test
|
||||||
|
DTSTART:20260720T090000
|
||||||
|
DTEND:20260720T100000
|
||||||
|
SUMMARY:Existing appointment
|
||||||
|
LOCATION:Desk
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
source_calendar=$tmpdir/source.ics
|
||||||
|
cat >"$source_calendar" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:existing@nocal.test
|
||||||
|
DTSTART:20260720T090000
|
||||||
|
DTEND:20260720T100000
|
||||||
|
SUMMARY:Existing appointment
|
||||||
|
LOCATION:Desk
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:imported@nocal.test
|
||||||
|
DTSTART:20260721T140000Z
|
||||||
|
DTEND:20260721T150000Z
|
||||||
|
SUMMARY:Imported appointment
|
||||||
|
DESCRIPTION:Transferred deterministically
|
||||||
|
X-NOCAL-CALENDAR-ID:work
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Import merges new appointments, skips exact duplicates, and preserves the
|
||||||
|
# exact pre-import bytes as the adjacent backup.
|
||||||
|
target=$tmpdir/target.ics
|
||||||
|
write_target "$target"
|
||||||
|
cp "$target" "$tmpdir/target-before.ics"
|
||||||
|
import_summary=$(
|
||||||
|
"$nocal" --import "$source_calendar" --calendar "$target"
|
||||||
|
)
|
||||||
|
printf '%s\n' "$import_summary" | grep -E 'Imported[[:space:]]+1([^0-9]|$)' >/dev/null ||
|
||||||
|
fail "import summary does not report one imported appointment"
|
||||||
|
printf '%s\n' "$import_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
|
||||||
|
fail "import summary does not report one skipped duplicate"
|
||||||
|
cmp "$target.bak" "$tmpdir/target-before.ics" >/dev/null ||
|
||||||
|
fail "successful import backup does not contain the exact prior target bytes"
|
||||||
|
grep -F 'UID:existing@nocal.test' "$target" >/dev/null
|
||||||
|
grep -F 'UID:imported@nocal.test' "$target" >/dev/null
|
||||||
|
|
||||||
|
# A source containing only an exact duplicate must not rewrite the target or
|
||||||
|
# create a backup.
|
||||||
|
duplicate_source=$tmpdir/duplicate-source.ics
|
||||||
|
write_target "$duplicate_source"
|
||||||
|
duplicate_target=$tmpdir/duplicate-target.ics
|
||||||
|
write_target "$duplicate_target"
|
||||||
|
cp "$duplicate_target" "$tmpdir/duplicate-target-before.ics"
|
||||||
|
duplicate_summary=$(
|
||||||
|
"$nocal" --import "$duplicate_source" --calendar "$duplicate_target"
|
||||||
|
)
|
||||||
|
printf '%s\n' "$duplicate_summary" | grep -E 'Imported[[:space:]]+0([^0-9]|$)' >/dev/null ||
|
||||||
|
fail "no-op import summary does not report zero imported appointments"
|
||||||
|
printf '%s\n' "$duplicate_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
|
||||||
|
fail "no-op import summary does not report one skipped duplicate"
|
||||||
|
cmp "$duplicate_target" "$tmpdir/duplicate-target-before.ics" >/dev/null ||
|
||||||
|
fail "exact-duplicate import rewrote the target"
|
||||||
|
[ ! -e "$duplicate_target.bak" ] ||
|
||||||
|
fail "exact-duplicate import created a backup despite performing no write"
|
||||||
|
|
||||||
|
# A differing appointment with the same UID is a conflict. No target or
|
||||||
|
# backup may be produced by the failed mutation.
|
||||||
|
conflict_source=$tmpdir/conflict-source.ics
|
||||||
|
cat >"$conflict_source" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:existing@nocal.test
|
||||||
|
DTSTART:20260720T090000
|
||||||
|
DTEND:20260720T100000
|
||||||
|
SUMMARY:Conflicting appointment
|
||||||
|
LOCATION:Desk
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
conflict_target=$tmpdir/conflict-target.ics
|
||||||
|
write_target "$conflict_target"
|
||||||
|
cp "$conflict_target" "$tmpdir/conflict-target-before.ics"
|
||||||
|
expect_failure "conflicting-UID import" \
|
||||||
|
"$nocal" --import "$conflict_source" --calendar "$conflict_target"
|
||||||
|
cmp "$conflict_target" "$tmpdir/conflict-target-before.ics" >/dev/null ||
|
||||||
|
fail "conflicting-UID import changed the target"
|
||||||
|
[ ! -e "$conflict_target.bak" ] ||
|
||||||
|
fail "conflicting-UID import created a backup"
|
||||||
|
|
||||||
|
missing_uid=$tmpdir/missing-uid.ics
|
||||||
|
cat >"$missing_uid" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTART:20260722T110000
|
||||||
|
DTEND:20260722T120000
|
||||||
|
SUMMARY:Missing identity
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
duplicate_uid=$tmpdir/duplicate-uid.ics
|
||||||
|
cat >"$duplicate_uid" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:duplicate@nocal.test
|
||||||
|
DTSTART:20260722T110000
|
||||||
|
DTEND:20260722T120000
|
||||||
|
SUMMARY:First duplicate identity
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:duplicate@nocal.test
|
||||||
|
DTSTART:20260723T110000
|
||||||
|
DTEND:20260723T120000
|
||||||
|
SUMMARY:Second duplicate identity
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
unsupported=$tmpdir/unsupported.ics
|
||||||
|
cat >"$unsupported" <<'EOF'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Nocal transfer CLI test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:unsupported@nocal.test
|
||||||
|
DTSTART:20260722T110000
|
||||||
|
DTEND:20260722T120000
|
||||||
|
SUMMARY:Unsupported appointment
|
||||||
|
ATTENDEE:mailto:person@example.test
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Unsafe import sources are rejected before the target persistence boundary.
|
||||||
|
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
|
||||||
|
unsafe_name=$(basename "$unsafe_source" .ics)
|
||||||
|
unsafe_target=$tmpdir/import-$unsafe_name-target.ics
|
||||||
|
write_target "$unsafe_target"
|
||||||
|
cp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics"
|
||||||
|
expect_failure "$unsafe_name import" \
|
||||||
|
"$nocal" --import "$unsafe_source" --calendar "$unsafe_target"
|
||||||
|
cmp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics" >/dev/null ||
|
||||||
|
fail "$unsafe_name import changed the target"
|
||||||
|
[ ! -e "$unsafe_target.bak" ] ||
|
||||||
|
fail "$unsafe_name import created a backup"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Identity validation applies equally to the target and leaves its exact bytes
|
||||||
|
# untouched when it fails.
|
||||||
|
duplicate_uid_target=$tmpdir/duplicate-uid-target.ics
|
||||||
|
cp "$duplicate_uid" "$duplicate_uid_target"
|
||||||
|
cp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics"
|
||||||
|
expect_failure "duplicate-UID target import" \
|
||||||
|
"$nocal" --import "$source_calendar" --calendar "$duplicate_uid_target"
|
||||||
|
cmp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics" >/dev/null ||
|
||||||
|
fail "import changed a duplicate-UID target"
|
||||||
|
[ ! -e "$duplicate_uid_target.bak" ] ||
|
||||||
|
fail "import into a duplicate-UID target created a backup"
|
||||||
|
|
||||||
|
# An unsafe target is read-only even when the source is valid.
|
||||||
|
read_only_target=$tmpdir/read-only-target.ics
|
||||||
|
cp "$unsupported" "$read_only_target"
|
||||||
|
cp "$read_only_target" "$tmpdir/read-only-target-before.ics"
|
||||||
|
expect_failure "read-only target import" \
|
||||||
|
"$nocal" --import "$source_calendar" --calendar "$read_only_target"
|
||||||
|
cmp "$read_only_target" "$tmpdir/read-only-target-before.ics" >/dev/null ||
|
||||||
|
fail "import changed a read-only target"
|
||||||
|
[ ! -e "$read_only_target.bak" ] ||
|
||||||
|
fail "import into a read-only target created a backup"
|
||||||
|
|
||||||
|
# Export creates a new parseable calendar. Re-exporting that calendar yields
|
||||||
|
# identical canonical bytes, demonstrating equivalent parsed appointments.
|
||||||
|
exported=$tmpdir/exported.ics
|
||||||
|
export_summary=$(
|
||||||
|
"$nocal" --export "$exported" --calendar "$source_calendar"
|
||||||
|
)
|
||||||
|
printf '%s\n' "$export_summary" | grep -E 'Exported[[:space:]]+2([^0-9]|$)' >/dev/null ||
|
||||||
|
fail "export summary does not report two exported appointments"
|
||||||
|
[ -f "$exported" ] || fail "successful export did not create its destination"
|
||||||
|
[ ! -e "$exported.bak" ] || fail "successful new export unexpectedly created a backup"
|
||||||
|
canonical_copy=$tmpdir/exported-again.ics
|
||||||
|
"$nocal" --export "$canonical_copy" --calendar "$exported" >"$tmpdir/out"
|
||||||
|
cmp "$exported" "$canonical_copy" >/dev/null ||
|
||||||
|
fail "exported calendar did not parse and round-trip equivalently"
|
||||||
|
|
||||||
|
# Existing export destinations are never overwritten or backed up.
|
||||||
|
existing_destination=$tmpdir/existing-destination.ics
|
||||||
|
printf '%s\n' 'sentinel destination bytes' >"$existing_destination"
|
||||||
|
cp "$existing_destination" "$tmpdir/existing-destination-before"
|
||||||
|
expect_failure "export to existing destination" \
|
||||||
|
"$nocal" --export "$existing_destination" --calendar "$source_calendar"
|
||||||
|
cmp "$existing_destination" "$tmpdir/existing-destination-before" >/dev/null ||
|
||||||
|
fail "failed export changed its existing destination"
|
||||||
|
[ ! -e "$existing_destination.bak" ] ||
|
||||||
|
fail "failed export created a backup for its existing destination"
|
||||||
|
|
||||||
|
# Unsafe export sources fail without creating any destination or backup.
|
||||||
|
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
|
||||||
|
unsafe_name=$(basename "$unsafe_source" .ics)
|
||||||
|
unsafe_destination=$tmpdir/export-$unsafe_name-output.ics
|
||||||
|
expect_failure "$unsafe_name export" \
|
||||||
|
"$nocal" --export "$unsafe_destination" --calendar "$unsafe_source"
|
||||||
|
[ ! -e "$unsafe_destination" ] ||
|
||||||
|
fail "$unsafe_name export created an output file"
|
||||||
|
[ ! -e "$unsafe_destination.bak" ] ||
|
||||||
|
fail "$unsafe_name export created an output backup"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Transfer actions are exclusive and cannot be combined with interactive,
|
||||||
|
# print, demo, or recovery actions. These parse failures must occur without a
|
||||||
|
# destination write or target backup.
|
||||||
|
option_target=$tmpdir/option-target.ics
|
||||||
|
write_target "$option_target"
|
||||||
|
cp "$option_target" "$tmpdir/option-target-before.ics"
|
||||||
|
option_export=$tmpdir/option-export.ics
|
||||||
|
|
||||||
|
expect_failure "combined import and export" \
|
||||||
|
"$nocal" --import "$source_calendar" --export "$option_export" \
|
||||||
|
--calendar "$option_target"
|
||||||
|
|
||||||
|
for conflicting_option in --demo --print --restore-backup; do
|
||||||
|
expect_failure "import with $conflicting_option" \
|
||||||
|
"$nocal" --import "$source_calendar" "$conflicting_option" \
|
||||||
|
--calendar "$option_target"
|
||||||
|
expect_failure "export with $conflicting_option" \
|
||||||
|
"$nocal" --export "$option_export" "$conflicting_option" \
|
||||||
|
--calendar "$option_target"
|
||||||
|
done
|
||||||
|
|
||||||
|
cmp "$option_target" "$tmpdir/option-target-before.ics" >/dev/null ||
|
||||||
|
fail "invalid transfer option combination changed the calendar"
|
||||||
|
[ ! -e "$option_target.bak" ] ||
|
||||||
|
fail "invalid transfer option combination created a calendar backup"
|
||||||
|
[ ! -e "$option_export" ] ||
|
||||||
|
fail "invalid transfer option combination created an export destination"
|
||||||
Reference in New Issue
Block a user